├── .editorconfig ├── .gitattributes ├── .gitignore ├── .vscode └── extensions.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build-for-windows.js ├── docs ├── assets │ ├── anki-card-back.png │ ├── anki-card-front.png │ ├── changelog │ │ ├── 0.0.1 │ │ │ ├── anki-card-back.png │ │ │ ├── anki-card-front.png │ │ │ ├── main-screenshot.png │ │ │ └── settings-screenshot.png │ │ └── 0.0.2 │ │ │ ├── anki-card-back.png │ │ │ ├── anki-card-front.png │ │ │ ├── main-screenshot.png │ │ │ └── settings-screenshot.png │ ├── main-screenshot.png │ └── settings-screenshot.png └── tauri-develop.md ├── index.html ├── package-lock.json ├── package.json ├── public └── tauri.svg ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── capabilities │ └── migrated.json ├── gen │ └── schemas │ │ ├── acl-manifests.json │ │ ├── capabilities.json │ │ ├── desktop-schema.json │ │ ├── linux-schema.json │ │ └── windows-schema.json ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── resources │ ├── config-template.toml │ └── dict.db ├── src │ ├── application │ │ ├── config.rs │ │ ├── dict.rs │ │ ├── logics │ │ │ ├── config.rs │ │ │ ├── dict.rs │ │ │ ├── mod.rs │ │ │ └── utils.rs │ │ └── mod.rs │ └── main.rs ├── tauri.conf.json └── tauri.linux.conf.json ├── src ├── App.vue ├── assets │ ├── OpenFilled.svg │ ├── arrow-back.svg │ ├── edit.svg │ ├── github.svg │ ├── model-back.html │ ├── model-css.css │ ├── model-front.html │ ├── model-template-release-note.md │ ├── play-audio.svg │ ├── reset.svg │ ├── settings.svg │ └── zhb-avatar.png ├── components │ ├── AddButton.vue │ ├── CardStatus.ts │ ├── CollinsCard.vue │ ├── OxfordCard.vue │ ├── PlayAudioButton.vue │ ├── ResetButton.vue │ ├── ReturnButton.vue │ ├── ScrollMemory copy.vue │ ├── ScrollMemory.vue │ ├── SentencePanel.vue │ ├── SettingButton.vue │ ├── Token.vue │ ├── WordCard.vue │ ├── YoudaoCard.vue │ └── index.ts ├── fluent-controls │ ├── FluentButton.vue │ ├── FluentHyperlink.vue │ ├── FluentInput.vue │ ├── FluentRadio.vue │ ├── FluentSelect.vue │ ├── fluent-scrollbar.css │ ├── fluent-styles.css │ ├── generateUniqueId.ts │ ├── index.ts │ └── useHover.ts ├── logics │ ├── anki-connect.ts │ ├── anki.ts │ ├── config.ts │ ├── dict.ts │ ├── globals.ts │ ├── lock.ts │ ├── stringutils.ts │ ├── typing.ts │ ├── utils.ts │ └── youdao.ts ├── main.ts ├── router.ts ├── tauri-api.ts ├── views │ ├── Main.vue │ └── Settings.vue └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.db filter=lfs diff=lfs merge=lfs -text 2 | src-tauri/icons/icon.ico filter=lfs diff=lfs merge=lfs -text 3 | src-tauri/icons/Square310x310Logo.png filter=lfs diff=lfs merge=lfs -text 4 | src-tauri/icons/128x128.png filter=lfs diff=lfs merge=lfs -text 5 | src-tauri/icons/Square150x150Logo.png filter=lfs diff=lfs merge=lfs -text 6 | src-tauri/icons/Square89x89Logo.png filter=lfs diff=lfs merge=lfs -text 7 | src-tauri/icons/Square71x71Logo.png filter=lfs diff=lfs merge=lfs -text 8 | src-tauri/icons/icon.png filter=lfs diff=lfs merge=lfs -text 9 | src-tauri/icons/Square107x107Logo.png filter=lfs diff=lfs merge=lfs -text 10 | src-tauri/icons/Square142x142Logo.png filter=lfs diff=lfs merge=lfs -text 11 | src-tauri/icons/Square30x30Logo.png filter=lfs diff=lfs merge=lfs -text 12 | src-tauri/icons/Square44x44Logo.png filter=lfs diff=lfs merge=lfs -text 13 | src-tauri/icons/128x128@2x.png filter=lfs diff=lfs merge=lfs -text 14 | src-tauri/icons/32x32.png filter=lfs diff=lfs merge=lfs -text 15 | src-tauri/icons/icon.icns filter=lfs diff=lfs merge=lfs -text 16 | src-tauri/icons/Square284x284Logo.png filter=lfs diff=lfs merge=lfs -text 17 | src-tauri/icons/StoreLogo.png filter=lfs diff=lfs merge=lfs -text 18 | 19 | *.db linguist-vendored 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.local.* 2 | *.local/ 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | pnpm-debug.log* 11 | lerna-debug.log* 12 | 13 | node_modules 14 | dist 15 | dist-ssr 16 | *.local 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "tauri-apps.tauri-vscode", 5 | "rust-lang.rust-analyzer" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | ## [0.1.0] - 2025-01-02 3 | 4 | 各位朋友,新年快乐!🎉 5 | 6 | ### 划词助手更新内容 7 | 8 | - 支持**编辑单词笔记**。单词添加成功后,点击单词条目右侧的“编辑”按钮,即可打开 Anki 的卡片编辑器,对所添加的单词笔记进行编辑。 9 | - 新增应用和模板的检查更新功能。 10 | - 修复在点击配置文件的“打开目录”时出现 cmd 窗口的问题。 11 | - 修复在配置文件路径包含空格时“打开目录”可能失败的问题。 12 | 13 | ### 单词笔记模板更新内容 14 | 15 | 本次更新将划词助手的单词笔记模板升级至 0.1.0 版本,模板的更新内容如下: 16 | 17 | - 新增**例句朗读**功能,点击例句中的喇叭图标可以朗读例句,再次点击可以停止朗读。例句朗读的语音来自有道词典,需要联网使用。 18 | - 为条目的**展开和收起**添加了**动画效果**,点击条目的标题可以展开或收起条目的内容。 19 | - 其他卡片布局优化。 20 | 21 | --- 22 | 23 | 如果你使用过本应用的 0.0.1 版本或 [mmjang](https://github.com/mmjang) 开发的 Android 版([mmjang / ankihelper](https://github.com/mmjang/ankihelper)),那么需要手动确认是否**将已有的旧模板升级为新模板**: 24 | 25 | 1. 打开 Anki 软件(事先安装好 AnkiConnect 插件)。 26 | 1. 点击划词助手右上角的设置按钮,进入设置页面。 27 | 1. 点击设置页面的“更新模板”按钮,并在弹出的对话框中确认更新。 28 | 29 | 你也可以继续使用旧模板,这不会影响划词助手的使用。但是,建议你更新模板,以在复习单词时获得更好的体验。 30 | 31 | 如果你的 Anki 软件中没有划词助手的旧模板,或者你是第一次使用划词助手,那么无需进行上述操作,直接使用即可。 32 | 33 | ## [0.0.1] - 2024-03-17 34 | 35 | 第一个版本。 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Anki 划词助手 3 |
4 | 5 |

Anki 划词助手

6 | 7 | Anki 划词助手是一个制作 Anki 卡片的工具,你可以用它标记句子中的生词,通过“单词结合上下文”的方式更好地背单词。 8 | 9 | 本项目受到了 [mmjang / ankihelper](https://github.com/mmjang/ankihelper) 的启发。由于原项目是 Android 应用,而且已经不再维护,而我自己用电脑的时间更多,于是自己用 [Tauri](https://github.com/tauri-apps/tauri) 写了一个类似的工具。 10 | 11 |

12 | 主界面 13 |

14 | 15 | # 安装 16 | ## 安装划词助手本体 17 | 18 | [Releases 页面](https://github.com/zhb2000/anki-marker/releases)提供了 Windows 平台的便携式应用(.zip)和安装程序(.msi/.exe),其余平台请自行编译。 19 | 20 | Anki 划词助手是一个基于 Tauri 的桌面应用,你的 Windows 系统需要带有 [Microsoft Edge WebView2](https://developer.microsoft.com/zh-cn/microsoft-edge/webview2/) 才能运行(Windows 10 2004 及以上版本已经自带)。 21 | 22 | ## 安装辅助工具 23 | 24 | Anki 划词助手需要通过 AnkiConnect 插件与 Anki 进行通信,你需要先安装 **Anki 应用**和 **AnkiConnect 插件**: 25 | 26 | 1. 安装 Anki 应用:[Anki - powerful, intelligent flashcards](https://apps.ankiweb.net/)。 27 | 1. 安装 AnkiConnect 插件:[AnkiConnect - AnkiWeb](https://ankiweb.net/shared/info/2055492159)。安装方法如下: 28 | 1. 打开 Anki,点击“工具-插件”。 29 | 1. 点击“获取插件”,输入 AnkiConnect 的代码 `2055492159`,点击“确定”。 30 | 1. 重启 Anki 应用。 31 | 32 | # 使用 33 | 34 | 使用 Anki 划词助手时,请确保 **Anki 应用已经打开**,AnkiConnect 的服务会在 Anki 启动时自动开启。 35 | 36 | AnkiConnect 默认会在 `localhost:8765` 上启动一个 HTTP 服务,如果你修改了 AnkiConnect 的端口号,请在设置中将“AnkiConnect 服务”这一项修改为对应的 URL: 37 | 38 |

39 | 应用设置 40 |

41 | 42 | 划词界面: 43 | 44 |

45 | 主界面 46 |

47 | 48 | 添加的 Anki 卡片(正面/背面): 49 | 50 |

51 | Anki 卡片正面 52 | Anki 卡片背面 53 |

54 | 55 | # 开发 56 | 57 | 开发模式(热重载): 58 | 59 | ```shell 60 | cargo tauri dev 61 | # 或者 62 | npm run tauri dev 63 | ``` 64 | 65 | 打包成 Windows 安装程序(.msi/.exe)和便携式应用(.zip): 66 | 67 | ```shell 68 | node build-for-windows.js 69 | ``` 70 | 71 | 打包好的安装程序和便携式应用位于 `src-tauri/target/release/release-assets` 目录下。 72 | 73 | 在其他平台上构建: 74 | 75 | ```shell 76 | cargo tauri build 77 | # 或者 78 | npm run tauri build 79 | ``` 80 | 81 | 打包好的应用位于 `src-tauri/target/release/bundle` 目录下。 82 | -------------------------------------------------------------------------------- /build-for-windows.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** Build portable zip and bundle installer for Windows. */ 3 | 4 | import { spawn } from 'child_process'; 5 | import fs from 'fs'; 6 | import fsp from 'fs/promises'; // 使用 fs/promises 提供的异步方法 7 | import path from 'path'; 8 | import archiver from 'archiver'; 9 | import { fileURLToPath } from 'url'; 10 | import { dirname } from 'path'; 11 | // ES 模块中处理 __dirname 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = dirname(__filename); 14 | 15 | /** 16 | * @param {string} productName 17 | * @param {string} packageName 18 | * @param {string} version 19 | */ 20 | async function renameAndCopyAssets(productName, packageName, version) { 21 | const releaseDir = path.join(__dirname, 'src-tauri/target/release'); 22 | const assetsDir = path.join(releaseDir, 'release-assets', version); 23 | 24 | // 创建 release-assets 目录 25 | await fsp.mkdir(assetsDir, { recursive: true }); 26 | 27 | // 复制 portable zip 28 | const portableFilename = `${packageName}_${version}_windows_x64-portable.zip`; 29 | const portableOldPath = path.join(releaseDir, 'portable', portableFilename); 30 | const portableNewPath = path.join(assetsDir, portableFilename); 31 | await fsp.copyFile(portableOldPath, portableNewPath); 32 | 33 | // 复制 msi 文件 34 | const msiOldFilename = `${productName}_${version}_x64_zh-CN.msi`; 35 | const msiOldPath = path.join(releaseDir, 'bundle/msi', msiOldFilename); 36 | const msiNewFilename = `${packageName}_${version}_windows_x64.msi`; 37 | const msiNewPath = path.join(assetsDir, msiNewFilename); 38 | await fsp.copyFile(msiOldPath, msiNewPath); 39 | 40 | // 复制 nsis 文件 41 | const nsisOldFilename = `${productName}_${version}_x64-setup.exe`; 42 | const nsisOldPath = path.join(releaseDir, 'bundle/nsis', nsisOldFilename); 43 | const nsisNewFilename = `${packageName}_${version}_windows_x64-setup.exe`; 44 | const nsisNewPath = path.join(assetsDir, nsisNewFilename); 45 | await fsp.copyFile(nsisOldPath, nsisNewPath); 46 | } 47 | 48 | /** 49 | * @param {string} productName 50 | * @param {string} portableName 51 | */ 52 | async function makePortable(productName, portableName) { 53 | const releaseDir = path.join(__dirname, 'src-tauri/target/release'); 54 | const portableDir = path.join(releaseDir, 'portable'); 55 | const packDir = path.join(portableDir, portableName); 56 | 57 | // 创建 portable 目录 58 | await fsp.mkdir(packDir, { recursive: true }); 59 | 60 | // 拷贝 exe 和 resources 目录到 portable 目录 61 | await fsp.copyFile( 62 | path.join(releaseDir, `${productName}.exe`), 63 | path.join(packDir, `${productName}.exe`) 64 | ); 65 | await fsp.cp( 66 | path.join(releaseDir, 'resources'), 67 | path.join(packDir, 'resources'), 68 | { recursive: true } 69 | ); 70 | 71 | // 删除 resources/icon.ico 72 | await fsp.unlink(path.join(packDir, 'resources/icon.ico')); 73 | 74 | // 拷贝并重命名 config-template.toml 为 config.toml 75 | await fsp.copyFile( 76 | path.join(releaseDir, 'resources/config-template.toml'), 77 | path.join(packDir, 'config.toml') 78 | ); 79 | 80 | // 压缩 portable 目录为 zip 81 | console.log(`开始压缩 ${portableName}.zip ...`); 82 | await new Promise((resolve, reject) => { 83 | const output = fs.createWriteStream(path.join(portableDir, `${portableName}.zip`)); 84 | const archive = archiver('zip', { zlib: { level: 9 } }); 85 | 86 | output.on('close', () => { 87 | const sizeInBytes = archive.pointer(); 88 | const sizeInMegabytes = (sizeInBytes / (1024 * 1024)).toFixed(2); 89 | console.log(`${portableName}.zip 压缩完成,总大小: ${sizeInMegabytes} MB`); 90 | resolve(); 91 | }); 92 | 93 | archive.on('error', err => reject(err)); 94 | 95 | archive.pipe(output); 96 | archive.directory(packDir, false); 97 | archive.finalize(); 98 | }); 99 | } 100 | 101 | /** 102 | * 执行命令行命令的封装 103 | * @param {string} command 104 | * @param {string[]} args 105 | */ 106 | async function runCommand(command, args) { 107 | return new Promise((resolve, reject) => { 108 | // 使用 spawn 替代 exec 来实时显示输出 109 | // 使用 stdio: 'inherit' 保证 tauri 的彩色输出 110 | const process = spawn(command, args, { shell: true, stdio: 'inherit' }); 111 | process.on('close', code => { 112 | if (code !== 0) { 113 | reject(new Error(`Command "${command} ${args.join(' ')}" exited with code ${code}`)); 114 | } else { 115 | resolve(); 116 | } 117 | }); 118 | }); 119 | } 120 | 121 | async function main() { 122 | const skipBuild = process.argv.includes('--skip-build'); 123 | const packageJson = JSON.parse(await fsp.readFile(path.join(__dirname, 'package.json'), 'utf8')); 124 | /** @type {string} */ 125 | const version = packageJson.version; 126 | /** @type {string} */ 127 | const packageName = packageJson.name; 128 | const tauriJson = JSON.parse(await fsp.readFile(path.join(__dirname, 'src-tauri/tauri.conf.json'), 'utf8')); 129 | /** @type {string} */ 130 | const productName = tauriJson.productName; 131 | const portableName = `${packageName}_${version}_windows_x64-portable`; 132 | 133 | if (!skipBuild) { 134 | console.log('开始构建 Tauri 项目...'); 135 | await runCommand('npm', ['run', 'tauri', 'build']); 136 | } else { 137 | console.log('跳过 npm run tauri build'); 138 | } 139 | await makePortable(productName, portableName); 140 | await renameAndCopyAssets(productName, packageName, version); 141 | console.log(`构建和打包完成!请查看 src-tauri/target/release/release-assets/${version} 目录。`); 142 | } 143 | 144 | main(); 145 | -------------------------------------------------------------------------------- /docs/assets/anki-card-back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhb2000/anki-marker/e2617df927a1c674e3c4d49ea63df52738d971a9/docs/assets/anki-card-back.png -------------------------------------------------------------------------------- /docs/assets/anki-card-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhb2000/anki-marker/e2617df927a1c674e3c4d49ea63df52738d971a9/docs/assets/anki-card-front.png -------------------------------------------------------------------------------- /docs/assets/changelog/0.0.1/anki-card-back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhb2000/anki-marker/e2617df927a1c674e3c4d49ea63df52738d971a9/docs/assets/changelog/0.0.1/anki-card-back.png -------------------------------------------------------------------------------- /docs/assets/changelog/0.0.1/anki-card-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhb2000/anki-marker/e2617df927a1c674e3c4d49ea63df52738d971a9/docs/assets/changelog/0.0.1/anki-card-front.png -------------------------------------------------------------------------------- /docs/assets/changelog/0.0.1/main-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhb2000/anki-marker/e2617df927a1c674e3c4d49ea63df52738d971a9/docs/assets/changelog/0.0.1/main-screenshot.png -------------------------------------------------------------------------------- /docs/assets/changelog/0.0.1/settings-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhb2000/anki-marker/e2617df927a1c674e3c4d49ea63df52738d971a9/docs/assets/changelog/0.0.1/settings-screenshot.png -------------------------------------------------------------------------------- /docs/assets/changelog/0.0.2/anki-card-back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhb2000/anki-marker/e2617df927a1c674e3c4d49ea63df52738d971a9/docs/assets/changelog/0.0.2/anki-card-back.png -------------------------------------------------------------------------------- /docs/assets/changelog/0.0.2/anki-card-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhb2000/anki-marker/e2617df927a1c674e3c4d49ea63df52738d971a9/docs/assets/changelog/0.0.2/anki-card-front.png -------------------------------------------------------------------------------- /docs/assets/changelog/0.0.2/main-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhb2000/anki-marker/e2617df927a1c674e3c4d49ea63df52738d971a9/docs/assets/changelog/0.0.2/main-screenshot.png -------------------------------------------------------------------------------- /docs/assets/changelog/0.0.2/settings-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhb2000/anki-marker/e2617df927a1c674e3c4d49ea63df52738d971a9/docs/assets/changelog/0.0.2/settings-screenshot.png -------------------------------------------------------------------------------- /docs/assets/main-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhb2000/anki-marker/e2617df927a1c674e3c4d49ea63df52738d971a9/docs/assets/main-screenshot.png -------------------------------------------------------------------------------- /docs/assets/settings-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhb2000/anki-marker/e2617df927a1c674e3c4d49ea63df52738d971a9/docs/assets/settings-screenshot.png -------------------------------------------------------------------------------- /docs/tauri-develop.md: -------------------------------------------------------------------------------- 1 | # Tauri + Vue 3 + TypeScript 2 | 3 | This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` 13 | 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "anki-marker", 3 | "private": true, 4 | "version": "0.1.1-0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc --noEmit && vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri" 11 | }, 12 | "dependencies": { 13 | "@tauri-apps/api": "^2.0.0", 14 | "@tauri-apps/plugin-clipboard-manager": "^2.2.0", 15 | "@tauri-apps/plugin-dialog": "^2.2.0", 16 | "@tauri-apps/plugin-http": "^2.2.0", 17 | "@tauri-apps/plugin-os": "^2.2.0", 18 | "@tauri-apps/plugin-shell": "^2.2.0", 19 | "element-plus": "^2.9.1", 20 | "github-markdown-css": "^5.8.1", 21 | "markdown-it": "^14.1.0", 22 | "normalize.css": "^8.0.1", 23 | "semver": "^7.6.3", 24 | "vue": "^3.4.29", 25 | "vue-router": "^4.4.0" 26 | }, 27 | "devDependencies": { 28 | "@tauri-apps/cli": "^2.0.0", 29 | "@types/markdown-it": "^14.1.2", 30 | "@types/semver": "^7.5.8", 31 | "@vitejs/plugin-vue": "^5.0.5", 32 | "archiver": "^7.0.1", 33 | "typescript": "^5.5.2", 34 | "vite": "^5.4.14", 35 | "vite-svg-loader": "^5.1.0", 36 | "vue-tsc": "^2.0.21" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/tauri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "anki-marker" 3 | version = "0.1.1-0" 4 | description = "Anki Marker is a tool for creating Anki cards." 5 | authors = ["ZHB"] 6 | license = "GPL-3.0" 7 | repository = "https://github.com/zhb2000/anki-marker" 8 | edition = "2021" 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [build-dependencies] 13 | tauri-build = { version = "2", features = [] } 14 | 15 | [dependencies] 16 | serde = { version = "1.0", features = ["derive"] } 17 | serde_json = "1.0" 18 | rusqlite = { version = "0.32", features = ["bundled"] } 19 | toml_edit = "0.22" 20 | notify = "7.0" 21 | notify-debouncer-full = "0.4" 22 | tauri = { version = "2", features = [] } 23 | tauri-plugin-shell = "2" 24 | tauri-plugin-http = { version = "2", features = ["unsafe-headers"] } 25 | tauri-plugin-dialog = "2" 26 | tauri-plugin-os = "2" 27 | tauri-plugin-clipboard-manager = "2" 28 | 29 | [features] 30 | # this feature is used for production builds or when `devPath` points to the filesystem 31 | # DO NOT REMOVE!! 32 | custom-protocol = ["tauri/custom-protocol"] 33 | 34 | [profile.release] 35 | panic = 'abort' 36 | lto = true 37 | strip = true 38 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/capabilities/migrated.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "migrated", 3 | "description": "permissions that were migrated from v1", 4 | "local": true, 5 | "windows": [ 6 | "main" 7 | ], 8 | "permissions": [ 9 | "core:default", 10 | "shell:allow-open", 11 | "dialog:allow-message", 12 | "dialog:allow-confirm", 13 | { 14 | "identifier": "http:default", 15 | "allow": [ 16 | { 17 | "url": "http://*" 18 | }, 19 | { 20 | "url": "https://*" 21 | }, 22 | { 23 | "url": "http://*:*" 24 | }, 25 | { 26 | "url": "https://*:*" 27 | } 28 | ] 29 | }, 30 | "os:allow-platform", 31 | "os:allow-version", 32 | "os:allow-os-type", 33 | "os:allow-family", 34 | "os:allow-arch", 35 | "os:allow-exe-extension", 36 | "os:allow-locale", 37 | "os:allow-hostname", 38 | "clipboard-manager:allow-read-text", 39 | "core:app:allow-app-show", 40 | "core:app:allow-app-hide", 41 | "shell:default", 42 | "http:default", 43 | "dialog:default", 44 | "os:default", 45 | "clipboard-manager:default" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /src-tauri/gen/schemas/capabilities.json: -------------------------------------------------------------------------------- 1 | {"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","dialog:allow-message","dialog:allow-confirm",{"identifier":"http:default","allow":[{"url":"http://*"},{"url":"https://*"},{"url":"http://*:*"},{"url":"https://*:*"}]},"os:allow-platform","os:allow-version","os:allow-os-type","os:allow-family","os:allow-arch","os:allow-exe-extension","os:allow-locale","os:allow-hostname","clipboard-manager:allow-read-text","core:app:allow-app-show","core:app:allow-app-hide","shell:default","http:default","dialog:default","os:default","clipboard-manager:default"]}} -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:8d5aea605a9a487c4400e072f62f621e40aa11e804fe92b7676382987a713812 3 | size 17070 4 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:2d97bd063e62dc5433a7f44ad90c206c57fd78ff9c12b189696a1eabb208bf35 3 | size 48664 4 | -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:077e86f9c60b36a9b2f9c39159c70e49507e315ed74cf1099434208e95ba1ef4 3 | size 2354 4 | -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:e885b22243fca6dba3702bf7ee938258c4eb8d378d486ad056c8db4700595f94 3 | size 13206 4 | -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:aa533fd91f82bcd073d7cc5192ae483639faf92001c1b7d86299fdeefce4eae3 3 | size 19805 4 | -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:4143ff0b545244245f826a0d9ec4653b43b6d111f674fe66e7851bdb76f5ecde 3 | size 21490 4 | -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:fd47c344076b372a11a8d622ed7985dbc63606e6b1410493c27d34cccf346240 3 | size 57183 4 | -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:a7005d595811cf4eed20be131abae830dc6828b97e7e167c2e480ecc0e1a3894 3 | size 2109 4 | -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:440907b8f84ef9724fcc6c883e50bc754618601de3cdb4aca89b1edb3d819e03 3 | size 65926 4 | -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:fdfc0e8fbdf8adbf2e84e89adf70016832b955147bba5162cb6e05458f6fc22f 3 | size 3784 4 | -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:43e871a5f42530e427d26ce7552c045d8cc178d0fdd32423094ecb1781c07288 3 | size 7396 4 | -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0fb581115df5dbc7ad78761e67c52897ff3a85a0fb8d27b9cceb958145ee284f 3 | size 10058 4 | -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:f37be81d51d5a127984e7f6e9ba05da7f64289550f35169d6adfa0ab2f2722cd 3 | size 4495 4 | -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:f1d6563746cb34cfba966eee6bb628b29e3632f9f0229a97fd4f697ad7d7c77a 3 | size 869949 4 | -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:4c3f8564445cfadd0c91794fee28d58ad92fd35aaa7212357742e6a6a8f61f48 3 | size 65211 4 | -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:f7a8609f92355abb1887180c42d656e800dafc03d4fc7ad2503122badb85f2dd 3 | size 151749 4 | -------------------------------------------------------------------------------- /src-tauri/resources/config-template.toml: -------------------------------------------------------------------------------- 1 | # 请勿手动修改此字段 2 | __minimal-version__ = "0.0.1" 3 | 4 | # AnkiConnect 服务的 URL 5 | anki-connect-url = "http://localhost:8765" 6 | 7 | # 将划词结果添加到哪个牌组 8 | deck-name = "划词助手默认牌组" 9 | 10 | # 使用的笔记模板名称 11 | model-name = "划词助手默认单词模板" 12 | -------------------------------------------------------------------------------- /src-tauri/resources/dict.db: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:72fb72b8f1e8977b7df7c611e44e594ffd5a6f9a3bf89bf66c3228e676effad5 3 | size 56307712 4 | -------------------------------------------------------------------------------- /src-tauri/src/application/config.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::sync::Mutex; 3 | use std::time::Duration; 4 | 5 | use tauri::path::BaseDirectory; 6 | use tauri::{AppHandle, Emitter, Manager, State}; 7 | 8 | use super::logics; 9 | use super::logics::config::{Config, PartialConfig}; 10 | 11 | #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] 12 | pub struct Portable(pub bool); 13 | 14 | impl Portable { 15 | pub fn new() -> Result { 16 | // 如果当前 exe 的旁边存在 config.toml,则认为是便携模式 17 | let config_path = logics::utils::current_exe_dir()?.join("config.toml"); 18 | let portable = config_path 19 | .try_exists() 20 | .map_err(|e| format!("failed to to detect if config.toml exists: {e}"))?; 21 | return Ok(Portable(portable)); 22 | } 23 | } 24 | 25 | #[derive(Debug, Clone, Hash, PartialEq, Eq)] 26 | pub struct ConfigPath(pub String); 27 | 28 | impl ConfigPath { 29 | pub fn new( 30 | portable: bool, 31 | path_resolver: &tauri::path::PathResolver, 32 | ) -> Result { 33 | let config_path = if portable { 34 | logics::utils::current_exe_dir()?.join("config.toml") 35 | } else { 36 | path_resolver 37 | .app_config_dir() 38 | .map_err(|e| format!("failed to resolve app config directory: {e}"))? 39 | .join("config.toml") 40 | }; 41 | return Ok(ConfigPath(config_path.to_string_lossy().into_owned())); 42 | } 43 | } 44 | 45 | #[tauri::command(rename_all = "snake_case")] 46 | pub fn read_config( 47 | config_path: State, 48 | portable: State, 49 | app: AppHandle, 50 | ) -> Result { 51 | let config_path: &Path = config_path.0.as_ref(); 52 | let portable = portable.0; 53 | if !config_path 54 | .try_exists() 55 | .map_err(|e| format!("failed to to detect if config.toml exists: {e}"))? 56 | { 57 | if portable { 58 | return Err("config.toml does not exist".to_string()); 59 | } 60 | // 非便携模式下,若 config.toml 不存在,则将模板配置复制到用户配置目录 61 | let template_path = app 62 | .path() 63 | .resolve("resources/config-template.toml", BaseDirectory::Resource) 64 | .map_err(|e| format!("failed to resolve resources/config-template.toml: {e}"))?; 65 | logics::config::copy_template_config(template_path, config_path)?; 66 | } 67 | return logics::config::read_config(config_path); 68 | } 69 | 70 | #[tauri::command(rename_all = "snake_case")] 71 | pub fn commit_config( 72 | modified: PartialConfig, 73 | config_path: State, 74 | ) -> Result<(), String> { 75 | let config_path: &Path = config_path.0.as_ref(); 76 | return logics::config::commit_config(config_path, modified); 77 | } 78 | 79 | #[tauri::command(rename_all = "snake_case")] 80 | pub fn config_path(config_path: State) -> String { 81 | return config_path.0.clone(); 82 | } 83 | 84 | #[tauri::command(rename_all = "snake_case")] 85 | pub fn is_portable(portable: State) -> bool { 86 | return portable.0; 87 | } 88 | 89 | #[tauri::command(rename_all = "snake_case")] 90 | pub fn show_in_explorer(path: String) -> Result<(), String> { 91 | return logics::utils::show_in_explorer(&path); 92 | } 93 | 94 | pub struct IsWatching(pub Mutex); 95 | 96 | impl IsWatching { 97 | pub fn new() -> Self { 98 | return IsWatching(Mutex::new(false)); 99 | } 100 | } 101 | 102 | /// Return true if the watcher is started successfully, false if it's already started. 103 | #[tauri::command(rename_all = "snake_case")] 104 | pub fn start_config_watcher( 105 | is_watching: State, 106 | config_path: State, 107 | app: AppHandle, 108 | ) -> Result { 109 | let watching = *is_watching 110 | .0 111 | .lock() 112 | .map_err(|e| format!("failed to lock is_watching: {e}"))?; 113 | if watching { 114 | return Ok(false); 115 | } 116 | let config_path: &Path = config_path.0.as_ref(); 117 | let window = app 118 | .get_webview_window("main") 119 | .ok_or("failed to get main window")?; 120 | let main_window = window.clone(); 121 | let on_change = move || { 122 | if main_window.emit("config-changed", ()).is_err() { 123 | println!("failed to emit config-changed event"); 124 | } 125 | }; 126 | let main_window = window.clone(); 127 | let on_error = move || { 128 | if main_window.emit("config-watcher-error", ()).is_err() { 129 | println!("failed to emit config-watcher-error event"); 130 | } 131 | }; 132 | let timeout = Duration::from_secs(2); 133 | logics::utils::watch_file_change(config_path, on_change, on_error, timeout)?; 134 | let mut guard = is_watching 135 | .0 136 | .lock() 137 | .map_err(|e| format!("failed to lock is_watching: {e}"))?; 138 | *guard = true; 139 | return Ok(true); 140 | } 141 | 142 | #[tauri::command(rename_all = "snake_case")] 143 | pub fn rust_in_release() -> Result { 144 | return Ok(!cfg!(debug_assertions)); 145 | } 146 | -------------------------------------------------------------------------------- /src-tauri/src/application/dict.rs: -------------------------------------------------------------------------------- 1 | use std::ops::DerefMut; 2 | use std::path::Path; 3 | use std::sync::{Mutex, MutexGuard}; 4 | 5 | use rusqlite::Connection; 6 | use tauri::path::BaseDirectory; 7 | use tauri::State; 8 | 9 | use super::logics; 10 | use super::logics::dict::{CollinsItem, OxfordItem}; 11 | 12 | #[derive(Debug, Clone, Hash, PartialEq, Eq)] 13 | pub struct DictPath(pub String); 14 | 15 | impl DictPath { 16 | pub fn new( 17 | portable: bool, 18 | path_resolver: &tauri::path::PathResolver, 19 | ) -> Result { 20 | let dict_path = if portable { 21 | logics::utils::current_exe_dir()? 22 | .join("resources") 23 | .join("dict.db") 24 | } else { 25 | path_resolver 26 | .resolve("resources/dict.db", BaseDirectory::Resource) 27 | .map_err(|e| format!("failed to resolve resources/dict.db: {e}"))? 28 | }; 29 | return Ok(DictPath(dict_path.to_string_lossy().into_owned())); 30 | } 31 | } 32 | 33 | trait GetConnection<'a> { 34 | fn connection(&'a mut self, dict_path: &Path) -> Result<&'a mut Connection, String>; 35 | } 36 | 37 | impl<'a> GetConnection<'a> for MutexGuard<'_, Option> { 38 | fn connection(&'a mut self, dict_path: &Path) -> Result<&'a mut Connection, String> { 39 | let opt_conn = self.deref_mut(); 40 | let conn = match opt_conn { 41 | Some(conn) => conn, 42 | None => { 43 | let conn = logics::dict::open_connection(dict_path)?; 44 | *opt_conn = Some(conn); 45 | opt_conn.as_mut().expect("unexpected None") 46 | } 47 | }; 48 | return Ok(conn); 49 | } 50 | } 51 | 52 | #[tauri::command(rename_all = "snake_case")] 53 | pub fn search_collins( 54 | word: String, 55 | conn: State>>, 56 | dict_path: State, 57 | ) -> Result, String> { 58 | let dict_path: &Path = dict_path.0.as_ref(); 59 | let mut guard = conn 60 | .lock() 61 | .map_err(|e| format!("failed to lock connection: {e}"))?; 62 | let conn = guard.connection(dict_path)?; 63 | return logics::dict::search_collins(conn, word); 64 | } 65 | 66 | #[tauri::command(rename_all = "snake_case")] 67 | pub fn search_oxford( 68 | word: String, 69 | conn: State>>, 70 | dict_path: State, 71 | ) -> Result, String> { 72 | let dict_path: &Path = dict_path.0.as_ref(); 73 | let mut guard = conn 74 | .lock() 75 | .map_err(|e| format!("failed to lock connection: {e}"))?; 76 | let conn = guard.connection(dict_path)?; 77 | return logics::dict::search_oxford(conn, word); 78 | } 79 | 80 | #[tauri::command(rename_all = "snake_case")] 81 | pub fn get_word_base( 82 | word: String, 83 | conn: State>>, 84 | dict_path: State, 85 | ) -> Result, String> { 86 | let dict_path: &Path = dict_path.0.as_ref(); 87 | let mut guard = conn 88 | .lock() 89 | .map_err(|e| format!("failed to lock connection: {e}"))?; 90 | let conn = guard.connection(dict_path)?; 91 | return logics::dict::get_word_base(conn, word); 92 | } 93 | -------------------------------------------------------------------------------- /src-tauri/src/application/logics/config.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | #[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 4 | #[serde(rename_all = "camelCase")] 5 | pub struct Config { 6 | #[serde(rename = "ankiConnectURL")] 7 | anki_connect_url: String, 8 | deck_name: String, 9 | model_name: String, 10 | } 11 | 12 | #[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 13 | #[serde(rename_all = "camelCase")] 14 | pub struct PartialConfig { 15 | #[serde(rename = "ankiConnectURL")] 16 | anki_connect_url: Option, 17 | deck_name: Option, 18 | model_name: Option, 19 | } 20 | 21 | /// 将配置模板复制到配置文件路径 22 | pub fn copy_template_config( 23 | template_path: impl AsRef, 24 | config_path: impl AsRef, 25 | ) -> Result<(), String> { 26 | fn inner(config_path: &Path, template_path: &Path) -> Result<(), String> { 27 | let config_dir = config_path 28 | .parent() 29 | .ok_or("config path is a root or an empty string")?; 30 | std::fs::create_dir_all(config_dir) 31 | .map_err(|e| format!("failed to create directory {}: {e}", config_dir.display()))?; 32 | std::fs::copy(template_path, config_path).map_err(|e| { 33 | format!( 34 | "failed to copy template config from {} to {}: {e}", 35 | template_path.display(), 36 | config_path.display() 37 | ) 38 | })?; 39 | return Ok(()); 40 | } 41 | return inner(config_path.as_ref(), template_path.as_ref()); 42 | } 43 | 44 | pub fn read_config(config_path: impl AsRef) -> Result { 45 | fn inner(config_path: &Path) -> Result { 46 | let toml_string = std::fs::read_to_string(config_path) 47 | .map_err(|e| format!("failed to read config file {}: {e}", config_path.display()))?; 48 | let doc = toml_string.parse::().map_err(|e| { 49 | format!( 50 | "failed to parse toml from config file {}: {e}", 51 | config_path.display() 52 | ) 53 | })?; 54 | let anki_connect_url = doc 55 | .get("anki-connect-url") 56 | .ok_or(r#"toml key "anki-connect-url" does not exist"#)? 57 | .as_str() 58 | .ok_or(r#"the value of "anki-connect-url" is not a string"#)?; 59 | let deck_name = doc 60 | .get("deck-name") 61 | .ok_or(r#"toml key "deck-name" does not exist"#)? 62 | .as_str() 63 | .ok_or(r#"the value of "deck-name" is not a string"#)?; 64 | let model_name = doc 65 | .get("model-name") 66 | .ok_or(r#"toml key "model-name" does not exist"#)? 67 | .as_str() 68 | .ok_or(r#"the value of "model-name" is not a string"#)?; 69 | return Ok(Config { 70 | anki_connect_url: anki_connect_url.to_string(), 71 | deck_name: deck_name.to_string(), 72 | model_name: model_name.to_string(), 73 | }); 74 | } 75 | return inner(config_path.as_ref()); 76 | } 77 | 78 | pub fn commit_config(config_path: impl AsRef, modified: PartialConfig) -> Result<(), String> { 79 | fn inner(config_path: &Path, modified: PartialConfig) -> Result<(), String> { 80 | let toml_string = std::fs::read_to_string(config_path) 81 | .map_err(|e| format!("failed to read config file {}: {e}", config_path.display()))?; 82 | let mut doc = toml_string.parse::().map_err(|e| { 83 | format!( 84 | "failed to parse toml from config file {}: {e}", 85 | config_path.display() 86 | ) 87 | })?; 88 | if let Some(anki_connect_url) = modified.anki_connect_url { 89 | doc["anki-connect-url"] = toml_edit::value(anki_connect_url); 90 | } 91 | if let Some(deck_name) = modified.deck_name { 92 | doc["deck-name"] = toml_edit::value(deck_name); 93 | } 94 | if let Some(model_name) = modified.model_name { 95 | doc["model-name"] = toml_edit::value(model_name); 96 | } 97 | std::fs::write(&config_path, doc.to_string()).map_err(|e| { 98 | format!( 99 | "failed to write to config file {}: {e}", 100 | config_path.display() 101 | ) 102 | })?; 103 | return Ok(()); 104 | } 105 | return inner(config_path.as_ref(), modified); 106 | } 107 | -------------------------------------------------------------------------------- /src-tauri/src/application/logics/dict.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use rusqlite::{Connection, OptionalExtension}; 4 | 5 | #[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct CollinsItem { 8 | word: String, 9 | phonetic: Option, 10 | sense: Option, 11 | en_def: Option, 12 | cn_def: Option, 13 | } 14 | 15 | #[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 16 | #[serde(rename_all = "camelCase")] 17 | pub struct OxfordItem { 18 | word: String, 19 | phrase: Option, 20 | phonetic: Option, 21 | sense: Option, 22 | ext: Option, 23 | en_def: Option, 24 | cn_def: Option, 25 | } 26 | 27 | pub fn open_connection(dict_path: impl AsRef) -> Result { 28 | fn inner(dict_path: &Path) -> Result { 29 | use rusqlite::OpenFlags; 30 | let flags = OpenFlags::SQLITE_OPEN_READ_ONLY 31 | | OpenFlags::SQLITE_OPEN_URI 32 | | OpenFlags::SQLITE_OPEN_NO_MUTEX; 33 | let conn = Connection::open_with_flags(dict_path, flags) 34 | .map_err(|e| format!("failed to open resources/dict.db, error: {e}"))?; 35 | return Ok(conn); 36 | } 37 | return inner(dict_path.as_ref()); 38 | } 39 | 40 | pub fn search_collins( 41 | conn: &Connection, 42 | word: impl AsRef, 43 | ) -> Result, String> { 44 | fn inner(conn: &Connection, word: &str) -> Result, String> { 45 | let mut stmt = conn 46 | .prepare_cached("select * from collins where word = ?1 collate nocase order by rowid") 47 | .map_err(|e| format!("failed to prepare SQL statement for collins search: {e}"))?; 48 | let mut rows = stmt 49 | .query([word]) 50 | .map_err(|e| format!("failed to query collins: {e}"))?; 51 | let mut items = vec![]; 52 | while let Some(row) = rows 53 | .next() 54 | .map_err(|e| format!("failed to get next row from collins search result: {e}"))? 55 | { 56 | items.push(CollinsItem { 57 | word: row 58 | .get("word") 59 | .map_err(|e| format!("failed to get word: {e}"))?, 60 | phonetic: row 61 | .get("phonetic") 62 | .map_err(|e| format!("failed to get phonetic: {e}"))?, 63 | sense: row 64 | .get("sense") 65 | .map_err(|e| format!("failed to get sense: {e}"))?, 66 | en_def: row 67 | .get("enDef") 68 | .map_err(|e| format!("failed to get enDef: {e}"))?, 69 | cn_def: row 70 | .get("cnDef") 71 | .map_err(|e| format!("failed to get cnDef: {e}"))?, 72 | }); 73 | } 74 | return Ok(items); 75 | } 76 | return inner(conn, word.as_ref()); 77 | } 78 | 79 | pub fn search_oxford(conn: &Connection, word: impl AsRef) -> Result, String> { 80 | fn inner(conn: &Connection, word: &str) -> Result, String> { 81 | let mut stmt = conn 82 | .prepare_cached("select * from oxford where word = ?1 collate nocase order by rowid") 83 | .map_err(|e| format!("failed to prepare SQL statement for oxford search: {e}"))?; 84 | let mut rows = stmt 85 | .query([word]) 86 | .map_err(|e| format!("failed to query oxford: {e}"))?; 87 | let mut items = vec![]; 88 | while let Some(row) = rows 89 | .next() 90 | .map_err(|e| format!("failed to get next row from oxford search result: {e}"))? 91 | { 92 | items.push(OxfordItem { 93 | word: row 94 | .get("word") 95 | .map_err(|e| format!("failed to get word: {e}"))?, 96 | phrase: row 97 | .get("phrase") 98 | .map_err(|e| format!("failed to get phrase: {e}"))?, 99 | phonetic: row 100 | .get("phonetic") 101 | .map_err(|e| format!("failed to get phonetic: {e}"))?, 102 | sense: row 103 | .get("sense") 104 | .map_err(|e| format!("failed to get sense: {e}"))?, 105 | ext: row 106 | .get("ext") 107 | .map_err(|e| format!("failed to get ext: {e}"))?, 108 | en_def: row 109 | .get("enDef") 110 | .map_err(|e| format!("failed to get enDef: {e}"))?, 111 | cn_def: row 112 | .get("cnDef") 113 | .map_err(|e| format!("failed to get cnDef: {e}"))?, 114 | }); 115 | } 116 | return Ok(items); 117 | } 118 | return inner(conn, word.as_ref()); 119 | } 120 | 121 | /// 获取单词的原型 122 | pub fn get_word_base(conn: &Connection, word: impl AsRef) -> Result, String> { 123 | fn inner(conn: &Connection, word: &str) -> Result, String> { 124 | let mut stmt = conn 125 | .prepare_cached("select * from forms where word = ?1 collate nocase") 126 | .map_err(|e| format!("failed to prepare SQL statement for word base search: {e}"))?; 127 | let base: Option = stmt 128 | .query_row([word], |row| row.get("base")) 129 | .optional() 130 | .map_err(|e| format!("failed to query word base: {e}"))?; 131 | return Ok(base); 132 | } 133 | return inner(conn, word.as_ref()); 134 | } 135 | -------------------------------------------------------------------------------- /src-tauri/src/application/logics/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod dict; 3 | pub mod utils; 4 | -------------------------------------------------------------------------------- /src-tauri/src/application/logics/utils.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | use std::thread::JoinHandle; 3 | use std::time::Duration; 4 | 5 | use notify::RecursiveMode; 6 | use notify_debouncer_full::new_debouncer; 7 | 8 | pub fn show_in_explorer(path: impl AsRef) -> Result<(), String> { 9 | #[cfg(target_os = "windows")] 10 | fn inner(path: &str) -> Result<(), String> { 11 | use std::os::windows::process::CommandExt; 12 | const CREATE_NO_WINDOW: u32 = 0x08000000; 13 | std::process::Command::new("cmd") 14 | .args(&["/C", "explorer", "/select,", path]) 15 | .creation_flags(CREATE_NO_WINDOW) 16 | .output() 17 | .map_err(|e| e.to_string())?; 18 | return Ok(()); 19 | } 20 | 21 | #[cfg(target_os = "macos")] 22 | fn inner(path: &str) -> Result<(), String> { 23 | std::process::Command::new("open") 24 | .args(&["-R", path]) 25 | .output() 26 | .map_err(|e| e.to_string())?; 27 | return Ok(()); 28 | } 29 | 30 | #[cfg(target_os = "linux")] 31 | fn inner(path: &str) -> Result<(), String> { 32 | let dir_path = std::path::Path::new(path) 33 | .parent() 34 | .ok_or("Failed to get parent directory")? 35 | .to_str() 36 | .ok_or("Parent directory is not valid utf-8")?; 37 | std::process::Command::new("xdg-open") 38 | .arg(dir_path) 39 | .output() 40 | .map_err(|e| e.to_string())?; 41 | return Ok(()); 42 | } 43 | 44 | #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] 45 | fn inner(_: &str) -> Result<(), String> { 46 | Err(format!( 47 | "show_in_explorer is not implemented on this platform: {}", 48 | std::env::consts::OS 49 | )) 50 | } 51 | 52 | let path = path 53 | .as_ref() 54 | .to_str() 55 | .ok_or("path is not valid utf-8")? 56 | .replace('/', std::path::MAIN_SEPARATOR_STR) 57 | .replace('\\', std::path::MAIN_SEPARATOR_STR); 58 | return inner(&path); 59 | } 60 | 61 | pub fn current_exe_dir() -> Result { 62 | let exe_path = std::env::current_exe() 63 | .map_err(|e| format!("failed to get current exe path: {}", e.to_string()))?; 64 | let exe_dir = exe_path 65 | .parent() 66 | .ok_or("failed to get current exe directory")?; 67 | return Ok(exe_dir.to_path_buf()); 68 | } 69 | 70 | pub fn watch_file_change( 71 | file_path: impl AsRef, 72 | on_change: impl Fn() + Send + 'static, 73 | on_error: impl Fn() + Send + 'static, 74 | timeout: Duration, 75 | ) -> Result, String> { 76 | fn inner( 77 | file_path: &Path, 78 | on_change: impl Fn() + Send + 'static, 79 | on_error: impl Fn() + Send + 'static, 80 | timeout: Duration, 81 | ) -> Result, String> { 82 | let (sender, receiver) = std::sync::mpsc::channel(); 83 | let mut debouncer = new_debouncer(timeout, None, sender) 84 | .map_err(|e| format!("failed to create file watcher debouncer: {e}"))?; 85 | debouncer 86 | .watch(Path::new(file_path), RecursiveMode::NonRecursive) 87 | .map_err(|e| { 88 | format!( 89 | "failed to watch file change for {}: {e}", 90 | file_path.display() 91 | ) 92 | })?; 93 | let file_path = file_path.to_path_buf(); 94 | let join_handle = std::thread::spawn(move || { 95 | let _keep_debouncer_alive = debouncer; 96 | for res in receiver { 97 | match res { 98 | Ok(_events) => { 99 | if file_path.try_exists().is_ok_and(|exists| exists) { 100 | on_change(); 101 | } 102 | } 103 | Err(_errors) => { 104 | on_error(); 105 | } 106 | } 107 | } 108 | unreachable!("watcher loop exited"); 109 | }); 110 | return Ok(join_handle); 111 | } 112 | return inner(file_path.as_ref(), on_change, on_error, timeout); 113 | } 114 | -------------------------------------------------------------------------------- /src-tauri/src/application/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod dict; 3 | pub mod logics; 4 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | use std::sync::Mutex; 5 | 6 | use rusqlite::Connection; 7 | use tauri::Manager; 8 | 9 | mod application; 10 | 11 | fn main() { 12 | tauri::Builder::default() 13 | .plugin(tauri_plugin_clipboard_manager::init()) 14 | .plugin(tauri_plugin_os::init()) 15 | .plugin(tauri_plugin_dialog::init()) 16 | .plugin(tauri_plugin_http::init()) 17 | .plugin(tauri_plugin_shell::init()) 18 | .setup(|app| { 19 | let portable = application::config::Portable::new()?; 20 | app.manage(portable); 21 | app.manage(application::config::ConfigPath::new( 22 | portable.0, 23 | app.path(), 24 | )?); 25 | app.manage(application::config::IsWatching::new()); 26 | app.manage(application::dict::DictPath::new( 27 | portable.0, 28 | app.path(), 29 | )?); 30 | app.manage(Mutex::new(None::)); 31 | Ok(()) 32 | }) 33 | .invoke_handler(tauri::generate_handler![ 34 | application::config::read_config, 35 | application::config::commit_config, 36 | application::config::config_path, 37 | application::config::is_portable, 38 | application::config::show_in_explorer, 39 | application::config::start_config_watcher, 40 | application::config::rust_in_release, 41 | application::dict::search_collins, 42 | application::dict::search_oxford, 43 | application::dict::get_word_base, 44 | ]) 45 | .run(tauri::generate_context!()) 46 | .expect("error while running tauri application"); 47 | } 48 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "beforeDevCommand": "npm run dev", 4 | "beforeBuildCommand": "npm run build", 5 | "frontendDist": "../dist", 6 | "devUrl": "http://localhost:1420" 7 | }, 8 | "bundle": { 9 | "category": "Education", 10 | "shortDescription": "Anki 划词助手", 11 | "longDescription": "Anki 划词助手是一个制作 Anki 卡片的工具,你可以用它标记句子中的生词,通过“单词结合上下文”的方式更好地背单词。", 12 | "active": true, 13 | "targets": ["nsis", "msi", "deb", "rpm"], 14 | "windows": { 15 | "webviewInstallMode": { 16 | "type": "embedBootstrapper" 17 | }, 18 | "nsis": { 19 | "languages": [ 20 | "SimpChinese" 21 | ] 22 | }, 23 | "wix": { 24 | "language": "zh-CN", 25 | "upgradeCode": "b8333182-e22f-5367-8f6f-ec9d68f60947" 26 | } 27 | }, 28 | "linux": { 29 | "appimage": { 30 | "bundleMediaFramework": true 31 | } 32 | }, 33 | "icon": [ 34 | "icons/32x32.png", 35 | "icons/128x128.png", 36 | "icons/128x128@2x.png", 37 | "icons/icon.icns", 38 | "icons/icon.ico", 39 | "icons/icon.png" 40 | ], 41 | "resources": [ 42 | "resources/dict.db", 43 | "resources/config-template.toml" 44 | ] 45 | }, 46 | "productName": "Anki 划词助手", 47 | "mainBinaryName": "Anki 划词助手", 48 | "version": "../package.json", 49 | "identifier": "com.zhb2000.anki-marker", 50 | "plugins": {}, 51 | "app": { 52 | "withGlobalTauri": false, 53 | "windows": [ 54 | { 55 | "fullscreen": false, 56 | "resizable": true, 57 | "title": "Anki 划词助手", 58 | "width": 800, 59 | "height": 500 60 | } 61 | ], 62 | "security": { 63 | "csp": null 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src-tauri/tauri.linux.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "com.zhb2000.anki-marker", 3 | "productName": "anki-marker", 4 | "mainBinaryName": "anki-marker" 5 | } 6 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 26 | 27 | 113 | 114 | 135 | -------------------------------------------------------------------------------- /src/assets/OpenFilled.svg: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /src/assets/arrow-back.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | -------------------------------------------------------------------------------- /src/assets/edit.svg: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /src/assets/github.svg: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/assets/model-back.html: -------------------------------------------------------------------------------- 1 | {{FrontSide}} 2 | 3 | 4 | {{#释义}} 5 |
6 |
释义
7 |
8 |
9 |
{{释义}}
10 |
11 |
12 |
13 | {{/释义}} 14 | 15 | {{#笔记}} 16 |
17 |
笔记
18 |
19 |
20 |
{{笔记}}
21 |
22 |
23 |
24 | {{/笔记}} 25 | 26 | {{#url}} 27 |
28 |
URL
29 |
30 |
31 | 32 |
33 |
34 |
35 | {{/url}} 36 | -------------------------------------------------------------------------------- /src/assets/model-css.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | /*global card style*/ 6 | .card { 7 | font-family: helvetica, arial, sans-serif; 8 | font-size: 16px; 9 | text-align: left; 10 | color: black; 11 | } 12 | 13 | .bar { 14 | border-bottom: 1px solid #29487d; 15 | color: white; 16 | padding: 5px; 17 | padding-left: 35px; 18 | text-decoration: none; 19 | font-size: 12px; 20 | font-weight: bold; 21 | background-color: #365899; 22 | background-repeat: no-repeat; 23 | background-position: 5px center; 24 | cursor: pointer; 25 | } 26 | 27 | .bar a { 28 | color: white; 29 | text-decoration: none; 30 | font-size: 12px; 31 | font-weight: bold; 32 | } 33 | 34 | .bar a:hover { 35 | text-decoration: underline; 36 | cursor: pointer; 37 | } 38 | 39 | .section { 40 | border: 1px solid; 41 | border-color: #e5e6e9 #dfe0e4 #d0d1d5; 42 | border-radius: 3px; 43 | background-color: white; 44 | color: black; 45 | position: relative; 46 | margin: 5px 0; 47 | overflow: hidden; 48 | } 49 | 50 | .items-grid { 51 | display: grid; 52 | grid-template-rows: 1fr; 53 | transition: grid-template-rows 0.3s ease; 54 | } 55 | 56 | .items-grid.fold { 57 | grid-template-rows: 0fr; 58 | } 59 | 60 | .items-overflow-hidden { 61 | overflow: hidden; 62 | } 63 | 64 | .items { 65 | padding: 8px 12px; 66 | } 67 | 68 | .items a:hover { 69 | text-decoration: underline; 70 | cursor: pointer; 71 | } 72 | 73 | hr { 74 | border: 0; 75 | margin: 3px 13px; 76 | border-top: 1px solid #e5e5e5; 77 | } 78 | 79 | #front, 80 | #back { 81 | line-height: 1.5em; 82 | } 83 | 84 | /* front field style */ 85 | #front { 86 | font-size: 36px; 87 | text-align: left; 88 | } 89 | 90 | #front span { 91 | display: inline-block; 92 | vertical-align: middle; 93 | } 94 | 95 | #front img { 96 | width: 36px; 97 | height: 36px; 98 | top: 6px; 99 | position: relative; 100 | margin-left: 10px; 101 | } 102 | 103 | /* back field style */ 104 | #back { 105 | font-size: 16px; 106 | text-align: left; 107 | } 108 | 109 | #phonetic { 110 | font-size: 14px; 111 | } 112 | 113 | #sentence-content b { 114 | border-radius: 4px; 115 | color: white; 116 | background-color: #888; 117 | padding: 0 3px; 118 | } 119 | 120 | .audio-button { 121 | cursor: pointer; 122 | width: 15px; 123 | height: 15px; 124 | border: 1px solid #888; 125 | border-radius: 50%; 126 | display: inline-block; 127 | vertical-align: middle; 128 | background-image: url('data:image/svg+xml,%3Csvg%20width%3D%2228%22%20height%3D%2228%22%20viewBox%3D%220%200%2028%2028%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpath%0A%20%20%20%20%20%20%20%20d%3D%22M10.7143%2018.1786H8C7.44772%2018.1786%207%2017.7109%207%2017.134V10.866C7%2010.2891%207.44772%209.82136%208%209.82136H10.7143L14.3177%207.28302C14.9569%206.65978%2016%207.1333%2016%208.04673V19.9533C16%2020.8667%2014.9569%2021.3402%2014.3177%2020.717L10.7143%2018.1786Z%22%0A%20%20%20%20%20%20%20%20stroke%3D%22%23333333%22%20stroke-width%3D%221.5%22%20%2F%3E%0A%20%20%20%20%3Cpath%0A%20%20%20%20%20%20%20%20d%3D%22M19%2018C19.6341%2017.4747%2020.1371%2016.8511%2020.4802%2016.1648C20.8234%2015.4785%2021%2014.7429%2021%2014C21%2013.2571%2020.8234%2012.5215%2020.4802%2011.8352C20.1371%2011.1489%2019.6341%2010.5253%2019%2010%22%0A%20%20%20%20%20%20%20%20stroke%3D%22%23333333%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22round%22%20%2F%3E%0A%3C%2Fsvg%3E'); 129 | background-size: contain; 130 | background-repeat: no-repeat; 131 | } 132 | 133 | .anki-icon { 134 | background-image: url('data:image/png;base64,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'); 135 | background-size: contain; 136 | background-repeat: no-repeat; 137 | } 138 | 139 | .back-icon { 140 | background-image: url("data:image/png;base64,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"); 141 | background-size: contain; 142 | background-repeat: no-repeat; 143 | } 144 | 145 | .note-icon { 146 | background-image: url('data:image/png;base64,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'); 147 | background-size: contain; 148 | background-repeat: no-repeat; 149 | } 150 | 151 | .sentence-icon { 152 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAABcVBMVEUAAAAXHB8SFxwACRcXHB8XHB8XHB8XHB8XHB8XHB8XHB8XHB8XHB8KEhsJEhsIERoQFx0XHB8XHB8XHB8XHB8XHB8KExsXHB8UGh4gIyImJyQmJyQkJiMYHR8XHB8XHB8gIyIVGh4RGB1PRTEXHB9LQi8QFx0UGR4NFBwUGh4NFRwSGB0OFhwTGR4kJiNJQS5ORTBORDAlJiMRGB0LExsKExsXHB+afEaNckOPdEV/aUAlJiMWGx+Nc0Oef0iYfEeRd0mxr6zPzMe6uLQxNTcUGRwWGx60sa20k1d5ZT6df0iLdErc3d/////5+fmsrq+PkZKQkpPf3+C2lVx4ZD37+/r4+Pj6+vr+/v739/f8/Pz9/f3u7u7s7Ozl5eXi4uLh4eH7+/vw8PDv7+/29vbt7e3j4+P19fWJckjMztHv8PHt7u/P0NK0k1qfgUqsiUptYEp0aFJ0Z1FuYUu+l1F2Yj3ruGDgsFrfr1rpt2BlVjft0I47AAAANnRSTlMAAAAACTgCCAqAx8mBBwkIEMDE3DM1Bwx2uMC/x/b3f7dxW/n9+VOSaZZpgGcqyff29skePz8ujCjMAAAAAWJLR0RNgGggZQAAATdJREFUOMtjYKAuYGRhZWVhxCnLxs7BycXNw8HOhqGGiYmXj1+AU1BIWERUWEiQU4CfT4yJCUmvuISklLSMrJmZnLy8nJmZrIy0lIKiOMIcRiVlcwtLK2sbMzMVFTMzG2srS1s7VTUkBer2Do5Ozi6ubmDg6uLs5O7hqYGkQNPL28fXzz8gEAwC/P18g4JDtNAV+IaGQUG4L1YFEWGRYBDl64tdQXRMLBhE4lIQFRcPAQlxidhNSIqBgOSUCOwKklMhIDkauxUoAJ8vIiPDIvD6IjYWaAc+X6SF4QoHqC+S06N88fsCFFQUBFRyekpKJL6AAoWSLzkBlZGZlY0EsnJyURXk5RcUFiGBwuIST2QF2qVl5RUooLyySgdJga6evoEhCjDQN9JFUsBsbGKKBkyMmXHmQdIAAFzrqJiDK+u9AAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDE1LTA3LTI1VDIxOjUwOjA3KzA4OjAwmBfJngAAACV0RVh0ZGF0ZTptb2RpZnkAMjAxNS0wNS0xOVQyMjozMjo1NyswODowMHAsh0sAAABOdEVYdHNvZnR3YXJlAEltYWdlTWFnaWNrIDYuOC44LTEwIFExNiB4ODZfNjQgMjAxNS0wNy0xOSBodHRwOi8vd3d3LmltYWdlbWFnaWNrLm9yZwUMnDUAAAAYdEVYdFRodW1iOjpEb2N1bWVudDo6UGFnZXMAMaf/uy8AAAAYdEVYdFRodW1iOjpJbWFnZTo6SGVpZ2h0ADUxMo+NU4EAAAAXdEVYdFRodW1iOjpJbWFnZTo6V2lkdGgANTEyHHwD3AAAABl0RVh0VGh1bWI6Ok1pbWV0eXBlAGltYWdlL3BuZz+yVk4AAAAXdEVYdFRodW1iOjpNVGltZQAxNDMyMDQ1OTc3KUMvNgAAABN0RVh0VGh1bWI6OlNpemUAMTIuOEtCQhoRQ10AAABadEVYdFRodW1iOjpVUkkAZmlsZTovLy9ob21lL3d3d3Jvb3Qvd3d3LmVhc3lpY29uLm5ldC9jZG4taW1nLmVhc3lpY29uLmNuL3NyYy8xMTg3Mi8xMTg3MjYzLnBuZzxoiMoAAAAASUVORK5CYII='); 153 | background-size: contain; 154 | background-repeat: no-repeat; 155 | } 156 | 157 | .url-icon { 158 | background-image: url('data:image/png;base64,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'); 159 | background-size: contain; 160 | background-repeat: no-repeat; 161 | } 162 | -------------------------------------------------------------------------------- /src/assets/model-front.html: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 |
11 | Anki 划词助手 12 |
13 |
14 | 15 |
16 |
{{单词}}
17 |
18 |
{{音标}} {{发音}}
19 |
20 | 21 | {{#例句}} 22 |
23 |
例句
24 |
25 |
26 |
27 | {{例句}} 28 |
29 |
30 |
31 |
32 |
33 |
34 | {{/例句}} 35 | 36 | 63 | -------------------------------------------------------------------------------- /src/assets/model-template-release-note.md: -------------------------------------------------------------------------------- 1 | # 划词助手单词笔记模板 0.1.0 2 | 3 | 新增**例句朗读**功能,点击例句中的喇叭图标可以朗读例句,再次点击可以停止朗读。例句朗读的语音来自有道词典,需要联网使用。 4 | 5 | 为条目的**展开和收起**添加了**动画效果**,点击条目的标题可以展开或收起条目的内容。 6 | 7 | 其他卡片布局优化。 8 | -------------------------------------------------------------------------------- /src/assets/play-audio.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | -------------------------------------------------------------------------------- /src/assets/reset.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /src/assets/settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/zhb-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhb2000/anki-marker/e2617df927a1c674e3c4d49ea63df52738d971a9/src/assets/zhb-avatar.png -------------------------------------------------------------------------------- /src/components/AddButton.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 36 | 37 | 99 | -------------------------------------------------------------------------------- /src/components/CardStatus.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * - `not-added`: 未添加到 Anki 3 | * - `processing-add`: 正在处理添加 4 | * - `processing-remove`: 正在处理删除 5 | * - `is-added`: 已添加到 Anki 6 | */ 7 | export type CardStatus = 'not-added' | 'processing-add' | 'processing-remove' | 'is-added'; 8 | -------------------------------------------------------------------------------- /src/components/CollinsCard.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/components/OxfordCard.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/components/PlayAudioButton.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 44 | 45 | 70 | -------------------------------------------------------------------------------- /src/components/ResetButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 38 | -------------------------------------------------------------------------------- /src/components/ReturnButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 37 | -------------------------------------------------------------------------------- /src/components/ScrollMemory copy.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 69 | -------------------------------------------------------------------------------- /src/components/ScrollMemory.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 57 | -------------------------------------------------------------------------------- /src/components/SentencePanel.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 23 | 24 | 40 | -------------------------------------------------------------------------------- /src/components/SettingButton.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 25 | 26 | 50 | -------------------------------------------------------------------------------- /src/components/Token.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 44 | 45 | 77 | -------------------------------------------------------------------------------- /src/components/WordCard.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 65 | 66 | 160 | -------------------------------------------------------------------------------- /src/components/YoudaoCard.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export type { CardStatus } from './CardStatus'; 2 | export { default as AddButton } from './AddButton.vue'; 3 | export { default as CollinsCard } from './CollinsCard.vue'; 4 | export { default as OxfordCard } from './OxfordCard.vue'; 5 | export { default as PlayAudioButton } from './PlayAudioButton.vue'; 6 | export { default as ResetButton } from './ResetButton.vue'; 7 | export { default as ReturnButton } from './ReturnButton.vue'; 8 | export { default as ScrollMemory } from './ScrollMemory.vue'; 9 | export { default as SentencePanel } from './SentencePanel.vue'; 10 | export { default as SettingButton } from './SettingButton.vue'; 11 | export { default as Token } from './Token.vue'; 12 | export { default as WordCard } from './WordCard.vue'; 13 | export { default as YoudaoCard } from './YoudaoCard.vue'; 14 | -------------------------------------------------------------------------------- /src/fluent-controls/FluentButton.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 27 | 28 | 81 | -------------------------------------------------------------------------------- /src/fluent-controls/FluentHyperlink.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 35 | -------------------------------------------------------------------------------- /src/fluent-controls/FluentInput.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 24 | 25 | 59 | -------------------------------------------------------------------------------- /src/fluent-controls/FluentRadio.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 28 | 29 | 116 | -------------------------------------------------------------------------------- /src/fluent-controls/FluentSelect.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 40 | -------------------------------------------------------------------------------- /src/fluent-controls/fluent-scrollbar.css: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar { 2 | width: var(--scrollbar-width); 3 | height: var(--scrollbar-width); 4 | background-color: transparent; 5 | } 6 | 7 | ::-webkit-scrollbar-track, 8 | ::-webkit-scrollbar-track-piece, 9 | ::-webkit-scrollbar-corner { 10 | background-color: transparent; 11 | } 12 | 13 | /* set background-clip for transparent border */ 14 | ::-webkit-scrollbar-thumb { 15 | background-color: var(--scrollbar-thumb-background); 16 | border-radius: calc(var(--scrollbar-width) / 2); 17 | border: 5px solid transparent; 18 | background-clip: padding-box; 19 | } 20 | 21 | ::-webkit-scrollbar-thumb:hover { 22 | background-color: var(--scrollbar-thumb-background-hover); 23 | border-width: 3px; 24 | } 25 | 26 | ::-webkit-scrollbar-button { 27 | display: none; 28 | } 29 | -------------------------------------------------------------------------------- /src/fluent-controls/fluent-styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --accent: #0067c0; 3 | --control-background: #fbfbfb; 4 | --control-background-hover: #f6f6f6; 5 | --control-background-active: #f5f5f5; 6 | --control-background-disabled: #f5f5f5; 7 | --control-text-color: black; 8 | --control-text-color-active: #5d5d5d; 9 | --control-text-color-disabled: #9d9d9d; 10 | --control-accent-background-hover: #1975c5; 11 | --control-accent-background-active: #3183ca; 12 | --control-accent-background-disabled: var(--control-accent-background-active); 13 | --control-accent-text-color: white; 14 | --control-accent-text-color-active: #c2daef; 15 | --control-accent-text-color-disabled: var(--control-accent-text-color-active); 16 | --border-width: 1.4px; 17 | --border-radius: 5px; 18 | --border-color: #e5e5e5; 19 | --border-bottom-color: #cccccc; 20 | --border-accent-color: #1473c5; 21 | --border-accent-bottom-color: #003e73; 22 | --font-size: 16px; 23 | --font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 24 | /* window */ 25 | --window-background: #f3f3f3; 26 | /* text input */ 27 | --input-text-background-focus: white; 28 | --input-text-border-bottom-width-focus: 2px; 29 | --placeholder-color: #5f5f5f; 30 | --placeholder-color-focus: #8d8d8d; 31 | /* radio button */ 32 | --radio-border-color: #838383; 33 | --radio-background: #ededed; 34 | --radio-background-hover: #e5e5e5; 35 | --radio-background-active: #dcdcdc; 36 | /* scrollbar */ 37 | --scrollbar-width: 15px; 38 | --scrollbar-thumb-background: #8a8a8a; 39 | --scrollbar-thumb-background-hover: #636363; 40 | } 41 | -------------------------------------------------------------------------------- /src/fluent-controls/generateUniqueId.ts: -------------------------------------------------------------------------------- 1 | let counter: number = 0; 2 | 3 | export function generateUniqueId(prefix?: string): string { 4 | counter += 1; 5 | if (prefix != null) { 6 | return `${prefix}-id-${counter}`; 7 | } else { 8 | return `id-${counter}`; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/fluent-controls/index.ts: -------------------------------------------------------------------------------- 1 | export { useHover } from './useHover'; 2 | export { generateUniqueId } from './generateUniqueId'; 3 | export { default as FluentButton } from './FluentButton.vue'; 4 | export { default as FluentSelect } from './FluentSelect.vue'; 5 | export { default as FluentInput } from './FluentInput.vue'; 6 | export { default as FluentRadio } from './FluentRadio.vue'; 7 | export { default as FluentHyperlink } from './FluentHyperlink.vue'; 8 | -------------------------------------------------------------------------------- /src/fluent-controls/useHover.ts: -------------------------------------------------------------------------------- 1 | import { ref, watch, computed } from 'vue'; 2 | import { useRoute } from 'vue-router'; 3 | 4 | /** 用 JavaScript 实现 hover 以同时支持鼠标和触摸 */ 5 | export function useHover() { 6 | const hovered = ref(false); 7 | const route = useRoute(); 8 | 9 | /** 在触摸事件触发后的短时间内忽略鼠标事件,避免控件仍然处于 hover 状态 */ 10 | let ignoreMouse = false; 11 | const TIMEOUT = 500; 12 | 13 | const listeners = { 14 | touchstart() { 15 | hovered.value = true; 16 | ignoreMouse = true; 17 | setTimeout(() => ignoreMouse = false, TIMEOUT); 18 | }, 19 | touchend() { 20 | hovered.value = false; 21 | ignoreMouse = true; 22 | setTimeout(() => ignoreMouse = false, TIMEOUT); 23 | }, 24 | touchcancel() { 25 | hovered.value = false; 26 | ignoreMouse = true; 27 | setTimeout(() => ignoreMouse = false, TIMEOUT); 28 | }, 29 | mouseenter() { 30 | if (!ignoreMouse) { 31 | hovered.value = true; 32 | } 33 | }, 34 | mouseleave() { 35 | if (!ignoreMouse) { 36 | hovered.value = false; 37 | } 38 | } 39 | }; 40 | 41 | watch(() => route.name, () => { 42 | hovered.value = false; // reset hover state when changing pages 43 | }); 44 | 45 | const classes = computed(() => ({ hover: hovered.value })); 46 | 47 | return { hovered, listeners, classes }; 48 | } 49 | -------------------------------------------------------------------------------- /src/logics/anki-connect.ts: -------------------------------------------------------------------------------- 1 | import { fetch } from '@tauri-apps/plugin-http'; 2 | 3 | import { typeAssertion } from './typing'; 4 | 5 | /** 6 | * Anki Connect API: https://foosoft.net/projects/anki-connect/ 7 | */ 8 | export class AnkiConnectApi { 9 | public url: string; 10 | 11 | public constructor(url: string) { 12 | this.url = url; 13 | } 14 | 15 | public async invoke(action: string, params?: Record): Promise { 16 | // 使用 Tauri 的 fetch 发送 HTTP 请求,以规避跨域问题 17 | const response = await fetch(this.url, { 18 | method: 'POST', 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | 'Accept': 'application/json', 22 | // Tauri v2 中需要设置请求头的 Origin 为 http://localhost, 23 | // 或者空字符串(Tauri 会移除请求头中的 Origin), 24 | // 否则 Anki Connect 会返回 HTTP 403 Forbidden。 25 | // https://github.com/tauri-apps/plugins-workspace/issues/1968#issuecomment-2561164698 26 | 'Origin': 'http://localhost' 27 | }, 28 | body: JSON.stringify({ action, params, version: 6 }), 29 | // mode: 'no-cors' // 添加这一行以规避跨域问题 30 | }); 31 | if (!response.ok) { 32 | throw new Error( 33 | 'HTTP request is not ok. ' + 34 | `status: ${response.status}, ` + 35 | `data: ${await response.text()}, ` + 36 | `headers: ${JSON.stringify(response.headers)}.` 37 | ); 38 | } 39 | const data = await response.json(); 40 | if (!(data instanceof Object)) { 41 | throw TypeError(`Expect response data to be Object but receive ${typeof data}: ${data}`); 42 | } 43 | if (!('error' in data)) { 44 | throw new Error('response is missing required error field'); 45 | } 46 | if (!('result' in data)) { 47 | throw new Error('response is missing required result field'); 48 | } 49 | typeAssertion<{ error: string | null, result: T; }>(data); 50 | if (data.error != null) { 51 | throw new Error(data.error); 52 | } 53 | return data.result; 54 | } 55 | 56 | // #region Deck Actions, https://foosoft.net/projects/anki-connect/#deck-actions 57 | /** Gets the complete list of deck names for the current user. */ 58 | public async deckNames(): Promise { 59 | return await this.invoke('deckNames'); 60 | } 61 | 62 | /** Gets the complete list of deck names and their respective IDs for the current user. */ 63 | public async deckNamesAndIds(): Promise> { 64 | return await this.invoke('deckNamesAndIds'); 65 | } 66 | 67 | /** Create a new empty deck. Will not overwrite a deck that exists with the same name. */ 68 | public async createDeck(deck: string): Promise { 69 | return await this.invoke('createDeck', { deck }); 70 | } 71 | // #endregion 72 | 73 | // #region Model Actions, https://foosoft.net/projects/anki-connect/#model-actions 74 | /** Gets the complete list of model names for the current user. */ 75 | public async modelNames(): Promise { 76 | return await this.invoke('modelNames'); 77 | } 78 | 79 | /** Gets a list of models for the provided model names from the current user. */ 80 | public async findModelsByName(modelNames: string[]): Promise[]> { 81 | return await this.invoke('findModelsByName', { modelNames }); 82 | } 83 | 84 | /** 85 | * Creates a new model to be used in Anki. User must provide the `modelName`, `inOrderFields` and `cardTemplates` 86 | * to be used in the model. There are optional fields `css` and `isCloze`. If not specified, `css` will use the 87 | * default Anki css and `isCloze` will be equal to `False`. If `isCloze` is `True` then model will be created 88 | * as `Cloze`. 89 | * 90 | * Optionally the `Name` field can be provided for each entry of `cardTemplates`. By default the card names will 91 | * be `Card 1`, `Card 2`, and so on. 92 | */ 93 | public async createModel( 94 | modelName: string, 95 | inOrderFields: string[], 96 | cardTemplates: Record[], 97 | css?: string, 98 | isCloze?: boolean 99 | ): Promise { 100 | return await this.invoke('createModel', { 101 | modelName, 102 | inOrderFields, 103 | cardTemplates, 104 | css, 105 | isCloze 106 | }); 107 | } 108 | 109 | /** 110 | * Modify the templates of an existing model by name. 111 | * Only specifies cards and specified sides will be modified. 112 | * If an existing card or side is not included in the request, it will be left unchanged. 113 | */ 114 | public async updateModelTemplates(name: string, templates: Record>) { 115 | await this.invoke('updateModelTemplates', { model: { name, templates } }); 116 | } 117 | 118 | /** Modify the CSS styling of an existing model by name. */ 119 | public async updateModelStyling(name: string, css: string) { 120 | await this.invoke('updateModelStyling', { model: { name, css } }); 121 | } 122 | // #endregion 123 | 124 | // #region Note Actions, https://foosoft.net/projects/anki-connect/#note-actions 125 | /** 126 | * Creates a note using the given deck and model, with the provided field values and tags. 127 | * Returns the identifier of the created note created on success, and null on failure. 128 | */ 129 | public async addNote( 130 | deckName: string, 131 | modelName: string, 132 | fields: Record, 133 | audio?: { url: string, filename: string, fields: string[]; }[], 134 | options?: { allowDuplicate: boolean; } 135 | ): Promise { 136 | return await this.invoke('addNote', { 137 | note: { 138 | deckName, 139 | modelName, 140 | fields, 141 | audio, 142 | options 143 | } 144 | }); 145 | } 146 | 147 | /** 148 | * Deletes notes with the given ids. If a note has several cards associated with it, 149 | * all associated cards will be deleted. 150 | */ 151 | public async deleteNotes(notes: number[]) { 152 | await this.invoke('deleteNotes', { notes }); 153 | } 154 | // #endregion 155 | 156 | // #region Graphical Actions, https://foosoft.net/projects/anki-connect/#graphical-actions 157 | /** 158 | * Opens the Edit dialog with a note corresponding to given note ID. The dialog is similar to 159 | * the Edit Current dialog, but: 160 | * 161 | * - has a Preview button to preview the cards for the note 162 | * - has a Browse button to open the browser with these cards 163 | * - has Previous/Back buttons to navigate the history of the dialog 164 | * - has no bar with the Close button 165 | */ 166 | public async guiEditNote(note: number) { 167 | await this.invoke('guiEditNote', { note }); 168 | } 169 | // #endregion 170 | } 171 | -------------------------------------------------------------------------------- /src/logics/anki.ts: -------------------------------------------------------------------------------- 1 | import { AnkiConnectApi } from './anki-connect'; 2 | import { typeAssertion } from './typing'; 3 | import type { CollinsItem, OxfordItem, YoudaoItem } from './dict'; 4 | import { escapeHTML } from './stringutils'; 5 | 6 | /** 7 | * Anki 服务类,在 Anki Connect API 的基础上针对应用的需求进行了封装。 8 | */ 9 | export class AnkiService extends AnkiConnectApi { 10 | public constructor(url: string) { 11 | super(url); 12 | } 13 | 14 | /** 创建划词助手笔记模板 */ 15 | public async createMarkerModel(modelName: string) { 16 | await this.createModel( 17 | modelName, 18 | ["单词", "音标", "释义", "笔记", "例句", "url", "发音"], // inOrderFields 19 | [ 20 | { 21 | "Name": "Card 1", 22 | "Front": MODEL_FRONT, 23 | "Back": MODEL_BACK 24 | } 25 | ], // cardTemplates 26 | MODEL_CSS 27 | ); 28 | } 29 | 30 | /** 获取划词助手笔记模板的第一个 model template */ 31 | private async getMarkerModelTemplate(modelName: string): Promise<{ 32 | name: string; 33 | ord: number; 34 | /** 卡片正面模板 */ 35 | qfmt: string; 36 | /** 卡片背面模板 */ 37 | afmt: string; 38 | }> { 39 | const models = await this.findModelsByName([modelName]); 40 | if (models.length === 0) { 41 | throw new Error(`Model ${modelName} 不存在`); 42 | } 43 | const model = models[0]; 44 | const templates = model.tmpls as { 45 | name: string; 46 | ord: number; 47 | qfmt: string; 48 | afmt: string; 49 | }[]; 50 | if (templates.length === 0) { 51 | throw new Error(`Model ${modelName} 没有卡片模板`); 52 | } 53 | return templates[0]; 54 | } 55 | 56 | /** 更新划词助手的笔记模板(更新 model templates 和 model styling) */ 57 | public async updateMarkerModel(modelName: string) { 58 | const template = await this.getMarkerModelTemplate(modelName); 59 | const templateName = template.name; 60 | await this.updateModelTemplates(modelName, { [templateName]: { Front: MODEL_FRONT, Back: MODEL_BACK } }); 61 | await this.updateModelStyling(modelName, MODEL_CSS); 62 | } 63 | 64 | /** 获取划词助手笔记模板的版本号 */ 65 | public async getCardTemplateVersionByModelName(modelName: string): Promise { 66 | const template = await this.getMarkerModelTemplate(modelName); 67 | return extractCardTemplateVersion(template.qfmt); 68 | } 69 | 70 | /** 添加一条划词助手单词笔记 */ 71 | public async addMarkerNote( 72 | deckName: string, 73 | modelName: string, 74 | fields: Fields, 75 | audioURL: string, 76 | audioFilename: string 77 | ): Promise { 78 | return await this.addNote( 79 | deckName, 80 | modelName, 81 | { ...fields }, // fields 82 | [ 83 | { 84 | url: audioURL, 85 | filename: audioFilename, 86 | fields: ['发音'] 87 | } 88 | ], // audio 89 | { allowDuplicate: true } // options 90 | ); 91 | } 92 | } 93 | 94 | /** 划词助手笔记模板的字段 */ 95 | interface Fields { 96 | '单词': string; 97 | '音标'?: string; 98 | '释义'?: string; 99 | '笔记'?: string; 100 | '例句': string; 101 | 'url'?: string; 102 | } 103 | 104 | function makeMeaning(item: CollinsItem | OxfordItem): string | null { 105 | const meaning = []; 106 | let firstLine: string | null = `${escapeHTML(item.sense ?? '')} ${escapeHTML((item as { ext?: string; }).ext ?? '')}`.trim(); 107 | if (firstLine === '') { 108 | firstLine = null; 109 | } else { 110 | meaning.push(firstLine); 111 | } 112 | if (item.enDef != null) { 113 | if (firstLine != null) { 114 | meaning[0] = `${firstLine}
`; 115 | } 116 | // Collins 词典的 EnDef 含有 标签,不转义 117 | meaning.push(`${item.enDef}`); 118 | } 119 | if (item.cnDef != null) { 120 | if (item.enDef == null && firstLine != null) { 121 | meaning[0] = `${firstLine}
`; 122 | } 123 | meaning.push(`${escapeHTML(item.cnDef)}`); 124 | } 125 | return (meaning.length > 0) ? meaning.join('
') : null; 126 | } 127 | 128 | function makeMeaningFromYoudao(item: YoudaoItem): string | null { 129 | const meaning = []; 130 | let firstLine: string | null = `${escapeHTML(item.sense ?? '')}`; 131 | if (firstLine === '') { 132 | firstLine = null; 133 | } else { 134 | meaning.push(firstLine); 135 | } 136 | if (item.cnDef != null) { 137 | if (firstLine != null) { 138 | meaning[0] = `${firstLine}
`; 139 | } 140 | meaning.push(`${escapeHTML(item.cnDef)}`); 141 | } 142 | return (meaning.length > 0) ? meaning.join('
') : null; 143 | } 144 | 145 | /** 根据单词释义和例句生成划词助手单词笔记的字段 */ 146 | export function makeFields( 147 | dict: 'collins' | 'oxford' | 'youdao', 148 | item: CollinsItem | OxfordItem | YoudaoItem, 149 | sentence: string 150 | ): Fields { 151 | if (dict === 'collins') { 152 | typeAssertion(item); 153 | return { 154 | '单词': item.word, 155 | '音标': item.phonetic ?? undefined, 156 | '释义': makeMeaning(item) ?? undefined, 157 | '例句': sentence 158 | }; 159 | } else if (dict === 'oxford') { 160 | typeAssertion(item); 161 | return { 162 | '单词': item.phrase ?? item.word, 163 | '音标': item.phonetic ?? undefined, 164 | '释义': makeMeaning(item) ?? undefined, 165 | '例句': sentence 166 | }; 167 | } else if (dict === 'youdao') { 168 | typeAssertion(item); 169 | return { 170 | '单词': item.word, 171 | '音标': item.phonetic ?? undefined, 172 | '释义': makeMeaningFromYoudao(item) ?? undefined, 173 | '例句': sentence 174 | }; 175 | } else { 176 | throw new Error(`Unknown dict: ${dict}`); 177 | } 178 | } 179 | 180 | import MODEL_FRONT from '../assets/model-front.html?raw'; 181 | import MODEL_BACK from '../assets/model-back.html?raw'; 182 | import MODEL_CSS from '../assets/model-css.css?raw'; 183 | 184 | function extractCardTemplateInfo(html: string): Record | null { 185 | const parser = new DOMParser(); 186 | // 解析 HTML 字符串为一个 Document 对象 187 | const doc = parser.parseFromString(html, 'text/html'); 188 | const scriptElement = doc.getElementById('com.zhb2000.anki-marker_card-template-info'); 189 | if (scriptElement != null) { 190 | // 获取 script 节点的文本内容 191 | const jsonString = scriptElement.textContent; 192 | if (jsonString != null) { 193 | // 解析 JSON 字符串为对象并返回 194 | return JSON.parse(jsonString); 195 | } else { 196 | return null; 197 | } 198 | } 199 | return null; 200 | } 201 | 202 | function extractCardTemplateVersion(html: string): string | null { 203 | const info = extractCardTemplateInfo(html); 204 | return (info != null && typeof info.version === 'string') ? info.version : null; 205 | } 206 | 207 | /** 应用内置的卡片模板的版本号 */ 208 | export const CARD_TEMPLATE_VERSION = extractCardTemplateVersion(MODEL_FRONT)!; 209 | -------------------------------------------------------------------------------- /src/logics/config.ts: -------------------------------------------------------------------------------- 1 | import * as api from '../tauri-api'; 2 | 3 | import { invoke } from './utils'; 4 | 5 | interface ConfigModel { 6 | ankiConnectURL: string; 7 | deckName: string; 8 | modelName: string; 9 | } 10 | 11 | const CONFIG_KEYS = ['ankiConnectURL', 'deckName', 'modelName'] as const; 12 | 13 | /** 配置项的默认值 */ 14 | export const CONFIG_DEFAULTS: Record = { 15 | ankiConnectURL: 'http://localhost:8765', 16 | deckName: '划词助手默认牌组', 17 | modelName: '划词助手默认单词模板', 18 | }; 19 | 20 | export class Config implements ConfigModel { 21 | /** The path of the configuration file. */ 22 | public readonly path: string; 23 | /** Whether the app is in portable mode. */ 24 | public readonly portable: boolean; 25 | /** Anki Connect 服务的 URL */ 26 | public ankiConnectURL!: string; 27 | /** 将划词结果添加到的牌组名 */ 28 | public deckName!: string; 29 | /** 划词结果使用的笔记模板名 */ 30 | public modelName!: string; 31 | /** 存储配置项的对象 */ 32 | private config: ConfigModel; 33 | /** 被修改过的配置项 */ 34 | private modified: Partial; 35 | // Config 对象被设计为始终存活的全局单例,因此不需要取消事件监听 36 | /** 'config-changed' 事件对应的取消监听函数 */ 37 | public __unlistenConfigChanged?: () => void; 38 | /** 'config-watcher-error' 事件对应的取消监听函数 */ 39 | public __unlistenConfigWatcherError?: () => void; 40 | 41 | private constructor(config: ConfigModel, path: string, portable: boolean) { 42 | this.config = config; 43 | this.modified = {}; 44 | this.path = path; 45 | this.portable = portable; 46 | for (const key of CONFIG_KEYS) { 47 | this.defineAccessor(key); 48 | } 49 | } 50 | 51 | private defineAccessor(propertyName: keyof ConfigModel): void { 52 | Object.defineProperty(this, propertyName, { 53 | get(this: Config) { 54 | return this.config[propertyName]; 55 | }, 56 | set(this: Config, value) { 57 | if (value !== this.config[propertyName]) { 58 | this.config[propertyName] = value; 59 | this.modified[propertyName] = value; 60 | } 61 | }, 62 | enumerable: true, 63 | configurable: true, 64 | }); 65 | } 66 | 67 | public async commit() { 68 | if (Object.keys(this.modified).length === 0) { 69 | return; 70 | } 71 | await invoke('commit_config', { modified: this.modified, config_path: this.path }); 72 | this.modified = {}; 73 | } 74 | 75 | public async reload() { 76 | const newConfig = await Config.load(); 77 | if (CONFIG_KEYS.some(key => this.config[key] !== newConfig.config[key])) { 78 | this.config = newConfig.config; 79 | this.modified = {}; 80 | } 81 | } 82 | 83 | /** 84 | * 启动配置文件监视器。 85 | * Return true if the watcher is started successfully, false if it's already started. 86 | */ 87 | public async startWatcher(): Promise { 88 | return startConfigWatcher(this); 89 | } 90 | 91 | public static async load(): Promise { 92 | const [config_path, cfg, portable] = await Promise.all([ 93 | invoke('config_path'), 94 | invoke('read_config'), 95 | invoke('is_portable') 96 | ]); 97 | return new Config(cfg, config_path, portable); 98 | } 99 | } 100 | 101 | export async function showInExplorer(path: string) { 102 | await invoke('show_in_explorer', { path }); 103 | } 104 | 105 | export async function openFile(path: string) { 106 | await api.shell.open(path); 107 | } 108 | 109 | /** 110 | * 启动配置文件监视器。 111 | * Return true if the watcher is started successfully, false if it's already started. 112 | */ 113 | export async function startConfigWatcher(config: Config): Promise { 114 | const newWatcherStarted = await invoke('start_config_watcher'); 115 | // listeners set in the front-end will be removed after the page is reloaded 116 | if (config.__unlistenConfigChanged == null) { 117 | // 监听 'config-changed' 事件,以便在配置文件被修改时重新加载配置 118 | config.__unlistenConfigChanged = await api.event.listen('config-changed', async () => { 119 | await config.reload(); 120 | }); 121 | } 122 | if (config.__unlistenConfigWatcherError == null) { 123 | // 监听 'config-watcher-error' 事件,以便在配置文件监视器出错时输出错误信息 124 | config.__unlistenConfigWatcherError = await api.event.listen('config-watcher-error', () => { 125 | console.error('Config watcher error'); 126 | }); 127 | } 128 | return newWatcherStarted; 129 | } 130 | -------------------------------------------------------------------------------- /src/logics/dict.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from './utils'; 2 | import { searchYoudaoDict as searchYoudaoWebDict } from './youdao'; 3 | 4 | export interface CollinsItem { 5 | word: string; 6 | phonetic: string | null; 7 | sense: string | null; 8 | enDef: string | null; 9 | cnDef: string | null; 10 | } 11 | 12 | export interface OxfordItem { 13 | word: string; 14 | phrase: string | null; 15 | phonetic: string | null; 16 | sense: string | null; 17 | ext: string | null; 18 | enDef: string | null; 19 | cnDef: string | null; 20 | } 21 | 22 | /** 获取单词的原型 */ 23 | async function getWordBase(word: string): Promise { 24 | const base = await invoke('get_word_base', { word }); 25 | return base; 26 | } 27 | 28 | /** 29 | * 尝试将单词转换为原型形式 30 | * 31 | * @returns 32 | * - 若单词有原型形式,则返回一个数组,第一个元素为原单词,第二个元素为原型形式。 33 | * - 否则返回一个只包含原单词的数组。 34 | */ 35 | async function convertWord(word: string): Promise<[string] | [string, string]> { 36 | const base = await getWordBase(word); 37 | if (base != null) { 38 | return [word, base]; 39 | } 40 | return [word]; 41 | } 42 | 43 | export async function searchCollins(word: string, autoConvert: boolean = true): Promise { 44 | const words = autoConvert ? await convertWord(word) : [word]; 45 | const promises = words.map(word => invoke('search_collins', { word })); 46 | const results: CollinsItem[][] = await Promise.all(promises); 47 | const items = results.flat(); 48 | return items; 49 | } 50 | 51 | export async function searchOxford(word: string, autoConvert: boolean = true): Promise { 52 | const words = autoConvert ? await convertWord(word) : [word]; 53 | const promises = words.map(word => invoke('search_oxford', { word })); 54 | const results: OxfordItem[][] = await Promise.all(promises); 55 | const items = results.flat(); 56 | return items; 57 | } 58 | 59 | export interface YoudaoItem { 60 | word: string; 61 | phonetic: string | null; 62 | sense: string | null; 63 | cnDef: string | null; 64 | /** 65 | * 释义类型,可能的值有: 66 | * - 'concise':简明释义 67 | * - 'web':网络释义 68 | * - 'phrase':短语释义 69 | */ 70 | meaningType: 'concise' | 'web' | 'phrase'; 71 | } 72 | 73 | /** 将有道词典的 custom-translation 的 content 为词性和释义 */ 74 | function splitYoudaoCustomTranslationContent(content: string): [string | null, string] { 75 | const match = content.match(/^([a-zA-Z.]+)\s+(.+)$/); // 匹配“词性”和“释义”部分 76 | if (match != null) { 77 | return [ 78 | match[1], // 提取词性 79 | match[2].trim() // 提取释义并去除多余空格 80 | ]; 81 | } 82 | // 没有词性,只有释义 83 | return [ 84 | null, 85 | content.trim() 86 | ]; 87 | } 88 | 89 | export async function searchYoudao(query: string): Promise { 90 | const searchResult = await searchYoudaoWebDict(query); 91 | if (searchResult.customTranslations.length === 0) { 92 | return []; 93 | } 94 | let phonetic: string | null = null; 95 | if (searchResult.ukPhoneticSymbol != null) { 96 | phonetic = `英[${searchResult.ukPhoneticSymbol}]`; 97 | } 98 | if (searchResult.usPhoneticSymbol != null) { 99 | phonetic = (phonetic != null) 100 | ? `${phonetic} 美[${searchResult.usPhoneticSymbol}]` 101 | : `美[${searchResult.usPhoneticSymbol}]`; 102 | } 103 | if (searchResult.phoneticSymbol != null && phonetic == null) { 104 | phonetic = searchResult.phoneticSymbol; 105 | } 106 | const items: YoudaoItem[] = []; 107 | // 简明释义 108 | for (const customTranslation of searchResult.customTranslations) { 109 | const [sense, cnDef] = splitYoudaoCustomTranslationContent(customTranslation); 110 | items.push({ 111 | word: searchResult.returnPhrase, 112 | phonetic, 113 | sense, 114 | cnDef, 115 | meaningType: 'concise' 116 | }); 117 | } 118 | // 网络释义 119 | if (searchResult.webTranslationSame != null) { 120 | for (const value of searchResult.webTranslationSame.values) { 121 | items.push({ 122 | word: searchResult.webTranslationSame.key, 123 | phonetic, 124 | sense: null, 125 | cnDef: value, 126 | meaningType: 'web' 127 | }); 128 | } 129 | } 130 | // 短语释义 131 | for (const webTranslation of searchResult.webTranslations) { 132 | items.push({ 133 | word: webTranslation.key, 134 | phonetic, 135 | sense: null, 136 | cnDef: webTranslation.values.join(';'), 137 | meaningType: 'phrase' 138 | }); 139 | } 140 | return items; 141 | } 142 | 143 | export function makePronunciationURL(word: string, pronunciationType: 'en' | 'us'): string { 144 | const type = (pronunciationType === 'en') ? 1 : 2; 145 | return `https://dict.youdao.com/dictvoice?type=${type}&audio=${encodeURIComponent(word)}`; 146 | } 147 | 148 | export function makePronunciationFilename(word: string, pronunciationType: 'en' | 'us'): string { 149 | return `youdao_${word}_${pronunciationType}.mp3`; 150 | } 151 | -------------------------------------------------------------------------------- /src/logics/globals.ts: -------------------------------------------------------------------------------- 1 | import { reactive, ref, watch, computed } from 'vue'; 2 | import * as api from '../tauri-api'; 3 | import { fetch } from '@tauri-apps/plugin-http'; 4 | import * as semver from 'semver'; 5 | 6 | import { Config } from './config'; 7 | import * as anki from './anki'; 8 | import { AnkiService } from './anki'; 9 | import * as utils from './utils'; 10 | 11 | 12 | export let DEBUG_APP_UPDATE_CURRENT_LOW: boolean; 13 | /** GitHub Release API 有请求频率限制,开发模式下不要频繁请求 */ 14 | export let DEBUG_APP_UPDATE_NOT_FETCH: boolean; 15 | export let DEBUG_TEMPLATE_UPDATE_CURRENT_LOW: boolean; 16 | export let DEBUG_TEMPLATE_UPDATE_NOT_UPDATE: boolean; 17 | 18 | async function initDebugFlags() { 19 | const IN_DEV_MODE = !await utils.rustInRelease(); 20 | DEBUG_APP_UPDATE_CURRENT_LOW = IN_DEV_MODE ? false : false; 21 | DEBUG_APP_UPDATE_NOT_FETCH = IN_DEV_MODE ? false : false; 22 | DEBUG_TEMPLATE_UPDATE_CURRENT_LOW = IN_DEV_MODE ? false : false; 23 | DEBUG_TEMPLATE_UPDATE_NOT_UPDATE = IN_DEV_MODE ? false : false; 24 | } 25 | 26 | // #region Config 27 | let config: Config; 28 | 29 | async function initConfig() { 30 | if (config != null) { 31 | return; 32 | } 33 | const rawConfig = await Config.load(); 34 | config = reactive(rawConfig) as Config; // 转化为响应式对象以便监听 config 的变化 35 | } 36 | 37 | export async function getConfig(): Promise { 38 | await initConfig(); 39 | return config; 40 | } 41 | // #endregion 42 | 43 | // #region AnkiService 44 | let ankiService: AnkiService; 45 | 46 | async function initAnkiService() { 47 | if (ankiService != null) { 48 | return; 49 | } 50 | const cfg = await getConfig(); 51 | ankiService = new AnkiService(cfg.ankiConnectURL); 52 | /** 监听 config 的 anki-connect-url 更新,并同步到 ankiService */ 53 | watch( 54 | () => config!.ankiConnectURL, 55 | newURL => ankiService!.url = newURL 56 | ); 57 | } 58 | 59 | export async function getAnkiService(): Promise { 60 | await initAnkiService(); 61 | return ankiService; 62 | } 63 | // #endregion 64 | 65 | // #region app version 66 | interface LatestAppInfo { 67 | version: string; 68 | tagName: string; 69 | htmlURL: string; 70 | name: string; 71 | body: string; 72 | } 73 | 74 | /** GitHub Release 上最新的应用版本信息 */ 75 | export const latestAppInfo = ref(null); 76 | /** GitHub Release 上最新的应用版本(semver 格式) */ 77 | export const latestAppVersion = computed(() => latestAppInfo.value?.version); 78 | /** 最新应用版本的 Release 页面 URL */ 79 | export const latestAppHtmlURL = computed(() => latestAppInfo.value?.htmlURL); 80 | /** 最新应用版本的 Release 名称 */ 81 | export const latestAppName = computed(() => latestAppInfo.value?.name); 82 | /** 最新应用版本的 Release 说明 */ 83 | export const latestAppBody = computed(() => latestAppInfo.value?.body); 84 | /** 上一次成功请求的时间戳 */ 85 | let lastFetchTimestamp: number | null = null; 86 | /** 当前应用的版本 */ 87 | let appVersion: string; 88 | /** 是否有可用的应用更新 */ 89 | export const appUpdateAvailable = computed(() => { 90 | if (latestAppVersion.value == null) { 91 | return false; 92 | } 93 | return semver.gt(latestAppVersion.value, appVersion); 94 | }); 95 | 96 | /** 检查是否需要发送网络请求,距离上次成功请求超过 1 分钟则需要发送 */ 97 | function shouldFetchLatestAppInfo(): boolean { 98 | const now = Date.now(); // 当前时间戳(毫秒) 99 | if (lastFetchTimestamp == null) { 100 | // 如果从未发送过请求,则需要发送 101 | return true; 102 | } 103 | // 计算距离上次请求的时间差(毫秒),并判断是否超过 1 分钟 104 | const timeSinceLastFetch = now - lastFetchTimestamp; 105 | return timeSinceLastFetch > 60 * 1000; // 1 分钟 = 60 * 1000 毫秒F 106 | } 107 | 108 | export async function fetchAndSetLatestAppInfo() { 109 | if (latestAppInfo.value == null || shouldFetchLatestAppInfo()) { 110 | latestAppInfo.value = await getLatestAppInfoFromGitHubRelease(); 111 | lastFetchTimestamp = Date.now(); 112 | } 113 | } 114 | 115 | export async function getLatestAppInfo(): Promise { 116 | await fetchAndSetLatestAppInfo(); 117 | return latestAppInfo.value!; 118 | } 119 | 120 | async function makeUserAgent(): Promise { 121 | const [appVersion, osType] = await Promise.all([ 122 | api.app.getVersion(), 123 | api.os.type() 124 | ]); 125 | return `$Anki-Marker/${appVersion} (${osType}; Tauri)`; 126 | } 127 | 128 | async function getLatestAppInfoFromGitHubRelease(): Promise { 129 | if (DEBUG_APP_UPDATE_NOT_FETCH) { 130 | await new Promise(resolve => setTimeout(resolve, 1000)); 131 | return { 132 | version: '100.0.1', 133 | tagName: 'v100.0.1', 134 | htmlURL: 'https://github.com/zhb2000/anki-marker/releases/tag/v0.0.1', 135 | name: 'Anki Marker v100.0.1', 136 | body: '## [0.0.1] - 2024-03-17\r\n第一个版本。\n\n开发测试显示效果用,' + 137 | '将 `src/logics/globals.ts` 中的 `DEBUG_APP_UPDATE_NOT_FETCH` 设置为 `false` 后可正常获取最新版本。' 138 | }; 139 | } 140 | const GITHUB_RELEASE_API = 'https://api.github.com/repos/zhb2000/anki-marker/releases/latest'; 141 | const response = await fetch(GITHUB_RELEASE_API, { 142 | method: 'GET', 143 | headers: { 144 | 'Accept': 'application/vnd.github.v3+json', // 推荐明确声明 GitHub API 版本(GitHub API v3) 145 | 'User-Agent': await makeUserAgent() // GitHub REST API 要求提供 User-Agent 146 | } 147 | }); 148 | if (!response.ok) { 149 | throw new Error( 150 | 'HTTP request is not ok. ' + 151 | `status: ${response.status}, ` + 152 | `data: ${await response.text()}, ` + 153 | `headers: ${JSON.stringify(response.headers)}.` 154 | ); 155 | } 156 | const data = await response.json() as Record; 157 | if (!(data instanceof Object)) { 158 | throw TypeError(`Expect response data to be Object but receive ${typeof data}: ${data}`); 159 | } 160 | const version = semver.clean(data.tag_name); 161 | if (version == null) { 162 | throw new Error(`version ${version} cannot be cleaned by semver`); 163 | } 164 | return { 165 | version, 166 | tagName: data.tag_name as string, 167 | htmlURL: data.html_url as string, 168 | name: data.name as string, 169 | body: data.body as string 170 | }; 171 | } 172 | 173 | async function initAppVersion() { 174 | if (appVersion == null) { 175 | appVersion = await api.app.getVersion(); 176 | if (DEBUG_APP_UPDATE_CURRENT_LOW) { 177 | appVersion = '0.0.0'; 178 | } 179 | } 180 | } 181 | 182 | export async function getAppVersion(): Promise { 183 | initAppVersion(); 184 | return appVersion; 185 | } 186 | // #endregion 187 | 188 | // #region template version 189 | /** 190 | * Anki 中当前的笔记模板版本 191 | * - string: 模板版本号 192 | * - null: 未获取到模板版本 193 | * - Error: 获取模板版本时出错 194 | */ 195 | export const templateVersion = ref(new Error('initializing')); 196 | /** 是否有可用的模板更新 */ 197 | export const templateUpdateAvailable = computed(() => { 198 | if (templateVersion.value == null) { 199 | return true; // “未知”状态下默认为可更新 200 | } else if (typeof templateVersion.value === 'string') { 201 | return semver.gt(anki.CARD_TEMPLATE_VERSION, templateVersion.value); 202 | } 203 | return false; 204 | }); 205 | 206 | /** 获取 Anki 中的笔记模板版本,出错时不抛出异常,而是将异常信息存入 templateVersion */ 207 | export async function fetchAndSetTemplateVersion(modelName: string) { 208 | if (DEBUG_TEMPLATE_UPDATE_CURRENT_LOW) { 209 | templateVersion.value = '0.0.0'; 210 | return; 211 | } 212 | try { 213 | templateVersion.value = await ankiService.getCardTemplateVersionByModelName(modelName); 214 | } catch (error) { 215 | console.error(error); 216 | templateVersion.value = (error instanceof Error) ? error : new Error(String(error)); 217 | } 218 | } 219 | // #endregion 220 | 221 | // #region Theme 222 | /** 223 | * 设置 element-plus 主题色 224 | * 225 | * - 修改主题色:https://github.com/element-plus/element-plus/discussions/14659 226 | * - 主题:https://element-plus.org/zh-CN/guide/theming.html 227 | */ 228 | function setElementTheme() { 229 | const root: HTMLElement = document.documentElement; 230 | const styles = getComputedStyle(root); 231 | const accentColor = styles.getPropertyValue('--accent').trim(); 232 | setThemeColor(accentColor); 233 | } 234 | 235 | /** 调整亮度生成 element-plus 主题色的 light 颜色 */ 236 | function adjustLightness(h: number, s: number, l: number, adjustment: number): string { 237 | return `hsl(${h}, ${s}%, ${Math.min(l + adjustment, 100)}%)`; 238 | } 239 | 240 | function setThemeColor(primaryColor: string): void { 241 | const root: HTMLElement = document.documentElement; 242 | // 获取 HSL 值 243 | const { r, g, b } = utils.hexToRgb(primaryColor); 244 | const { h, s, l } = utils.rgbToHsl(r, g, b); 245 | // 设置主色和其他 light 颜色 246 | root.style.setProperty('--el-color-primary', `hsl(${h}, ${s}%, ${l}%)`); 247 | root.style.setProperty('--el-color-primary-light-3', adjustLightness(h, s, l, 10)); 248 | root.style.setProperty('--el-color-primary-light-5', adjustLightness(h, s, l, 20)); 249 | root.style.setProperty('--el-color-primary-light-7', adjustLightness(h, s, l, 30)); 250 | root.style.setProperty('--el-color-primary-light-8', adjustLightness(h, s, l, 35)); 251 | root.style.setProperty('--el-color-primary-light-9', adjustLightness(h, s, l, 40)); 252 | root.style.setProperty('--el-color-white', '#ffffff'); 253 | root.style.setProperty('--el-color-black', '#000000'); 254 | } 255 | // #endregion 256 | 257 | /** 是否已经初始化过 */ 258 | let initializedAtAppStart = false; 259 | 260 | export async function initAtAppStart() { 261 | if (initializedAtAppStart) { 262 | return; 263 | } 264 | // 初始化调试标志 265 | await initDebugFlags(); 266 | // 禁用 WebView 右键菜单和快捷键 267 | if (await utils.rustInRelease()) { 268 | utils.disableWebviewContextMenu(); 269 | utils.disableWebviewKeyboardShortcuts(); 270 | } 271 | // 设置 element-plus 主题色 272 | setElementTheme(); 273 | // 获取应用版本 274 | await initAppVersion(); 275 | // 加载配置文件 276 | try { 277 | await initConfig(); 278 | } catch (error) { 279 | console.error(error); 280 | await api.dialog.message(String(error), { title: '配置文件读取失败', kind: 'error' }); 281 | throw error; // 配置文件读取失败时不继续后续操作 282 | } 283 | // 初始化 AnkiService 对象 284 | await initAnkiService(); 285 | // 启动配置文件监听器 286 | try { 287 | await config.startWatcher(); 288 | } catch (error) { 289 | console.error(error); 290 | await api.dialog.message(String(error), { title: '配置文件监听失败', kind: 'error' }); 291 | // 配置文件监听失败时仅弹窗报错,不阻止后续操作 292 | } 293 | // 获取笔记模板版本,不等待结果。 294 | // 当模板名称或 AnkiConnect URL 改变时,重新获取模板版本。 295 | watch( 296 | () => [config.modelName, config.ankiConnectURL], 297 | async ([newModelName, _]) => await fetchAndSetTemplateVersion(newModelName), 298 | { immediate: true } 299 | ); 300 | // 检查应用更新,在初始化代码中不等待更新检查的结果,避免阻塞应用启动 301 | void (async () => { 302 | try { 303 | await fetchAndSetLatestAppInfo(); 304 | } catch (error) { 305 | console.error(error); 306 | // 应用更新检查失败时仅在控制台报错,不弹窗提示,也不阻止后续操作 307 | } 308 | })(); 309 | initializedAtAppStart = true; 310 | } 311 | -------------------------------------------------------------------------------- /src/logics/lock.ts: -------------------------------------------------------------------------------- 1 | export class AsyncLock { 2 | private isLocked: boolean; 3 | private waitingResolvers: Array<() => void>; 4 | 5 | constructor() { 6 | this.isLocked = false; 7 | this.waitingResolvers = []; 8 | } 9 | 10 | /** 检查锁是否被持有 */ 11 | public locked(): boolean { 12 | return this.isLocked; 13 | } 14 | 15 | /** 尝试获取锁 */ 16 | public async acquire(): Promise { 17 | // 如果锁已被持有,等待直到锁被释放 18 | if (this.isLocked) { 19 | await new Promise(resolve => { 20 | this.waitingResolvers.push(resolve); 21 | }); 22 | } 23 | // 获取锁 24 | this.isLocked = true; 25 | } 26 | 27 | /** 释放锁 */ 28 | public release() { 29 | // 如果队列中有等待的操作,唤醒下一个 30 | if (this.waitingResolvers.length > 0) { 31 | const resolve = this.waitingResolvers.shift(); 32 | if (resolve != null) { 33 | resolve(); 34 | } 35 | } else { 36 | // 没有等待的操作,释放锁 37 | this.isLocked = false; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/logics/stringutils.ts: -------------------------------------------------------------------------------- 1 | export function isWord(token: string): boolean { 2 | return /^\w+$/.test(token); 3 | } 4 | 5 | export function tokenize(text: string): string[] { 6 | const tokens = []; 7 | const regex = /\w+/g; 8 | let lastEnd = 0; 9 | for (let match = regex.exec(text); match != null; match = regex.exec(text)) { 10 | const token = match[0]; 11 | const start = match.index; 12 | if (lastEnd < start) { 13 | tokens.push(text.substring(lastEnd, start)); // push previous non-word token 14 | } 15 | tokens.push(token); // push current word token 16 | lastEnd = start + token.length; 17 | } 18 | if (lastEnd < text.length) { 19 | tokens.push(text.substring(lastEnd)); // push the last non-word token if exists 20 | } 21 | return tokens; 22 | } 23 | 24 | const escapeDiv = document.createElement('div'); 25 | 26 | /** 27 | * 转义 HTML 字符串中的特殊字符。 28 | * 29 | * 示例:`escapeHTML('')` 返回 `<script>alert("XSS")</script>` 30 | */ 31 | export function escapeHTML(html: string): string { 32 | escapeDiv.textContent = html; // 使用 textContent 而不是 innerHTML 来避免 HTML 解析 33 | return escapeDiv.innerHTML; // 获取转义后的 HTML 字符串 34 | } 35 | 36 | const decodeTextarea = document.createElement('textarea'); 37 | 38 | /** 39 | * 解码 HTML 字符串中的实体字符。 40 | * 41 | * 示例:`decodeHtmlEntities('<script>alert("XSS")</script>')` 返回 `` 42 | */ 43 | export function decodeHtmlEntities(content: string): string { 44 | decodeTextarea.innerHTML = content; 45 | return decodeTextarea.value; 46 | } 47 | -------------------------------------------------------------------------------- /src/logics/typing.ts: -------------------------------------------------------------------------------- 1 | export function typeAssertion(_value: any): asserts _value is T { } 2 | 3 | // 辅助类型,用于条件类型检查 4 | type IfEquals = 5 | (() => T extends X ? 1 : 2) extends 6 | (() => T extends Y ? 1 : 2) ? A : B; 7 | 8 | /** The keys of a type that are writable. */ 9 | export type WritableKeys = { 10 | [P in keyof T]: IfEquals< 11 | { [Q in P]: T[P] }, 12 | { -readonly [Q in P]: T[P] }, 13 | P 14 | > 15 | }[keyof T]; 16 | 17 | /** The keys of a type that are methods. */ 18 | export type MethodKeys = { 19 | [P in keyof T]: T[P] extends Function ? P : never 20 | }[keyof T]; 21 | 22 | export type Constructor = new (...args: any[]) => T; 23 | -------------------------------------------------------------------------------- /src/logics/utils.ts: -------------------------------------------------------------------------------- 1 | import * as api from '../tauri-api'; 2 | 3 | export * as string from './stringutils'; 4 | export { isWord, tokenize, escapeHTML } from './stringutils'; 5 | export * as typing from './typing'; 6 | 7 | export async function invoke(cmd: string, args?: Record): Promise { 8 | try { 9 | return await api.core.invoke(cmd, args); 10 | } catch (error) { 11 | throw (typeof error === 'string') ? new Error(error) : error; 12 | } 13 | } 14 | 15 | /** 16 | * 检测是否在 release 模式下 17 | * 18 | * https://github.com/tauri-apps/wry/issues/30 19 | */ 20 | export function tauriInRelease(): boolean { 21 | return window.location.hostname === 'tauri.localhost'; 22 | } 23 | 24 | export async function rustInRelease(): Promise { 25 | return await invoke('rust_in_release'); 26 | } 27 | 28 | /** 29 | * 创建一个防抖函数 30 | * 31 | * @param func 要延迟执行的函数 32 | * @param wait 延迟时间(毫秒) 33 | * @param immediate 是否立即执行,若为 `false` 则为标准的防抖逻辑,延迟 `wait` 毫秒后执行 `func`; 34 | * 若为 `true` 则立即执行函数 `func`,然后在 `wait` 毫秒内不再执行。 35 | * @returns 返回一个防抖函数 36 | */ 37 | export function debounce any>( 38 | func: F, 39 | wait: number, 40 | immediate: boolean = false 41 | ): (...args: Parameters) => void { 42 | let timeout: ReturnType | null = null; 43 | 44 | return function (this: any, ...args: Parameters) { 45 | if (timeout != null) { 46 | // 若延迟定时器已被设置,则清除之前的定时器,重新设置一个延迟定时器 47 | clearTimeout(timeout); 48 | } 49 | const callNow = immediate && (timeout == null); // 需要在设置 timeout 前判断 timeout 是否为 null 50 | timeout = setTimeout(() => { 51 | timeout = null; 52 | if (!immediate) { 53 | func.apply(this, args); // 延迟执行的防抖逻辑 54 | } 55 | }, wait); 56 | if (callNow) { 57 | func.apply(this, args); // 立即执行的防抖逻辑 58 | } 59 | }; 60 | } 61 | 62 | /** 63 | * 创建一个节流函数 64 | * 65 | * @param func 要被限制执行频率的函数 66 | * @param wait 时间间隔(毫秒) 67 | * @returns 返回一个节流函数 68 | */ 69 | export function throttle any>(func: F, wait: number): (...args: Parameters) => void { 70 | let timeout: number | null = null; 71 | let lastExec = 0; 72 | 73 | return function (this: any, ...args: Parameters): void { 74 | const now = Date.now(); 75 | 76 | const execute = () => { 77 | lastExec = now; 78 | func.apply(this, args); 79 | }; 80 | 81 | if (timeout != null) { 82 | clearTimeout(timeout); 83 | } 84 | if (now - lastExec >= wait) { 85 | execute(); 86 | } else { 87 | // ensure the last call will be executed 88 | timeout = setTimeout(execute, wait - (now - lastExec)); 89 | } 90 | }; 91 | } 92 | 93 | /** 将 HEX 转换为 RGB */ 94 | export function hexToRgb(hex: string): { r: number; g: number; b: number; } { 95 | const bigint: number = parseInt(hex.slice(1), 16); 96 | const r: number = (bigint >> 16) & 255; 97 | const g: number = (bigint >> 8) & 255; 98 | const b: number = bigint & 255; 99 | return { r, g, b }; 100 | } 101 | 102 | /** 将 RGB 转换为 HSL */ 103 | export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number; } { 104 | r /= 255; 105 | g /= 255; 106 | b /= 255; 107 | 108 | const max: number = Math.max(r, g, b); 109 | const min: number = Math.min(r, g, b); 110 | let h: number = 0, s: number = 0, l: number = (max + min) / 2; 111 | 112 | if (max !== min) { 113 | const d: number = max - min; 114 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 115 | switch (max) { 116 | case r: h = (g - b) / d + (g < b ? 6 : 0); break; 117 | case g: h = (b - r) / d + 2; break; 118 | case b: h = (r - g) / d + 4; break; 119 | } 120 | h /= 6; 121 | } 122 | 123 | return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) }; 124 | } 125 | 126 | export function disableWebviewContextMenu(): void { 127 | document.addEventListener('contextmenu', e => { 128 | if (!((e.target instanceof HTMLInputElement && e.target.type === 'text') 129 | || e.target instanceof HTMLTextAreaElement)) { 130 | e.preventDefault(); 131 | } 132 | }); 133 | } 134 | 135 | /** 136 | * Disable WebView keyboard shortcuts. 137 | * 138 | * See https://support.google.com/chrome/answer/157179 139 | */ 140 | export function disableWebviewKeyboardShortcuts(): void { 141 | document.addEventListener("keydown", e => { 142 | if (e.key === 'F5' || (e.ctrlKey && e.key === 'r')) { // 禁用 F5 或 Ctrl + R 刷新 143 | e.preventDefault(); 144 | } 145 | if (e.key === 'F3' || (e.ctrlKey && e.key === 'f')) { // 禁用 F3 或 Ctrl + F 查找 146 | e.preventDefault(); 147 | } 148 | if (e.ctrlKey && e.key === 'j') { // 禁用 Ctrl + J 打开下载内容 149 | e.preventDefault(); 150 | } 151 | if (e.ctrlKey && e.key === 'h') { // 禁用 Ctrl + H 打开历史记录 152 | e.preventDefault(); 153 | } 154 | if (e.key === 'F7') { // 禁用 F7 开启光标浏览模式 155 | e.preventDefault(); 156 | } 157 | if (e.ctrlKey && e.key === 'g') { // 禁用 Ctrl + G 查找下一个 158 | e.preventDefault(); 159 | } 160 | if (e.ctrlKey && e.key === 'p') { // 禁用 Ctrl + P 打印 161 | e.preventDefault(); 162 | } 163 | }); 164 | } 165 | -------------------------------------------------------------------------------- /src/logics/youdao.ts: -------------------------------------------------------------------------------- 1 | import { fetch } from '@tauri-apps/plugin-http'; 2 | import { decodeHtmlEntities } from './stringutils'; 3 | 4 | /** 表示一条网络释义的结构 */ 5 | interface WebTranslation { 6 | /** 网络释义的关键词。*/ 7 | key: string; 8 | 9 | /** 关键词对应的多个释义值。*/ 10 | values: string[]; 11 | } 12 | 13 | /** 表示解析 XML 后的完整数据结构 */ 14 | interface SearchResult { 15 | /** 返回的查询短语,通常是用户查询的关键词。*/ 16 | returnPhrase: string; 17 | 18 | /** 查询短语的音标,可能为空,例如 'mə'ʃi:n'。*/ 19 | phoneticSymbol: string | null; 20 | 21 | ukPhoneticSymbol: string | null; 22 | 23 | usPhoneticSymbol: string | null; 24 | 25 | /** 翻译类型,例如 'ec' 表示英汉翻译。*/ 26 | translationType: string | null; 27 | 28 | /** 自定义翻译结果的数组,例如英汉翻译,包含多个翻译内容。*/ 29 | customTranslations: string[]; 30 | 31 | webTranslationSame: WebTranslation | null; 32 | 33 | /** 网络释义结果的数组,包含关键词和对应的多个翻译。*/ 34 | webTranslations: WebTranslation[]; 35 | } 36 | 37 | // 参考:https://github.com/g8up/youDaoDict/issues/19 38 | export async function searchYoudaoDict(query: string): Promise { 39 | const params = new URLSearchParams({ 40 | client: 'deskdict', 41 | keyfrom: 'chrome.extension', 42 | pos: '-1', 43 | doctype: 'xml', 44 | xmlVersion: '3.2', 45 | dogVersion: '1.0', 46 | vendor: 'unknown', 47 | appVer: '3.1.17.4208', 48 | ue: 'utf8', 49 | le: 'eng', 50 | q: query 51 | }); 52 | const url = `https://dict.youdao.com/fsearch?${params.toString()}`; 53 | const response = await fetch(url, { 54 | method: 'GET', 55 | headers: { 56 | 'Accept': 'text/xml', 57 | // 需要设置 Origin 请求头, 58 | // 或者设置为空字符串(Tauri 会移除请求头中的 Origin), 59 | // 否则有道词典 API 会返回 Invalid CORS request。 60 | 'Origin': 'https://dict.youdao.com', 61 | 'Connection': 'keep-alive' 62 | } 63 | }); 64 | const xmlText = await response.text(); 65 | const parser = new DOMParser(); 66 | const xmlDoc = parser.parseFromString(xmlText, 'text/xml'); 67 | // 提取 return-phrase 节点的内容 68 | const returnPhrase = decodeHtmlEntities(xmlDoc.querySelector('return-phrase')?.textContent ?? ''); 69 | // 提取 phonetic-symbol 节点的内容 70 | const phoneticSymbol = xmlDoc.querySelector('phonetic-symbol')?.textContent ?? null; 71 | const ukPhoneticSymbol = xmlDoc.querySelector('uk-phonetic-symbol')?.textContent ?? null; 72 | const usPhoneticSymbol = xmlDoc.querySelector('us-phonetic-symbol')?.textContent ?? null; 73 | // 提取 custom-translation 数据 74 | const translationType = xmlDoc.querySelector('custom-translation type')?.textContent ?? null; 75 | const customTranslationNodes = xmlDoc.querySelectorAll('custom-translation translation'); 76 | const customTranslations: string[] = Array 77 | .from(customTranslationNodes) 78 | .map(node => node.querySelector('content')?.textContent?.trim() ?? ''); 79 | // 提取 web-translation 数据 80 | const webTranslationNodes = xmlDoc.querySelectorAll('yodao-web-dict web-translation'); 81 | let webTranslationSame: WebTranslation | null = null; 82 | const webTranslations: WebTranslation[] = []; 83 | for (const node of webTranslationNodes) { 84 | const key = node.querySelector('key')?.textContent?.trim() ?? ''; 85 | const valueNodes = node.querySelectorAll('trans value'); 86 | const values = Array.from(valueNodes).map(valueNode => valueNode.textContent?.trim() ?? ''); 87 | if (node.getAttribute('same') === 'true' || key === returnPhrase) { 88 | // 表示查询词的网络释义 89 | webTranslationSame = { key, values }; 90 | } else { 91 | // 其余 表示查询词的相关短语的网络释义 92 | webTranslations.push({ key, values }); 93 | } 94 | } 95 | return { 96 | returnPhrase, 97 | phoneticSymbol, 98 | ukPhoneticSymbol, 99 | usPhoneticSymbol, 100 | translationType, 101 | customTranslations, 102 | webTranslationSame, 103 | webTranslations, 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import 'normalize.css'; 3 | import ElementPlus from 'element-plus'; 4 | import 'element-plus/dist/index.css'; 5 | 6 | import './fluent-controls/fluent-styles.css'; 7 | import './fluent-controls/fluent-scrollbar.css'; 8 | import App from './App.vue'; 9 | import { router } from './router'; 10 | 11 | const app = createApp(App); 12 | app.use(router); 13 | app.use(ElementPlus); 14 | app.mount('#app'); 15 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router'; 2 | 3 | import Main from './views/Main.vue'; 4 | import Settings from './views/Settings.vue'; 5 | 6 | export const router = createRouter({ 7 | history: createWebHistory(), 8 | routes: [ 9 | { 10 | path: '/', 11 | name: 'Main', 12 | component: Main 13 | }, 14 | { 15 | path: '/settings', 16 | name: 'Settings', 17 | component: Settings 18 | } 19 | ] 20 | }); 21 | -------------------------------------------------------------------------------- /src/tauri-api.ts: -------------------------------------------------------------------------------- 1 | // 导出 @tauri-apps/api 中的所有模块 2 | export * from '@tauri-apps/api'; 3 | 4 | // 导出 @tauri-apps/plugin-xxx 中的所有模块 5 | export * as clipboard from '@tauri-apps/plugin-clipboard-manager'; 6 | export * as dialog from '@tauri-apps/plugin-dialog'; 7 | export * as http from '@tauri-apps/plugin-http'; 8 | export * as os from '@tauri-apps/plugin-os'; 9 | export * as shell from '@tauri-apps/plugin-shell'; 10 | -------------------------------------------------------------------------------- /src/views/Main.vue: -------------------------------------------------------------------------------- 1 | 308 | 309 | 370 | 371 | 481 | -------------------------------------------------------------------------------- /src/views/Settings.vue: -------------------------------------------------------------------------------- 1 | 169 | 170 | 320 | 321 | 332 | 333 | 412 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module "*.vue" { 4 | import type { DefineComponent } from "vue"; 5 | const component: DefineComponent<{}, {}, any>; 6 | export default component; 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import svgLoader from 'vite-svg-loader'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig(async () => ({ 7 | plugins: [vue(), svgLoader()], 8 | 9 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 10 | // 11 | // 1. prevent vite from obscuring rust errors 12 | clearScreen: false, 13 | // 2. tauri expects a fixed port, fail if that port is not available 14 | server: { 15 | port: 1420, 16 | strictPort: true, 17 | }, 18 | // 3. to make use of `TAURI_DEBUG` and other env variables 19 | // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand 20 | envPrefix: ["VITE_", "TAURI_"], 21 | })); 22 | --------------------------------------------------------------------------------