├── .npmrc ├── .eslintignore ├── versions.json ├── doc ├── setting.png ├── translator.gif └── README(EN).md ├── src ├── assets │ ├── engine-logo │ │ ├── youdao.png │ │ └── logos.ts │ └── icon │ │ ├── PlayIcon.vue │ │ ├── NoteIcon.vue │ │ └── LinkIcon.vue ├── translate │ ├── const │ │ ├── translate-request.ts │ │ ├── translate-save-data.ts │ │ ├── null_config_error.ts │ │ ├── translate-response.ts │ │ ├── translate-engines.ts │ │ └── support-lang.ts │ ├── engines │ │ ├── youdao │ │ │ ├── youdao-configs.ts │ │ │ └── youdao-translator.ts │ │ └── baidubce │ │ │ ├── baidubce-configs.ts │ │ │ └── baidubce-translator.ts │ └── modal │ │ ├── TranslationModal.ts │ │ └── TranslationModalComponent.vue ├── langs │ ├── index.ts │ ├── zh_tw.json │ ├── zh_cn.json │ └── en.json ├── vue-env.d.ts ├── util │ ├── utils.ts │ ├── jsonp.ts │ └── i18n.ts ├── main.ts └── setting.ts ├── .editorconfig ├── manifest.json ├── .gitignore ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── README.md ├── LICENSE ├── package.json ├── styles.css └── esbuild.config.mjs /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.2": "1.1.1", 3 | "1.0.4": "1.1.1" 4 | } 5 | 6 | -------------------------------------------------------------------------------- /doc/setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grover572/obsidian-Dictionary-translator/HEAD/doc/setting.png -------------------------------------------------------------------------------- /doc/translator.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grover572/obsidian-Dictionary-translator/HEAD/doc/translator.gif -------------------------------------------------------------------------------- /src/assets/engine-logo/youdao.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grover572/obsidian-Dictionary-translator/HEAD/src/assets/engine-logo/youdao.png -------------------------------------------------------------------------------- /src/translate/const/translate-request.ts: -------------------------------------------------------------------------------- 1 | import {from, to} from "./translate-engines"; 2 | 3 | export interface TranslateRequest { 4 | from?: from; 5 | to: to; 6 | words: string 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /src/langs/index.ts: -------------------------------------------------------------------------------- 1 | import en from "./en.json"; 2 | import zh_cn from "./zh_cn.json"; 3 | import zh_tw from "./zh_tw.json"; 4 | 5 | export const LANGS = { 6 | en: en, 7 | zh_cn: zh_cn, 8 | zh_tw: zh_tw, 9 | }; 10 | -------------------------------------------------------------------------------- /src/translate/const/translate-save-data.ts: -------------------------------------------------------------------------------- 1 | export interface TranslatorSaveData { 2 | calloutType?: calloutType 3 | title: string 4 | content: string[] 5 | radio: ArrayBuffer | null, 6 | radioLabel: string | null 7 | } 8 | 9 | type calloutType = "translator-card-callout" 10 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dictionary-translator", 3 | "name": "Dictionary translator", 4 | "version": "1.0.4", 5 | "minAppVersion": "1.1.1", 6 | "description": "它可以帮助你翻译单词或句子,听新单词或句子的录音,甚至录下自己的发音,以内部链接的形式保存到你的笔记中。", 7 | "author": "Grover", 8 | "authorUrl": "https://github.com/grover572", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /src/translate/const/null_config_error.ts: -------------------------------------------------------------------------------- 1 | export class NullConfigError extends Error { 2 | constructor(...keys: string[]) { 3 | super(keys.join(",") + " is null"); 4 | this.name = "NullConfigError"; 5 | if (Error.captureStackTrace) { 6 | // 修正堆栈跟踪,以便它指向这个自定义的错误类 7 | Error.captureStackTrace(this, NullConfigError); 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/translate/engines/youdao/youdao-configs.ts: -------------------------------------------------------------------------------- 1 | import {EngineConfig} from "../../const/translate-engines"; 2 | 3 | export class YoudaoConfigs implements EngineConfig { 4 | 5 | appKey: string; 6 | appSecret: string; 7 | 8 | constructor(appKey: string, appSecret: string) { 9 | this.appKey = appKey; 10 | this.appSecret = appSecret; 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/translate/engines/baidubce/baidubce-configs.ts: -------------------------------------------------------------------------------- 1 | import {EngineConfig} from "../../const/translate-engines"; 2 | 3 | export class BaidubceConfigs implements EngineConfig { 4 | 5 | apiKey: string; 6 | secretKey: string; 7 | 8 | constructor(apiKey: string, secretKey: string) { 9 | this.apiKey = apiKey; 10 | this.secretKey = secretKey; 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/vue-env.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Declare the `*.vue` modules. 3 | * 4 | * This will allow importing the `*.vue` modules in the `*.ts` files. 5 | * 6 | * 声明 `*.vue` 模块. 7 | * 8 | * 这将允许在 `*.ts` 文件中导入 `*.vue` 模块. 9 | */ 10 | declare module "*.vue" { 11 | import type { DefineComponent } from "vue"; 12 | const component: DefineComponent<{}, {}, any>; 13 | export default component; 14 | } 15 | -------------------------------------------------------------------------------- /src/util/utils.ts: -------------------------------------------------------------------------------- 1 | export function findEmptyKeys(obj: T): string[] { 2 | const emptyKeys: string[] = []; 3 | for (const key in obj) { 4 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 5 | const value = obj[key as keyof T]; 6 | if (value === null || value === undefined || (typeof value === "string" && value === "")) { 7 | emptyKeys.push(key); 8 | } 9 | } 10 | } 11 | return emptyKeys; 12 | } 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | 24 | dist 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "resolveJsonModule": true, 15 | "lib": [ 16 | "DOM", 17 | "ES5", 18 | "ES6", 19 | "ES7" 20 | ], 21 | "allowSyntheticDefaultImports": true 22 | }, 23 | "include": [ 24 | "**/*.ts", 25 | "**/*.vue" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src/translate/const/translate-response.ts: -------------------------------------------------------------------------------- 1 | import {from, to} from "./translate-engines"; 2 | 3 | export interface TranslateResponse { 4 | from: from 5 | to: to 6 | source: string 7 | translation: [string] 8 | speeches?: [{ //读音 9 | phonetic: string, // 音标 10 | speech: string, // 发音 11 | area: string // 地区 12 | }] 13 | explains?: [any] // 解释 说明 14 | extensions?: [{ // 形式扩充 15 | name: string, 16 | value: string 17 | }] 18 | isWord: boolean // 是否是单词 19 | link: [string], 20 | boomExplains: [{ 21 | type: string, 22 | explains: [string] 23 | }] 24 | 25 | } 26 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { 5 | "node": true 6 | }, 7 | "plugins": [ 8 | "@typescript-eslint", 9 | "vue" 10 | ], 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/eslint-recommended", 14 | "plugin:@typescript-eslint/recommended", 15 | "plugin:vue/recommended" 16 | ], 17 | "parserOptions": { 18 | "sourceType": "module" 19 | }, 20 | "rules": { 21 | "no-unused-vars": "off", 22 | "@typescript-eslint/no-unused-vars": [ 23 | "error", 24 | { 25 | "args": "none" 26 | } 27 | ], 28 | "@typescript-eslint/ban-ts-comment": "off", 29 | "no-prototype-builtins": "off", 30 | "@typescript-eslint/no-empty-function": "off" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/util/jsonp.ts: -------------------------------------------------------------------------------- 1 | import originJSONP from 'jsonp' 2 | 3 | export default function jsonp (url:string, data:Record, option?:Record):Promise { 4 | // 拼接url时判断是否已有问号 5 | url += (url.indexOf('?') > -1) ? '&' : '?' + param(data) 6 | return new Promise((resolve, reject) => { 7 | originJSONP(url, option, (err, data) => { 8 | if (!err) { 9 | resolve(data) 10 | } else { 11 | reject(err) 12 | } 13 | }) 14 | }) 15 | } 16 | 17 | // 将data(参数对象)封装到url里面 18 | function param (data:Record) { 19 | let url = '' 20 | for (const i in data) { 21 | const value = data[i] !== undefined ? data[i] : '' 22 | // url拼接参数,参数之间用&隔开 23 | url += `&${i}=${encodeURIComponent(value)}` 24 | } 25 | // 如果url有data,将第一个"&"删掉 26 | return url ? url.substring(1) : '' 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian-Dictionary-translator 2 | 3 | [English Doc](doc/README(EN).md) 4 | 5 | ## 我能帮你做什么? 6 | 7 | 这是一个简单的插件,帮助你在完成你的知识库的同时可以快速的翻译陌生的单词或句子,并将其生成单词卡插入笔记本中。 此外,你还可以将翻译引擎的发音文件保存到你的vault,如果你想,你也可以录制自己的发音。 8 | 9 | ## 如何使用 10 | 11 | 1. 配置翻译引擎 12 | ![setting.png](doc/setting.png) 13 | 2. 右键划词翻译 (你可以录制自己的发音) 14 | ![translator.gif](doc/translator.gif) 15 | 16 | 17 | ## 翻译引擎支持 18 | 19 | - [x] 有道翻译 20 | > **2024-04-23**
21 | > 由于[有道文本翻译接口返回内容更新](https://ai.youdao.com/gw-notice.s?page=1n0),依赖的`词典`相关数据缺失,导致插件体验不完整,只能展示基础的`释义`、`链接`、`基础发音`等。 22 | - [x] 百度智能云翻译 23 | - [ ] 谷歌翻译 24 | - [ ] 微软翻译 25 | 26 | #### 自定义翻译引擎 27 | 28 | 1. 自定义策略类: 实现`TranslationStrategy`抽象类 29 | 2. 在`TranslateEngines`中添加自定义的策略类 30 | 1. 请求自定义的翻译引擎 31 | 2. 解析响应结果,封装为`TranslateResponse`类型的响应 32 | 3. 为你的翻译引擎在`setting.ts`中添加独特的配置项 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/assets/icon/PlayIcon.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 16 | 17 | 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Grover 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/translate/const/translate-engines.ts: -------------------------------------------------------------------------------- 1 | import {YoudaoTranslator} from "../engines/youdao/youdao-translator"; 2 | import {TranslateResponse} from "./translate-response"; 3 | import {TranslateRequest} from "./translate-request"; 4 | import DictionaryPlugin from "../../main"; 5 | import {support_lang} from "./support-lang"; 6 | import {BaiduBceTranslator} from "../engines/baidubce/baidubce-translator"; 7 | 8 | export abstract class TranslationStrategy { 9 | config: EngineConfig; 10 | plugin: DictionaryPlugin; 11 | 12 | abstract translate(request: TranslateRequest): Promise; // async func 13 | } 14 | 15 | export class EngineConfig { 16 | [key: string]: any; 17 | } 18 | 19 | export interface TranslateEngine { 20 | strategy: new (config: EngineConfig, plugin: DictionaryPlugin) => TranslationStrategy; 21 | } 22 | 23 | export type from = keyof typeof support_lang | "auto" 24 | export type to = keyof typeof support_lang 25 | 26 | export type SupportEngine = "youdao" | "baidubce" 27 | 28 | export const TranslateEngines: Record = { 29 | youdao: { 30 | strategy: YoudaoTranslator, 31 | }, 32 | baidubce: { 33 | strategy: BaiduBceTranslator 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/translate/modal/TranslationModal.ts: -------------------------------------------------------------------------------- 1 | import {App, Editor, Modal} from "obsidian"; 2 | import {createApp, App as VueApp} from "vue"; 3 | import TranslationModalComponent from "./TranslationModalComponent.vue"; 4 | import {TranslateResponse} from "../const/translate-response"; 5 | import DictionaryPlugin from "../../main"; 6 | 7 | export class TranslationModal extends Modal { 8 | 9 | component: VueApp; 10 | data: TranslateResponse | undefined; 11 | plugin: DictionaryPlugin; 12 | editor: Editor; 13 | 14 | constructor(plugin: DictionaryPlugin, data: TranslateResponse | undefined, editor: Editor) { 15 | super(plugin.app); 16 | this.data = data; 17 | this.plugin = plugin; 18 | this.editor = editor; 19 | } 20 | 21 | 22 | onOpen() { 23 | const {modalEl} = this; 24 | modalEl.empty(); 25 | 26 | const app = createApp(TranslationModalComponent, { 27 | response: this.data, 28 | plugin: this.plugin, 29 | editor:this.editor, 30 | closeCallback: () => this.close() 31 | }); 32 | this.component = app; 33 | app.mount(modalEl); 34 | } 35 | 36 | onClose() { 37 | console.debug("close") 38 | const {modalEl} = this; 39 | this.component.unmount(); 40 | modalEl.empty(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-Dictionary-translator", 3 | "version": "1.0.4", 4 | "description": "我可以帮助你翻译单词或句子,听新单词或句子的录音,甚至录下自己的发音,以内部链接的形式保存到你的笔记中。", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/crypto-js": "^4.2.2", 16 | "@types/jsonp": "^0.2.3", 17 | "@types/mustache": "^4.2.5", 18 | "@types/node": "^16.11.6", 19 | "@typescript-eslint/eslint-plugin": "5.29.0", 20 | "@typescript-eslint/parser": "5.29.0", 21 | "builtin-modules": "3.3.0", 22 | "esbuild": "0.17.3", 23 | "eslint": "^8.57.0", 24 | "eslint-plugin-vue": "^9.22.0", 25 | "naive-ui": "^2.38.1", 26 | "obsidian": "1.1.1", 27 | "tslib": "2.4.0", 28 | "typescript": "4.7.4", 29 | "vue": "^3.4.21" 30 | }, 31 | "dependencies": { 32 | "adm-zip": "^0.5.12", 33 | "axios": "^1.6.7", 34 | "crypto-js": "^4.2.0", 35 | "esbuild-plugin-copy": "^2.1.1", 36 | "esbuild-plugin-vue3": "^0.4.2", 37 | "js-audio-recorder": "^1.0.7", 38 | "jsonp": "^0.2.1", 39 | "lucide": "^0.298.0", 40 | "lucide-vue-next": "^0.359.0", 41 | "mustache": "^4.2.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/util/i18n.ts: -------------------------------------------------------------------------------- 1 | import Mustache from "mustache"; 2 | import { moment } from "obsidian"; 3 | 4 | import { LANGS } from "../langs"; 5 | 6 | export type LangType = keyof typeof LANGS; 7 | export type LangTypeAndAuto = LangType | "auto"; 8 | export type I18nKey = keyof (typeof LANGS)["en"] 9 | 10 | export class I18n { 11 | lang: LangTypeAndAuto; 12 | readonly saveSettingFunc: (tolang: LangTypeAndAuto) => Promise; 13 | constructor( 14 | lang: LangTypeAndAuto, 15 | saveSettingFunc: (tolang: LangTypeAndAuto) => Promise 16 | ) { 17 | this.lang = lang; 18 | this.saveSettingFunc = saveSettingFunc; 19 | } 20 | 21 | _get(key: I18nKey) { 22 | let realLang = this.lang; 23 | if (this.lang === "auto" && moment.locale().replace("-", "_") in LANGS) { 24 | realLang = moment.locale().replace("-", "_") as LangType; 25 | } else { 26 | realLang = "en"; 27 | } 28 | 29 | // as (typeof LANGS)["en"]是一种类型断言,它告诉TypeScript我们希望将某个值视为与LANGS["en"]相同类型的值 30 | const res: string = 31 | (LANGS[realLang] as (typeof LANGS)["en"])[key] || LANGS["en"][key] || key; 32 | return res; 33 | } 34 | 35 | t(key: I18nKey, vars?: Record) { 36 | if (vars === undefined) { 37 | return this._get(key); 38 | } 39 | return Mustache.render(this._get(key), vars); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/langs/zh_tw.json: -------------------------------------------------------------------------------- 1 | { 2 | "confirm": "確認", 3 | "disable": "關閉", 4 | "enable": "啟用", 5 | "goback": "返回", 6 | "submit": "遞交", 7 | "sometext": "此處有一段文字。", 8 | "engines_chooser_div_title": "請選擇翻譯引擎", 9 | "translate_engine": "翻譯引擎", 10 | "youdao": "有道翻譯", 11 | "youdao_app_key": "應用金鑰", 12 | "youdao_app_secret": "應用密鑰", 13 | "google": "谷歌翻譯", 14 | "youdao_console": "進入有道智雲管理控制台", 15 | "guide": " · {{engine}}引擎設定 ", 16 | "youdao_guide": "開通文本翻譯與語音合成服務,接著取得應用金鑰與應用密鑰。", 17 | "connect_test": "連線測試", 18 | "connect_test_desc": "檢查與翻譯引擎的連線狀態,插件會將 'hello' 翻譯為简体中文 '你好',請核對結果的正確性。", 19 | "test": "測試", 20 | "init_engine_exception": "設定翻譯引擎時遇到錯誤: {{error}}", 21 | "engine_translate_exception": "{{engine}}引擎 發生錯誤. 錯誤代碼:{{code}}", 22 | "connect_test_success": "連線成功", 23 | "first_target_lang": "預設目標語言", 24 | "base_setting": " · 基本設定", 25 | "first_target_lang_desc": "翻譯時之預設目標語言", 26 | "tran2target": "翻譯所選內容", 27 | "into_note": "儲存至筆記", 28 | "web_site": "網站連結", 29 | "pick_voice": "挑選所保存的語音", 30 | "attach": "附加檔案目錄", 31 | "attach_desc": "您希望將音頻檔案儲存於何處?", 32 | "attach_placeholder": "例如:Translator", 33 | "show_link": "顯示連結", 34 | "show_link_desc": "是否於單詞卡片中顯示連結?", 35 | "show_radio": "語音播放", 36 | "show_radio_desc": "是否於單詞卡片中顯示語音播放?", 37 | "play_the_radio": "播放語音", 38 | "select_explains": "挑選保存的解釋", 39 | "save_to_note": "儲存至筆記", 40 | "play_error": "播放發生錯誤,請重試", 41 | "file_exist": "無法建立資料夾,因為該路徑已有檔案存在: {{folderpath}}", 42 | "save_radio_fail": "儲存語音檔案失敗,請檢查網路連線", 43 | "stop_record": "停止錄音", 44 | "start_record": "開始錄音" 45 | } 46 | -------------------------------------------------------------------------------- /doc/README(EN).md: -------------------------------------------------------------------------------- 1 | # Obsidian-Dictionary-translator 2 | [中文文档](doc%2Freadme%28cn%29.md) 3 | ## What Can I Do for You? 4 | 5 | This is a simple plugin designed to help you expand your knowledge base while also quickly translating unfamiliar words or sentences and generating flashcards that can be plugged into your notebook. In addition, you can also save the translation engine's pronunciation files to your vault, and you can also record your own pronunciation if you want. 6 | ## How to Use 7 | 8 | 1. Configure the Translation Engine 9 | ![setting.png](doc%2Fsetting.png) 10 | 2. Right-click on words for translation (You can record your own pronunciation) 11 | ![translator.gif](doc%2Ftranslator.gif) 12 | 13 | ## Supported Translation Engines 14 | 15 | - [x] Youdao Translation 16 | - [ ] Google Translate 17 | - [ ] Microsoft Translator 18 | 19 | #### Custom Translation Engine 20 | 21 | 1. Create a Custom Strategy Class: Implement the abstract class `TranslationStrategy`. 22 | 2. Add your custom strategy class to `TranslateEngines`: 23 | 1. Make a request to your custom translation engine. 24 | 2. Parse the response and encapsulate it as a `TranslateResponse` type response. 25 | 3. Add a unique configuration item for your translation engine in `setting.ts`. 26 | 27 | This guide provides a clear and concise explanation of how to utilize the plugin for translating and learning purposes. By following these steps, you can easily integrate a translation engine into your workflow, enhancing your ability to understand and retain new information. 28 | -------------------------------------------------------------------------------- /src/langs/zh_cn.json: -------------------------------------------------------------------------------- 1 | { 2 | "confirm": "确认", 3 | "disable": "关闭", 4 | "enable": "开启", 5 | "goback": "返回", 6 | "submit": "提交", 7 | "sometext": "这里有一段文字。", 8 | "engines_chooser_div_title": "请选择翻译引擎", 9 | "translate_engine": "翻译引擎", 10 | "youdao": "有道翻译", 11 | "baidubce": "百度智能云-词典翻译", 12 | "youdao_app_key": "应用ID", 13 | "youdao_app_secret": "应用密钥", 14 | "google": "谷歌翻译", 15 | "youdao_console": "进入有道智云控制台", 16 | "baidubce_console": "进入百度智能云控制台", 17 | "guide": " · {{engine}}引擎配置 ", 18 | "youdao_guide": "开通文本翻译与语音合成服务,然后获取应用ID与应用密钥。", 19 | "baidubce_guide": "开通词典翻译服务,创建应用,获取应用ID与应用密钥。", 20 | "connect_test": "连通测试", 21 | "connect_test_desc": "检查与翻译引擎的连通性,插件会将 'hello' 翻译为简体中文 '你好',请检查结果的正确性。", 22 | "test": "测试", 23 | "init_engine_exception": "配置翻译引擎时出现错误: {{error}}", 24 | "engine_translate_exception": "{{engine}}引擎 出现一个错误. 错误代码:{{code}}", 25 | "connect_test_success": "连通成功", 26 | "first_target_lang": "默认目标语言", 27 | "base_setting": " · 基础设置", 28 | "first_target_lang_desc": "翻译时默认的目标语言", 29 | "tran2target": "翻译选中内容", 30 | "into_note": "添加到笔记", 31 | "web_site": "链接", 32 | "pick_voice": "选择保存的发音", 33 | "attach": "附件目录", 34 | "attach_desc": "要把你的音频文件存在哪里?", 35 | "attach_placeholder": "e.g.:Translator", 36 | "show_link": "链接", 37 | "show_link_desc": "是否要在单词卡片中展示链接?", 38 | "show_radio": "音频", 39 | "show_radio_desc": "是否要在单词卡片中展示音频?", 40 | "play_the_radio": "播放音频", 41 | "select_explains": "选择保存的释义", 42 | "save_to_note": "保存到笔记", 43 | "play_error": "播放失败,请重试", 44 | "file_exist": "无法创建文件夹,因为路径上存在一个文件: {{folderpath}}", 45 | "save_radio_fail": "保存录音文件失败,请检查网络", 46 | "stop_record": "停止录音", 47 | "start_record": "开始录音" 48 | } 49 | -------------------------------------------------------------------------------- /src/translate/const/support-lang.ts: -------------------------------------------------------------------------------- 1 | import {LangTypeAndAuto} from "../../util/i18n"; 2 | import {moment} from "obsidian"; 3 | import {SupportEngine, to} from "./translate-engines"; 4 | 5 | export interface SupportLang { 6 | [key: string]: { 7 | en: string 8 | cn: string 9 | engine: Record // 引擎自身的的语言编码 10 | } 11 | } 12 | 13 | export const support_lang: SupportLang = { 14 | "en": {"en": "English", "cn": "英语", engine: {youdao: "en", baidubce: "en"}}, 15 | "fr": {"en": "French", "cn": "法语", engine: {youdao: "fr", baidubce: "fra"}}, 16 | "ja": {"en": "Japanese", "cn": "日语", engine: {youdao: "ja", baidubce: "jp"}}, 17 | "ko": {"en": "Korean", "cn": "韩语", engine: {youdao: "ko", baidubce: "kor"}}, 18 | "zh-CHS": {"en": "LChinese", "cn": "简体中文", engine: {youdao: "zh-CHS", baidubce: "zh"}}, 19 | "zh-CHT": {"en": "Chinese", "cn": "繁体中文", engine: {youdao: "zh-CHT", baidubce: "cht"}}, 20 | } 21 | 22 | export function getLanguageOptions(lang: LangTypeAndAuto): Record { 23 | 24 | let l: "cn" | "en" = "cn"; 25 | if (lang === "en" || (lang === "auto" && moment.locale().replace("-", "_") === "en")) { 26 | l = "en"; 27 | } 28 | 29 | const languageMap = Object.entries(support_lang).reduce((acc: Record, [key, value]) => { 30 | acc[key] = value[l]; 31 | return acc; 32 | }, {}); 33 | return languageMap; 34 | } 35 | 36 | export function getLangName(target: to, lang: LangTypeAndAuto): string { 37 | let l: "cn" | "en" = "cn"; 38 | if (lang === "en" || (lang === "auto" && moment.locale().replace("-", "_") === "en")) { 39 | l = "en"; 40 | } 41 | return support_lang[target][l]; 42 | } 43 | -------------------------------------------------------------------------------- /src/assets/icon/NoteIcon.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 29 | 30 | 33 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | 10 | 11 | .settings-hide { 12 | display: none; 13 | } 14 | 15 | .setting-engine-header { 16 | display: flex; 17 | justify-content: space-between; 18 | height: 30px; 19 | } 20 | 21 | .setting-engine-header img { 22 | transform: scale(0.8); 23 | } 24 | 25 | .setting-engine-header .guide { 26 | font-weight: bolder; 27 | font-size: 15px; 28 | line-height: 30px; 29 | } 30 | 31 | .setting-title { 32 | font-weight: bolder; 33 | font-size: 15px; 34 | line-height: 30px; 35 | height: 40px; 36 | border-top: 1px gainsboro solid; 37 | padding-top: 10px; 38 | } 39 | 40 | .setting-title + .setting-item { 41 | border-top: none; 42 | } 43 | 44 | .setting-config-box { 45 | border-top: 1px gainsboro solid; 46 | padding-top: 10px; 47 | } 48 | 49 | .translate-card-header { 50 | font-size: 20px; 51 | } 52 | 53 | .translate-card-header button { 54 | border: none; 55 | } 56 | 57 | .translate-card-header button:hover { 58 | border: none; 59 | } 60 | 61 | .translate-card-header .arrows { 62 | font-weight: bolder; 63 | } 64 | 65 | .translate-card-header .source-link { 66 | text-decoration: none; 67 | } 68 | 69 | .translate-card-header .source-link:hover { 70 | color: #2080f0; 71 | text-decoration: none; 72 | } 73 | 74 | .translate-card-button { 75 | margin-top: 10px; 76 | } 77 | 78 | .translate-card-link { 79 | width: 100%; 80 | height: 300px; 81 | } 82 | 83 | .translate-card .record-button { 84 | margin-left: 5px; 85 | } 86 | 87 | .translate-card .explain-card { 88 | padding: 3px; 89 | } 90 | 91 | .translate-card .explain-card:hover { 92 | background-color: rgba(245, 245, 245, 0.69); 93 | } 94 | 95 | .translate-card button:not(.clickable-icon) { 96 | background-color: transparent; 97 | box-shadow: none; 98 | } 99 | 100 | .callout[data-callout="translator-card-callout"] { 101 | --callout-icon: book-open-text; 102 | } 103 | .callout[data-callout="translator-card-callout"] .callout-icon{ 104 | vertical-align: bottom; 105 | } 106 | -------------------------------------------------------------------------------- /src/langs/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "confirm": "Confirm", 3 | "disable": "Disable", 4 | "enable": "Enable", 5 | "goback": "Go back", 6 | "submit": "Submit", 7 | "engines_chooser_div_title": "Please choose your translate engine", 8 | "translate_engine": "Translate engine", 9 | "youdao": "Youdao Translator", 10 | "baidubce": "Baidu Cloud Translator", 11 | "google": "Google Translator", 12 | "youdao_app_key": "AppKey", 13 | "api_key": "API Key", 14 | "secret_key": "Secret Key", 15 | "youdao_app_secret": "AppSecret", 16 | "youdao_console": "Click to open YoudaoYun console,", 17 | "baidubce_console": "Click to open Baidu Cloud console,", 18 | "guide": " - {{engine}} configure ", 19 | "youdao_guide": "Open text translation and speech synthesis services.Then get AppKey and AppSecret.", 20 | "baidubce_guide": "A dictionary translation service has been launched.", 21 | "connect_test": "Connect test", 22 | "connect_test_desc": "Check connectivity with translation services,the plugin will translate Chinese '你好' into English 'hello', please check whether the result is correct", 23 | "test": "Test", 24 | "init_engine_exception": "An exception occurred when the engine was configured: {{error}}", 25 | "engine_translate_exception": "{{engine}} Engine has a exception. ErrorCode:{{code}}", 26 | "connect_test_success": "Connect success", 27 | "first_target_lang": "Default target language", 28 | "base_setting": " - Base setting", 29 | "first_target_lang_desc": "Default translation target language", 30 | "tran2target": " Translation the selected content", 31 | "into_note": "Append to note", 32 | "web_site": "Links", 33 | "pick_voice": "Pick you love voice", 34 | "attach": "Attach folder", 35 | "attach_desc": "Where do you want to store your recordings", 36 | "attach_placeholder": "e.g.:Translator", 37 | "show_link": "Show web site", 38 | "show_link_desc": "Whether to display web pages", 39 | "show_radio": "Show radio", 40 | "show_radio_desc": "Whether to display radio?", 41 | "play_the_radio": "play the radio", 42 | "select_explains": "Select explains", 43 | "save_to_note": "Save to note", 44 | "play_error": "Play failure,please retry", 45 | "file_exist": "The folder cannot be created because it already exists as a file: {{folderpath}}.", 46 | "save_radio_fail": "Save radio failed,please check your network", 47 | "stop_record": "Stop record", 48 | "start_record": "Start record" 49 | } 50 | -------------------------------------------------------------------------------- /src/assets/icon/LinkIcon.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 20 | 21 | 24 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import copy from "esbuild-plugin-copy"; 5 | import esbuildPluginVue3 from "esbuild-plugin-vue3"; 6 | import * as fs from "fs"; 7 | import AdmZip from "adm-zip"; 8 | import path from "path"; 9 | import {readFileSync} from "fs"; 10 | 11 | 12 | const banner = 13 | `/* 14 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 15 | if you want to view the source, please visit the github repository of this plugin 16 | https://github.com/grover572/obsidian-Dictionary-translator 17 | */ 18 | `; 19 | 20 | const prod = (process.argv[2] === "production"); 21 | 22 | const context = await esbuild.context({ 23 | banner: { 24 | js: banner, 25 | }, 26 | plugins: [copy({ 27 | assets: { 28 | from: ["src/assets/*"], 29 | to: ["assets/"] 30 | } 31 | }), esbuildPluginVue3()], 32 | entryPoints: ["src/main.ts"], 33 | bundle: true, 34 | external: [ 35 | "obsidian", 36 | "electron", 37 | "@codemirror/autocomplete", 38 | "@codemirror/collab", 39 | "@codemirror/commands", 40 | "@codemirror/language", 41 | "@codemirror/lint", 42 | "@codemirror/search", 43 | "@codemirror/state", 44 | "@codemirror/view", 45 | "@lezer/common", 46 | "@lezer/highlight", 47 | "@lezer/lr", 48 | ...builtins], 49 | format: "cjs", 50 | target: "es2018", 51 | logLevel: "info", 52 | sourcemap: prod ? false : "inline", 53 | treeShaking: true, 54 | outfile: "main.js", 55 | allowOverwrite: true 56 | }); 57 | 58 | 59 | if (prod) { 60 | await context.rebuild(); 61 | await packageDist(["main.js", "styles.css", "manifest.json"], "dist"); 62 | process.exit(0); 63 | } else { 64 | await context.watch(); 65 | } 66 | 67 | async function packageDist(files, dist) { 68 | fs.rmdirSync(dist,{recursive:true}) 69 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 70 | const {id} = manifest; 71 | const zipFileName = dist + "/" + id + ".zip"; 72 | // 创建目标目录 73 | fs.mkdirSync(path.dirname(zipFileName), {recursive: true}); 74 | const zip = new AdmZip(); 75 | files.forEach((file) => { 76 | const filePath = `${file}`; 77 | const stat = fs.statSync(filePath); 78 | if (stat.isFile()) { 79 | zip.addLocalFile(filePath); 80 | } 81 | }); 82 | zip.writeZip(zipFileName); 83 | console.log(`Files bundled and saved to ${zipFileName}`); 84 | 85 | files.forEach((file)=>{ 86 | fs.copyFileSync(file,`${dist}/${file}`) 87 | }) 88 | console.log(`Files copy to ${dist}`); 89 | fs.rmSync("main.css") 90 | } 91 | 92 | 93 | function copyFileToDirectory(sourceFilePath, targetDirectoryPath) { 94 | const fileName = path.basename(sourceFilePath); 95 | const targetFilePath = path.join(targetDirectoryPath, fileName); 96 | 97 | const readStream = fs.createReadStream(sourceFilePath); 98 | const writeStream = fs.createWriteStream(targetFilePath); 99 | 100 | // 执行拷贝 101 | readStream.pipe(writeStream); 102 | 103 | // 监听拷贝完成事件 104 | writeStream.on('finish', () => { 105 | console.log(`File "${fileName}" copied to "${targetDirectoryPath}"`); 106 | }); 107 | 108 | // 监听可能的错误事件 109 | writeStream.on('error', (err) => { 110 | console.error(`Error copying file: ${err}`); 111 | }); 112 | } 113 | -------------------------------------------------------------------------------- /src/translate/engines/baidubce/baidubce-translator.ts: -------------------------------------------------------------------------------- 1 | import {from, TranslationStrategy} from "../../const/translate-engines"; 2 | import DictionaryPlugin from "../../../main"; 3 | import {TranslateRequest} from "../../const/translate-request"; 4 | import {TranslateResponse} from "../../const/translate-response"; 5 | import {BaidubceConfigs} from "./baidubce-configs"; 6 | import {requestUrl, RequestUrlResponse} from "obsidian"; 7 | 8 | const BAIDUBCE_TRANSLATE_API = "https://aip.baidubce.com/rpc/2.0/mt/texttrans-with-dict/v1"; 9 | 10 | export class BaiduBceTranslator implements TranslationStrategy { 11 | config: BaidubceConfigs; 12 | plugin: DictionaryPlugin; 13 | 14 | accessToken: string; 15 | 16 | constructor(config: BaidubceConfigs, plugin: DictionaryPlugin) { 17 | if (config.apiKey && config.secretKey) { 18 | this.config = config; 19 | this.plugin = plugin; 20 | } else { 21 | throw new Error("配置项缺失"); 22 | } 23 | } 24 | 25 | async translate(request: TranslateRequest): Promise { 26 | try { 27 | await this.getAccessToken(); 28 | console.log(request) 29 | return this.parseResponse(await requestUrl({ 30 | url: BAIDUBCE_TRANSLATE_API + "?access_token=" + this.accessToken, 31 | method: "POST", 32 | contentType: "application/json;charset=utf-8", 33 | body: JSON.stringify({ 34 | from: "auto", 35 | // TODO 插件语种 => 引擎语种的映射 36 | to: request.to === "cn" ? "zh" : request.to, 37 | q: request.words 38 | }) 39 | })); 40 | } catch (error) { 41 | throw error; 42 | } 43 | } 44 | 45 | async getAccessToken() { 46 | const clientId = this.config.apiKey; 47 | const clientSecret = this.config.secretKey; 48 | const url = `https://aip.baidubce.com/oauth/2.0/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`; 49 | try { 50 | // requestUrl 忽略跨域检测 51 | const response = await requestUrl(url); 52 | if (response.status != 200 || !response?.json?.access_token) { 53 | throw new Error("请求百度智能云时失败,HTTP StatusCode:" + response.status); 54 | } 55 | this.accessToken = response.json.access_token; 56 | } catch (e) { 57 | console.error("Error details:", e); 58 | throw new Error("请求百度智能云时异常,请检查apiKey与secretKey; 详细信息:" + e.message); 59 | } 60 | 61 | } 62 | 63 | 64 | private parseResponse(requestUrlResponse: RequestUrlResponse): TranslateResponse { 65 | const transResult = requestUrlResponse?.json?.result?.trans_result?.[0]; 66 | if (!transResult) { 67 | throw new Error("翻译引擎结果为空"); 68 | } 69 | console.log(JSON.stringify(transResult)) 70 | 71 | const dict = transResult.dict && JSON.parse(transResult.dict); 72 | const src_tts = transResult.src_tts; 73 | const isWord = !(!dict); 74 | // console.log(src_tts) 75 | const simpleMeans = dict?.word_result?.simple_means; 76 | // console.log(JSON.stringify(dict)) 77 | // console.log(simpleMeans) 78 | 79 | return { 80 | boomExplains: isWord ? simpleMeans.symbols[0].parts.map(part => { 81 | return { 82 | type: part.part || '', 83 | explains: part.means 84 | }; 85 | }) : [{ 86 | type: "", 87 | explains: transResult.dst 88 | }], 89 | explains: isWord ? simpleMeans.symbols[0].parts.map(part => { 90 | return `${part.part} ${part.means.join(';')}`; 91 | }) : transResult.dst, 92 | extensions: null, 93 | isWord: isWord, 94 | link: null, 95 | source: transResult.src, 96 | speeches: [{speech: src_tts}], 97 | translation: [transResult.dst] 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import {Editor, normalizePath, Notice, Plugin, TFile, TFolder, MarkdownView} from 'obsidian'; 2 | import {DEFAULT_SETTINGS, DictionarySettings, DictionarySettingTab} from "./setting"; 3 | import {I18n, I18nKey, LangTypeAndAuto} from "./util/i18n"; 4 | import {TranslateEngines, TranslationStrategy} from "./translate/const/translate-engines"; 5 | import {TranslationModal} from "./translate/modal/TranslationModal"; 6 | import "styles.css" 7 | import {TranslatorSaveData} from "./translate/const/translate-save-data"; 8 | import * as CryptoJS from 'crypto-js'; 9 | import {support_lang} from "./translate/const/support-lang"; 10 | 11 | export default class DictionaryPlugin extends Plugin { 12 | settings: DictionarySettings; 13 | i18n!: I18n; 14 | private engine: TranslationStrategy; 15 | 16 | async onload() { 17 | 18 | await this.loadSettings(); 19 | 20 | this.i18n = new I18n(this.settings.lang!, async (lang: LangTypeAndAuto) => { 21 | this.settings.lang = lang; 22 | await this.saveSettings(); 23 | }); 24 | 25 | this.addSettingTab(new DictionarySettingTab(this.app, this)); 26 | 27 | const t = (x: I18nKey, vars?: any) => { 28 | return this.i18n.t(x, vars); 29 | }; 30 | 31 | this.registerEvent( 32 | this.app.workspace.on("editor-menu", (menu, editor, view) => { 33 | const selection = editor.getSelection(); 34 | const onlyPunctuation = /^[\u2000-\u206F\u2E00-\u2E7F\\'!"#\$%&\(\)\*\+,\-\./:;<=>\?@\[\]\^_`\{\|\}~]+$/.test(selection.replace(" ", "")); 35 | if (selection.trim().length > 0 && !onlyPunctuation) { 36 | menu.addItem((item) => { 37 | item 38 | .setTitle(t("tran2target")) 39 | .setIcon("languages") 40 | .onClick(async () => { 41 | const translateResponse = await this.getTranslator()?.translate({ 42 | to: support_lang[this.settings.targetLang].engine[this.settings.engine], 43 | words: selection 44 | }); 45 | new TranslationModal(this, translateResponse, editor).open(); 46 | }); 47 | }); 48 | } 49 | 50 | }) 51 | ); 52 | } 53 | 54 | 55 | private hash(selection: string): string { 56 | const timestamp = new Date().getTime(); 57 | const combinedString = timestamp.toString() + selection; 58 | let hash = 0; 59 | let i; 60 | 61 | for (i = 0; i < combinedString.length; i++) { 62 | hash = ((hash << 5) - hash) + combinedString.charCodeAt(i); 63 | hash |= 0; 64 | } 65 | 66 | const hashStr = Math.abs(hash).toString(); 67 | return hashStr.length > 10 ? hashStr.slice(-10) : hashStr.padStart(10, '0'); 68 | } 69 | 70 | async saveNote(editor: Editor, saveData: TranslatorSaveData) { 71 | const selection = editor.getSelection(); 72 | const hash = this.hash(selection); 73 | await this.reloadAttachFolder(); 74 | 75 | let radioPath; 76 | // save radio 77 | if (saveData.radio) { 78 | radioPath = this.settings.attach + "/" + saveData.title.substring(0, 10) + "-" + hash + ".mp3"; 79 | this.app.vault.createBinary(radioPath, saveData.radio); 80 | } 81 | 82 | if (editor.inTableCell) { 83 | const currentFile = app.workspace.getActiveFile(); 84 | if (currentFile) { 85 | const markdownView = app.workspace.getActiveViewOfType(MarkdownView); 86 | if (markdownView && markdownView.file.path === currentFile.path) { 87 | editor = markdownView.editor; 88 | } 89 | } 90 | } 91 | 92 | const appendPosition = editor.lastLine() + 1; 93 | const title = `\n\n>[!translator-card-callout]+ ${saveData.title}\n` 94 | const content = saveData.content.map(c => `>${c.trim()}`).join("\n"); 95 | const radio = `\n![[${radioPath}]]` 96 | const anchor = `\n^${hash}` 97 | editor.setLine(appendPosition, title + content + (saveData.radio ? radio : "") + anchor) 98 | 99 | editor.replaceSelection(`[[#^${hash}|${selection}]]`); 100 | 101 | } 102 | 103 | onunload() { 104 | new Notice("Bye ~ 🙋🏻 ") 105 | } 106 | 107 | async loadSettings() { 108 | this.settings = Object.assign({}, DEFAULT_SETTINGS, JSON.parse(this.decrypt(await this.loadData()))); 109 | } 110 | 111 | private key: string = "saturn1&&saturn2" 112 | 113 | private encrypt(param: string) { 114 | const encrypted = CryptoJS.AES.encrypt(param, this.key, { 115 | mode: CryptoJS.mode.ECB, 116 | padding: CryptoJS.pad.Pkcs7, 117 | }); 118 | return encrypted.toString(); 119 | } 120 | 121 | private decrypt(param: any) { 122 | if (!param) return "{}" 123 | const decrypted = CryptoJS.AES.decrypt(param, this.key, { 124 | mode: CryptoJS.mode.ECB, 125 | padding: CryptoJS.pad.Pkcs7, 126 | }); 127 | return decrypted.toString(CryptoJS.enc.Utf8); 128 | } 129 | 130 | async saveSettings() { 131 | console.log(JSON.stringify(this.settings)) 132 | await this.saveData(this.encrypt(JSON.stringify(this.settings))); 133 | } 134 | 135 | getTranslator(): TranslationStrategy | undefined { 136 | try { 137 | this.engine = new TranslateEngines[this.settings.engine].strategy(this.settings.engineConfig, this); 138 | return this.engine; 139 | } catch (e) { 140 | new Notice(this.i18n.t("init_engine_exception", {error: e.message})) 141 | } 142 | } 143 | 144 | async reloadAttachFolder() { 145 | const folderpath = normalizePath(this.settings.attach); 146 | const vault = this.app.vault; 147 | const folder = vault.getAbstractFileByPath(folderpath); 148 | if (folder && folder instanceof TFolder) { 149 | return; 150 | } 151 | if (folder && folder instanceof TFile) { 152 | new Notice(this.i18n.t("file_exist", {folderpath})) 153 | } 154 | return await vault.createFolder(folderpath); 155 | } 156 | 157 | 158 | } 159 | 160 | -------------------------------------------------------------------------------- /src/translate/engines/youdao/youdao-translator.ts: -------------------------------------------------------------------------------- 1 | import {TranslationStrategy} from "../../const/translate-engines"; 2 | import CryptoJS from 'crypto-js'; 3 | import {YoudaoConfigs} from "./youdao-configs"; 4 | import {TranslateResponse} from "../../const/translate-response"; 5 | import {TranslateRequest} from "../../const/translate-request"; 6 | import {NullConfigError} from "../../const/null_config_error"; 7 | import {findEmptyKeys} from "../../../util/utils"; 8 | import jsonp from "../../../util/jsonp"; 9 | import {Notice} from "obsidian"; 10 | import DictionaryPlugin from "../../../main"; 11 | import {from} from "../../const/translate-engines"; 12 | 13 | const YOUDAO_API = "https://openapi.youdao.com/api" 14 | 15 | export class YoudaoTranslator implements TranslationStrategy { 16 | plugin: DictionaryPlugin; 17 | config: YoudaoConfigs; 18 | 19 | constructor(config: YoudaoConfigs, plugin: DictionaryPlugin) { 20 | if (config.appKey && config.appSecret) { 21 | this.config = config; 22 | this.plugin = plugin; 23 | } else { 24 | throw new Error("配置项缺失"); 25 | } 26 | } 27 | 28 | async translate(request: TranslateRequest): Promise { 29 | try { 30 | return this.parseResponse(await jsonp(YOUDAO_API, { 31 | appKey: this.config.appKey, 32 | q: request.words, 33 | signType: "v3", 34 | from: request.from, 35 | to: request.to, 36 | ...this.generateSign(request.words) 37 | })); 38 | } catch (error) { 39 | throw error; 40 | } 41 | } 42 | 43 | private generateSign(token: string): Record { 44 | const salt = (new Date()).getTime(); 45 | const curTime = Math.round(new Date().getTime() / 1000); 46 | const len = token.length; 47 | if (len > 20) token = token.substring(0, 10) + len + token.substring(len - 10, len); 48 | return { 49 | salt, 50 | curtime: curTime, 51 | sign: CryptoJS.SHA256(this.config.appKey + token + salt + curTime + this.config.appSecret).toString(CryptoJS.enc.Hex) 52 | } 53 | } 54 | 55 | private parseResponse(youdaoApiResponse: YoudaoApiResponse): TranslateResponse | any { 56 | if (youdaoApiResponse.errorCode != "0") { 57 | const i18n = this.plugin.i18n; 58 | const message = i18n.t("engine_translate_exception", { 59 | engine: i18n.t("youdao"), 60 | code: youdaoApiResponse.errorCode 61 | }); 62 | new Notice(message); 63 | throw new Error(message) 64 | } 65 | const lang = youdaoApiResponse.l.split("2"); 66 | const result: TranslateResponse | any = { 67 | isWord: youdaoApiResponse.isWord, 68 | from: lang[0], 69 | to: lang[1], 70 | source: youdaoApiResponse.query, 71 | translation: youdaoApiResponse.translation 72 | }; 73 | 74 | const boomExplains = youdaoApiResponse?.basic?.explains?.map(explain => this.splitString(explain)); 75 | 76 | return youdaoApiResponse.isWord ? { 77 | ...result, 78 | speeches: result.from == "en" ? [{ 79 | phonetic: youdaoApiResponse?.basic?.["uk-phonetic"], 80 | speech: youdaoApiResponse?.basic?.["uk-speech"], 81 | area: "uk" 82 | }, { 83 | phonetic: youdaoApiResponse?.basic?.["us-phonetic"], 84 | speech: youdaoApiResponse?.basic?.["us-speech"], 85 | area: "us" 86 | }] : [], 87 | explains: youdaoApiResponse.basic.explains, 88 | extensions: youdaoApiResponse.basic?.wfs?.map(item => ({name: item?.wf?.name, value: item?.wf?.value})), 89 | isWord: youdaoApiResponse.isWord, 90 | link: [youdaoApiResponse.webdict.url], 91 | boomExplains: boomExplains 92 | } : { 93 | ...result, 94 | speeches: [{speech: youdaoApiResponse?.speakUrl}], 95 | link: [youdaoApiResponse.mTerminalDict.url && youdaoApiResponse.webdict.url] 96 | }; 97 | } 98 | 99 | splitString(input: string): {} { 100 | const parts = input.split('.'); 101 | if (parts.length < 2) { 102 | parts.unshift(""); 103 | } 104 | const type = parts[0]; 105 | const explains = parts[1]?.split(';'); 106 | return {type, explains}; 107 | } 108 | 109 | } 110 | 111 | interface YoudaoApiResponse { 112 | returnPhrase: string[]; 113 | query: string; 114 | errorCode: string; 115 | l: string; 116 | tSpeakUrl: string; 117 | web: Array<{ 118 | value: string[]; 119 | key: string; 120 | }>; 121 | requestId: string; 122 | translation: string[]; 123 | mTerminalDict: { 124 | url: string; 125 | }; 126 | dict: { 127 | url: string; 128 | }; 129 | webdict: { 130 | url: string; 131 | }; 132 | basic: { 133 | exam_type: string[]; 134 | "us-phonetic": string; 135 | phonetic: string; 136 | "uk-phonetic": string; 137 | wfs: Array<{ 138 | wf: { 139 | name: string; 140 | value: string; 141 | }; 142 | }>; 143 | "uk-speech": string; 144 | explains: string[]; 145 | "us-speech": string; 146 | }; 147 | isWord: boolean; 148 | speakUrl: string; 149 | } 150 | -------------------------------------------------------------------------------- /src/setting.ts: -------------------------------------------------------------------------------- 1 | import {App, Modal, moment, Notice, PluginSettingTab, Setting, TextComponent} from "obsidian"; 2 | import DictionaryPlugin from "./main"; 3 | import {LangTypeAndAuto, I18nKey, I18n} from "./util/i18n"; 4 | import {EngineConfig, SupportEngine, to, TranslateEngines} from "./translate/const/translate-engines"; 5 | import {createElement, Eye, EyeOff} from "lucide"; 6 | import {YoudaoConfigs} from "./translate/engines/youdao/youdao-configs"; 7 | import {logo_image} from "./assets/engine-logo/logos"; 8 | import {getLanguageOptions, support_lang} from "./translate/const/support-lang"; 9 | import {BaidubceConfigs} from "./translate/engines/baidubce/baidubce-configs"; 10 | 11 | export interface DictionarySettings { 12 | show_radio: boolean; 13 | show_link: boolean; 14 | attach: string; 15 | engine: keyof typeof TranslateEngines; 16 | lang: LangTypeAndAuto, 17 | targetLang: to, 18 | engineConfig: EngineConfig 19 | } 20 | 21 | export const DEFAULT_SETTINGS: DictionarySettings = { 22 | show_radio: true, 23 | show_link: true, 24 | attach: "", 25 | engine: "youdao", 26 | lang: "auto", 27 | targetLang: moment.locale() in support_lang ? moment.locale() : "zh-CHS", 28 | engineConfig: new YoudaoConfigs("", "") 29 | } 30 | 31 | export class DictionarySettingTab extends PluginSettingTab { 32 | plugin: DictionaryPlugin; 33 | 34 | constructor(app: App, plugin: DictionaryPlugin) { 35 | super(app, plugin); 36 | this.plugin = plugin; 37 | } 38 | 39 | display(): void { 40 | 41 | const {containerEl} = this; 42 | 43 | containerEl.empty(); 44 | const i18n = (x: I18nKey, vars?: any) => { 45 | return this.plugin.i18n.t(x, vars); 46 | }; 47 | 48 | // title 49 | // containerEl.createEl("h1", {text: "Dictionary Settings"}); 50 | 51 | // Div : enginesChooserDiv 52 | const enginesChooserDiv = containerEl.createDiv(); 53 | 54 | const youdaoEngineDiv = this.renderYoudaoEngineSettings(containerEl, i18n); 55 | 56 | const baidubceEngineDiv = this.renderBaiduBceEngineSettings(containerEl, i18n); 57 | 58 | // common connect test 59 | new Setting(containerEl) 60 | .setName(i18n("connect_test")) 61 | .setDesc(i18n("connect_test_desc")) 62 | .addButton((bc) => { 63 | bc.setButtonText(i18n("test")); 64 | bc.onClick(async evt => { 65 | let en: boolean = false; 66 | if (this.plugin.settings.lang === "en" || 67 | (this.plugin.settings.lang === "auto" 68 | && moment.locale().replace("-", "_") === "en")) { 69 | en = true; 70 | } 71 | 72 | const words = en ? "你好" : "hello"; 73 | try { 74 | const translateResponse = await this.plugin.getTranslator()?.translate({ 75 | from: en ? "cn" : "en", 76 | to: en ? "en" : "cn", 77 | words: words 78 | }); 79 | console.debug(translateResponse) 80 | if (translateResponse) { 81 | new Notice(`${i18n("connect_test_success")} : ${words} ➡️ ${translateResponse.translation}`) 82 | } else { 83 | new Notice(i18n("init_engine_exception", {error: "unknown"})) 84 | } 85 | } catch (e) { 86 | new Notice(i18n("init_engine_exception", {error: e.message})) 87 | } 88 | }) 89 | }) 90 | 91 | 92 | const engine = new Setting(enginesChooserDiv); 93 | engine.setName(i18n("translate_engine")) 94 | .setDesc(i18n("engines_chooser_div_title")) 95 | .addDropdown(cb => cb.addOptions(this.getEnginesOptions()) 96 | .setValue(`${this.plugin.settings.engine}`) 97 | .onChange(async (value) => { 98 | console.debug("engine:" + value) 99 | this.plugin.settings.engine = value as SupportEngine; 100 | youdaoEngineDiv.toggleClass("settings-hide", value !== "youdao") 101 | baidubceEngineDiv.toggleClass("settings-hide", value !== "baidubce") 102 | // googleEngineDiv.toggleClass("settings-hide", value !== "google") 103 | await this.plugin.saveSettings(); 104 | }) 105 | ); 106 | 107 | const baseSettingDiv = containerEl.createDiv({cls: "setting-base-box"}); 108 | const baseHeader = baseSettingDiv.createDiv({cls: "setting-title"}); 109 | baseHeader.createEl("span", {text: i18n("base_setting"), cls: "guide"}) 110 | new Setting(baseSettingDiv) 111 | .setName(i18n("first_target_lang")) 112 | .setDesc(i18n("first_target_lang_desc")) 113 | .addDropdown(cb => cb.addOptions(getLanguageOptions(this.plugin.settings.lang)) 114 | .setValue(this.plugin.settings.targetLang as string) 115 | .onChange(async value => { 116 | this.plugin.settings.targetLang = value; 117 | await this.plugin.saveSettings(); 118 | }) 119 | ) 120 | 121 | 122 | new Setting(baseSettingDiv) 123 | .setName(i18n("attach")) 124 | .setDesc(i18n("attach_desc")) 125 | .addText(text => { 126 | text.setPlaceholder(i18n("attach_placeholder")) 127 | .setValue(this.plugin.settings.attach) 128 | .onChange(async (value) => { 129 | this.plugin.settings.attach = value; 130 | await this.plugin.saveSettings(); 131 | }); 132 | }); 133 | 134 | new Setting(containerEl) 135 | .setName(i18n("show_link")) 136 | .setDesc(i18n("show_link_desc")) 137 | .addToggle(tc => { 138 | tc.setValue(this.plugin.settings.show_link) 139 | .onChange(async (value) => { 140 | this.plugin.settings.show_link = value; 141 | await this.plugin.saveSettings(); 142 | }) 143 | }) 144 | 145 | new Setting(containerEl) 146 | .setName(i18n("show_radio")) 147 | .setDesc(i18n("show_radio_desc")) 148 | .addToggle(tc => { 149 | tc.setValue(this.plugin.settings.show_radio) 150 | .onChange(async (value) => { 151 | this.plugin.settings.show_radio = value; 152 | await this.plugin.saveSettings(); 153 | }) 154 | }) 155 | 156 | } 157 | 158 | private renderYoudaoEngineSettings(containerEl: HTMLElement, i18n: Function) { 159 | /** 160 | * ========== 161 | * ==youdao== 162 | * ========== 163 | */ 164 | const youdaoEngineDiv = containerEl.createDiv({cls: "setting-config-box"}); 165 | youdaoEngineDiv.toggleClass("settings-hide", this.plugin.settings.engine !== "youdao") 166 | const youdaoHeader = youdaoEngineDiv.createDiv({cls: "setting-engine-header"}); 167 | youdaoHeader.createEl("span", {text: i18n("guide", {engine: i18n("youdao")}) + ":", cls: "guide"}) 168 | youdaoHeader.createEl("img", {attr: {src: logo_image.youdao}}) 169 | const youdaoGuideP = youdaoEngineDiv.createEl("p"); 170 | youdaoGuideP.createEl("a", { 171 | href: "https://ai.youdao.com/console/", 172 | text: i18n("youdao_console"), 173 | }); 174 | youdaoGuideP.createEl("span", {text: i18n("youdao_guide")}) 175 | 176 | 177 | new Setting(youdaoEngineDiv) 178 | .setName(i18n("youdao_app_key")) 179 | .setDesc("AppKey") 180 | .addText((text) => { 181 | text.inputEl.setAttribute("type", "password"); 182 | text 183 | .setValue(`${(this.plugin.settings.engineConfig as YoudaoConfigs).appKey ?? ""}`) 184 | .onChange(async (value) => { 185 | this.setConfigValue(this.plugin.settings.engineConfig, 'appKey', value) 186 | await this.plugin.saveSettings(); 187 | }); 188 | this.addPasswordFocusEvent(text); 189 | }); 190 | 191 | new Setting(youdaoEngineDiv) 192 | .setName(i18n("youdao_app_secret")) 193 | .setDesc("AppSecret") 194 | .addText((text) => { 195 | text.inputEl.setAttribute("type", "password"); 196 | text 197 | .setValue(`${(this.plugin.settings.engineConfig as YoudaoConfigs).appSecret ?? ""}`) 198 | .onChange(async (value) => { 199 | this.setConfigValue(this.plugin.settings.engineConfig, 'appSecret', value) 200 | await this.plugin.saveSettings(); 201 | }); 202 | this.addPasswordFocusEvent(text); 203 | }); 204 | return youdaoEngineDiv; 205 | } 206 | 207 | private renderBaiduBceEngineSettings(containerEl: HTMLElement, i18n: Function) { 208 | const baidubceEngineDiv = containerEl.createDiv({cls: "setting-config-box"}); 209 | baidubceEngineDiv.toggleClass("settings-hide", this.plugin.settings.engine !== "baidubce") 210 | const baidubceHeader = baidubceEngineDiv.createDiv({cls: "setting-engine-header"}); 211 | baidubceHeader.createEl("span", {text: i18n("guide", {engine: i18n("baidubce")}) + ":", cls: "guide"}) 212 | baidubceHeader.createEl("img", {attr: {src: logo_image.baidubce}}) 213 | const baidubceGuideP = baidubceEngineDiv.createEl("p"); 214 | baidubceGuideP.createEl("a", { 215 | href: "https://console.bce.baidu.com/", 216 | text: i18n("baidubce_console"), 217 | }); 218 | baidubceGuideP.createEl("span", {text: i18n("baidubce_guide")}) 219 | 220 | 221 | new Setting(baidubceGuideP) 222 | .setName(i18n("api_key")) 223 | .setDesc("API Key") 224 | .addText((text) => { 225 | text.inputEl.setAttribute("type", "password"); 226 | text 227 | .setValue(`${(this.plugin.settings.engineConfig as BaidubceConfigs).apiKey ?? ""}`) 228 | .onChange(async (value) => { 229 | this.setConfigValue(this.plugin.settings.engineConfig, 'apiKey', value) 230 | await this.plugin.saveSettings(); 231 | }); 232 | this.addPasswordFocusEvent(text); 233 | }); 234 | 235 | new Setting(baidubceGuideP) 236 | .setName(i18n("secret_key")) 237 | .setDesc("Secret Key") 238 | .addText((text) => { 239 | text.inputEl.setAttribute("type", "password"); 240 | text 241 | .setValue(`${(this.plugin.settings.engineConfig as BaidubceConfigs).secretKey ?? ""}`) 242 | .onChange(async (value) => { 243 | console.log(value) 244 | this.setConfigValue(this.plugin.settings.engineConfig, 'secretKey', value) 245 | await this.plugin.saveSettings(); 246 | }); 247 | this.addPasswordFocusEvent(text); 248 | }); 249 | return baidubceEngineDiv; 250 | } 251 | 252 | private addPasswordFocusEvent(text: TextComponent) { 253 | text.inputEl.addEventListener("focus", function () { 254 | this.setAttribute("type", "text"); 255 | }); 256 | 257 | text.inputEl.addEventListener("blur", function () { 258 | this.setAttribute("type", "password"); 259 | }); 260 | } 261 | 262 | private setConfigValue(obj: T | undefined, key: K, value: V): T { 263 | const updatedObj = obj ?? {} as T; 264 | updatedObj[key] = value; 265 | return updatedObj; 266 | } 267 | 268 | private getEnginesOptions(): Record { 269 | const options: Record = {} 270 | Object.entries(TranslateEngines).forEach(([key, value]) => { 271 | options[key] = this.plugin.i18n.t(key as I18nKey) 272 | }); 273 | return options 274 | } 275 | 276 | 277 | } 278 | 279 | -------------------------------------------------------------------------------- /src/translate/modal/TranslationModalComponent.vue: -------------------------------------------------------------------------------- 1 | 149 | 150 | 378 | 379 | 380 | 383 | -------------------------------------------------------------------------------- /src/assets/engine-logo/logos.ts: -------------------------------------------------------------------------------- 1 | export const logo_image = { 2 | youdao: "data:image/png;base64,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", 3 | google: "https://www.gstatic.com/images/branding/googlelogo/svg/googlelogo_clr_74x24px.svg", 4 | baidubce: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHYAAAAaCAYAAABikagwAAAAAXNSR0IArs4c6QAABsdJREFUaEPtWk1onEUYnjVJ60Fom4I2W/FkLR56iEkMlqIXEdJko4InURrBdKMVEQQPPRgr+HMRPCSYpAVT6EHxoGYTvRREKBWbTVNb0Ka9SbNqS3+EqpCfrjyTfb6+OzvzzXzbmK26A4Hsfu+8M/M+87x/36au/1UsqoCRu3hMfXfljHqkeYfK3L0rYEZdpJYWSPmAPXf9ZzV+YUpdXvw92ufmpg2q795u9cBd99Vy7/W1YyzgBBZAAlAA6xo7N+1QPffsUgC6Pm4vC1iBhdud/O1Y0E4Bat09B5lqTYXKgE0CqLnL2w3g3MTn6uTMtN7m4IF3vUb9pTCvCoV5LdfW/rBX3icwkz+hRdLpraolvbVMHGthmN/7dCZ5roENcbtQCvBkrLUt5Iu/oyNDkcGTbFTKjh48rD8eGNzvVAFgOTK9T1vl+rP7tOEBQrZ/TwSECexDbR3q4OhwBLypDPrl5RkbGVJjo8NaDHuV+rAO1oM899Xe+qCWzU0d1WBfunRRPf/sM9WaRw19dEhpYJnxukBDkoRkicDCTR+/esa6sI+5PFi1uwYQE1NHtZF7ux+vVo2eBz3QBz1kq03h3uw+NZn7IghYeUkwb+/AK1olWfrW4H4NLMGWniI/+5OWBbBdTzxW9dk++exLlTo3f6OYbk5pJgIsGVvj2GfLlpFI+UohMMlmRLhNui8cGiyxDQCBmy6BBfPave6zsqpra++MmN/SktZsITOxh57MU/o5dONCYk2ARVAANs5DxgIkymGf9CyQAYM5j+eU58MzygPYubM/Vg3slpa0Sj36xp/Fgd1NqrezUSsCwAC3ed0GL0i8DJiHDJnZ8fLxr9VSblytf+/T4M3BrdJ9SjflUiCBhUHwGYaG0WBUgG0OEzS6QqzNy2aLjW1tHRFjpWulyyWw0gXb9s0LiTXwPy8v9rsacV2umbr/xT/0VW7f1qDefm6dAnurHctzs2p58rC6MTerVdw59q1VlYx/FJAxjLHPZRx8bwLLGJlk76GuGCAADKwZByzOhXPYBtgPPXTFIZc3yVlM2QhYPsjublIvdTUl1gmWLo6/XzbPBSyThcSLiLhoApvPn9BJGRlnY+xUKU7S1cPQYAvjH3TIhAzGhxuGi2YclsDSy8jkCax1DQmsdOmUt2XQ1dgIcyqABXMPvbo+sb6FD16LmMrJPmBDXRBAJGBkmQmsjlEjQ5ox0DtSypxvHqSoBvr7NOtWgHgHx9ePARAZic94DvZBDwYuSVxWzCQJFyQTk9DhokCGGbNpZOrxuXQfOLh8NQXWLBNcGyZgeB4CrO/gJrDM1BmbwVImPNAFQFzAyjNIYGWJxUtD9wvgkA+YAxcI8+rAlthB9wgwbAarZMbLpWRlhbE/nDqpTp8+pTZu3FQmeu3aVf0Z3/N/UxeeEUQJLNjHBgSTOgDLuhkeRpY81Is5sgRiYgdZ6AwZkK0pY3HI7lJJEbdhGTtdjPV3ccrLnRU3uwLsrTKEjYUQV4yz4ALGNShoC8gx5nONEGBrHmNDNynlXMAi8XFlpLZ14PaypeYBgQ2N+dLw+N8GrCxfyECASWAZvzGfpRYbFNTPEBEasuQ5a8rYuEaE3GQIYyWwLk9gZs02YNmUCLl0TIJswNLtao8wOhy1EQmsqV82KPiM1UNoacQyEhehAlgoRcnzZGdjcE1rK3Wgx5cVh95EX/IEFmvmiDqSiRBrUNmEWGEr4lXtXDH2xUtkK3NkeDCZbLt0bGWy5WoFFhNR9mS7GlXHtgbn5WWHqXj5V6vMPwmsjD90z3RrcMm2JghYgTLnphssB3Y1XbHN1UpX7GMh62bZb47zIrSHF9jIHVg6UmaHybWgD9jQ5EnGToIogcWNZp2LeIU/M94SNNmDZjYrY+xquWLpnmW3ia5YZ8VGP5yZsjyb682UaXOWVBGw35xeKg4eWVCFK/E/fUK7Ef1kl9uVC92xvVU19OxRDdtbrZhX23mSrptdHzYjbG962I91xTVeklvJimVslFkxgbVdQFf7k+yUc0JivZShjaIX7RPfL6mRrxadALPVuJT7WDf4XaPp9Q+dgHJO3HtUl168sWCyw3gKwMBAGJcMhZzOE0oZL/UVChfUTH667F0we9LypXwSQ+KNEF/LYZ7sKrGOBZPY9GdJxu9sCRTOguch9bg5X+6n4qcxbx5ZUPnzyxUA+4BtzPSpxswLSeyyxrI2j1T9C4813nzi5ay/eYJbBnvBYg4XsA07uxRATW3eknjxtZ3gCjX/TXBjf346fX5ZMf6awPri6NqCFrJaHdgKK5G5SJ4QY8FOMPXfNf5fwP4N1WgvjAgb6j8AAAAASUVORK5CYII=" 5 | } 6 | --------------------------------------------------------------------------------