From 0261d88ee3319ddbc8f42c69f9b2a2f42ff714cd Mon Sep 17 00:00:00 2001 From: troyeguo <13820674+troyeguo@users.noreply.github.com> Date: Thu, 14 May 2026 11:06:01 +0800 Subject: [PATCH] feat: implement local dictionary management with import and delete functionality --- src/assets/locales/zh-CN.json | 2 + .../dialogs/settingDialog/component.tsx | 13 ++ .../dialogs/settingDialog/settingDialog.css | 2 +- src/components/popups/popupDict/component.tsx | 16 ++ .../settings/dictSetting/component.tsx | 167 ++++++++++++++++++ .../settings/dictSetting/dictSetting.css | 9 + src/containers/settings/dictSetting/index.tsx | 14 ++ .../settings/dictSetting/interface.tsx | 12 ++ .../settings/pluginSetting/component.tsx | 2 +- src/store/actions/manager.tsx | 25 +++ src/utils/file/dictUtil.ts | 139 +++++++++++++++ 11 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 src/containers/settings/dictSetting/component.tsx create mode 100644 src/containers/settings/dictSetting/dictSetting.css create mode 100644 src/containers/settings/dictSetting/index.tsx create mode 100644 src/containers/settings/dictSetting/interface.tsx create mode 100644 src/utils/file/dictUtil.ts diff --git a/src/assets/locales/zh-CN.json b/src/assets/locales/zh-CN.json index a29a698c..6861d221 100644 --- a/src/assets/locales/zh-CN.json +++ b/src/assets/locales/zh-CN.json @@ -642,6 +642,8 @@ "Key": "密钥", "Api key": "API 密钥", "Deck Name": "卡组名称", + "Import dictionary": "导入词典", + "Local dictionary": "本地词典", "e.g. Vocabulary": "例如 词汇表", "Audio is not ready yet": "图书解析中,请稍后再试", "Drop your books here": "拖拽图书到此处", diff --git a/src/components/dialogs/settingDialog/component.tsx b/src/components/dialogs/settingDialog/component.tsx index 5d56c27a..b68111fd 100644 --- a/src/components/dialogs/settingDialog/component.tsx +++ b/src/components/dialogs/settingDialog/component.tsx @@ -13,6 +13,8 @@ import DataSetting from "../../../containers/settings/dataSetting"; import AISetting from "../../../containers/settings/aiSetting"; import BackgroundSetting from "../../../containers/settings/backgroundSetting"; import ChapterSetting from "../../../containers/settings/chapterSetting"; +import DictSetting from "../../../containers/settings/dictSetting"; +import { isElectron } from "react-device-detect"; class SettingDialog extends React.Component< SettingInfoProps, SettingInfoState @@ -72,6 +74,8 @@ class SettingDialog extends React.Component< return "Background"; case "chapter": return "TXT parser"; + case "dict": + return "Local dictionary"; default: return "Setting"; } @@ -131,6 +135,13 @@ class SettingDialog extends React.Component< "TXT parser", "19px" )} + {isElectron && + this.renderSidebarItem( + "dict", + "icon-address-book", + "Local dictionary", + "18px" + )} {this.renderSidebarItem("about", "icon-detail", "About", "18px")} @@ -172,6 +183,8 @@ class SettingDialog extends React.Component< ) : this.props.settingMode === "chapter" ? ( + ) : this.props.settingMode === "dict" ? ( + ) : ( )} diff --git a/src/components/dialogs/settingDialog/settingDialog.css b/src/components/dialogs/settingDialog/settingDialog.css index 5c702a02..6d4b1bff 100644 --- a/src/components/dialogs/settingDialog/settingDialog.css +++ b/src/components/dialogs/settingDialog/settingDialog.css @@ -31,7 +31,7 @@ letter-spacing: 0.5px; } .setting-dialog-sidebar-group { - margin-bottom: 8px; + margin-bottom: 0px; } .setting-dialog-sidebar-item { display: flex; diff --git a/src/components/popups/popupDict/component.tsx b/src/components/popups/popupDict/component.tsx index 577736c3..60e440c2 100644 --- a/src/components/popups/popupDict/component.tsx +++ b/src/components/popups/popupDict/component.tsx @@ -20,6 +20,7 @@ import { import { chatStream } from "../../../utils/request/common"; import { marked } from "marked"; import { getIframeDoc } from "../../../utils/reader/docUtil"; +import DictUtil from "../../../utils/file/dictUtil"; declare var window: any; class PopupDict extends React.Component { private aiTextAccumulator: string = ""; @@ -62,6 +63,7 @@ class PopupDict extends React.Component { } } componentDidMount() { + console.log(this.props.plugins, "plugins"); this.handleLookUp(); } async handleLookUp() { @@ -305,6 +307,20 @@ class PopupDict extends React.Component { this.aiTextAccumulator = ""; this.setState({ isAiWaiting: false, dictText: " " }); return ""; + } else if ( + this.state.dictService && + this.state.dictService.startsWith("dict_") + ) { + this.setState({ isAddNew: false }); + const plugin = this.props.plugins.find( + (item) => item.key === this.state.dictService + ); + if (!plugin) return ""; + const config: any = plugin.config || {}; + const dictId: string = config.dictId || ""; + if (!dictId) return ""; + console.log(dictId, "dictId"); + dictText = await DictUtil.lookupWord(dictId, text); } else if ( this.state.dictService && this.state.dictService !== "official-ai-dict-plugin" diff --git a/src/containers/settings/dictSetting/component.tsx b/src/containers/settings/dictSetting/component.tsx new file mode 100644 index 00000000..827747c8 --- /dev/null +++ b/src/containers/settings/dictSetting/component.tsx @@ -0,0 +1,167 @@ +import React from "react"; +import "./dictSetting.css"; +import { SettingInfoProps, SettingInfoState } from "./interface"; +import { Trans } from "react-i18next"; +import { isElectron } from "react-device-detect"; +import toast from "react-hot-toast"; +import DictUtil, { DictMeta } from "../../../utils/file/dictUtil"; + +declare var window: any; + +class DictSetting extends React.Component { + fileInputRef = React.createRef(); + + constructor(props: SettingInfoProps) { + super(props); + this.state = { + dicts: [], + isLoading: true, + }; + } + + componentDidMount() { + this.loadDicts(); + } + + loadDicts = () => { + const ids = DictUtil.getDictIds(); + const dicts: DictMeta[] = []; + for (const id of ids) { + const meta = DictUtil.getDictMeta(id); + if (meta) dicts.push(meta); + } + this.setState({ dicts, isLoading: false }); + }; + + handleImportClick = () => { + this.fileInputRef.current?.click(); + }; + + handleFileChange = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + const file = files[0]; + e.target.value = ""; + + const ext = file.name.split(".").pop()?.toLowerCase() || ""; + if (ext !== "mdx" && ext !== "mdd") { + toast.error(this.props.t("Only MDX and MDD files are supported")); + return; + } + + const reader = new FileReader(); + reader.onload = async (ev) => { + const arrayBuffer = ev.target?.result as ArrayBuffer; + if (!arrayBuffer) return; + + const id = Date.now().toString(); + const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, ""); + const meta: Omit = { + name: fileNameWithoutExt, + extension: ext, + }; + + try { + await DictUtil.saveDict(id, fileNameWithoutExt, arrayBuffer); + DictUtil.saveDictMeta(id, meta); + DictUtil.addDictId(id); + + const newDict: DictMeta = { id, ...meta }; + this.setState((prev) => ({ + dicts: [...prev.dicts, newDict], + })); + this.props.handleFetchPlugins(); + toast.success(this.props.t("Import successful")); + } catch (err) { + console.error(err); + toast.error(this.props.t("Import failed")); + } + }; + reader.readAsArrayBuffer(file); + }; + + handleDelete = async (dict: DictMeta) => { + try { + await DictUtil.deleteDict(dict.id); + DictUtil.deleteDictMeta(dict.id); + DictUtil.removeDictId(dict.id); + this.setState((prev) => ({ + dicts: prev.dicts.filter((d) => d.id !== dict.id), + })); + this.props.handleFetchPlugins(); + toast.success(this.props.t("Deletion successful")); + } catch (err) { + console.error(err); + toast.error(this.props.t("Deletion failed")); + } + }; + + render() { + const { dicts, isLoading } = this.state; + return ( + <> +
+ {isLoading ? ( +
+ Loading... +
+ ) : dicts.length === 0 ? ( +
+ No local dictionaries imported yet +
+ ) : ( + dicts.map((dict) => ( +
+ + + {dict.name} + + + {dict.extension.toUpperCase()} + + + this.handleDelete(dict)} + > + Delete + +
+ )) + )} +
+ + {/* Hidden file input */} + + + {/* Import button */} +
+ + Import dictionary + +
+ + ); + } +} + +export default DictSetting; diff --git a/src/containers/settings/dictSetting/dictSetting.css b/src/containers/settings/dictSetting/dictSetting.css new file mode 100644 index 00000000..5ebb71b5 --- /dev/null +++ b/src/containers/settings/dictSetting/dictSetting.css @@ -0,0 +1,9 @@ +.dict-setting-list { + margin: 10px 25px 60px 25px; +} + +.dict-setting-empty { + margin: 20px 30px; + color: #888; + font-size: 14px; +} diff --git a/src/containers/settings/dictSetting/index.tsx b/src/containers/settings/dictSetting/index.tsx new file mode 100644 index 00000000..55715bdb --- /dev/null +++ b/src/containers/settings/dictSetting/index.tsx @@ -0,0 +1,14 @@ +import { connect } from "react-redux"; +import DictSetting from "./component"; +import { withTranslation } from "react-i18next"; +import { withRouter } from "react-router-dom"; +import { handleFetchPlugins } from "../../../store/actions"; + +const mapStateToProps = () => { + return {}; +}; +const actionCreator = { handleFetchPlugins }; +export default connect( + mapStateToProps, + actionCreator +)(withTranslation()(withRouter(DictSetting as any) as any) as any); diff --git a/src/containers/settings/dictSetting/interface.tsx b/src/containers/settings/dictSetting/interface.tsx new file mode 100644 index 00000000..181c8ff6 --- /dev/null +++ b/src/containers/settings/dictSetting/interface.tsx @@ -0,0 +1,12 @@ +import { RouteComponentProps } from "react-router-dom"; +import { DictMeta } from "../../../utils/file/dictUtil"; + +export interface SettingInfoProps extends RouteComponentProps { + t: (title: string) => string; + handleFetchPlugins: () => void; +} + +export interface SettingInfoState { + dicts: DictMeta[]; + isLoading: boolean; +} diff --git a/src/containers/settings/pluginSetting/component.tsx b/src/containers/settings/pluginSetting/component.tsx index 03acab2f..4ab504a5 100644 --- a/src/containers/settings/pluginSetting/component.tsx +++ b/src/containers/settings/pluginSetting/component.tsx @@ -201,7 +201,7 @@ class SettingDialog extends React.Component< {this.props.plugins && this.props.plugins - .filter((item) => item.type !== "ai") + .filter((item) => item.type !== "ai" && item.type !== "dictionary") .map((item) => { return (
diff --git a/src/store/actions/manager.tsx b/src/store/actions/manager.tsx index 8b9513a4..4d349887 100644 --- a/src/store/actions/manager.tsx +++ b/src/store/actions/manager.tsx @@ -22,6 +22,7 @@ import { azureTTSVoiceList, officialVoiceList } from "../../constants/ttsList"; import { langToName } from "../../utils/common"; import { resetReaderRequest } from "../../utils/request/reader"; import { resetThirdpartyRequest } from "../../utils/request/thirdparty"; +import DictUtil from "../../utils/file/dictUtil"; export function handleBooks(books: BookModel[]) { return { type: "HANDLE_BOOKS", payload: books }; } @@ -299,6 +300,29 @@ export function handleFetchPlugins() { } pluginList = pluginList.filter((p: PluginModel) => p.type !== "ai"); + // Load local dictionary plugins from ConfigService + const localDictIds = DictUtil.getDictIds(); + for (const dictId of localDictIds) { + const meta = DictUtil.getDictMeta(dictId); + console.log(meta, "dict mdeta"); + if (meta) { + let localDictPlugin = new PluginModel( + `dict_${dictId}`, + "dictionary", + meta.name, + "dict", + "1.0.0", + "", + { dictId }, + [], + [], + "", + "" + ); + pluginList.push(localDictPlugin); + } + } + if (ConfigService.getReaderConfig("aiTranslateModel")) { const modelKey = ConfigService.getReaderConfig("aiTranslateModel"); const entry = ConfigService.getObjectConfig( @@ -371,6 +395,7 @@ export function handleFetchPlugins() { pluginList.push(assistPlugin); } } + console.log(pluginList, "PLUGINLIST"); TokenService.getToken("is_authed").then((value) => { let isAuthed = value === "yes"; if ( diff --git a/src/utils/file/dictUtil.ts b/src/utils/file/dictUtil.ts new file mode 100644 index 00000000..e16ff83f --- /dev/null +++ b/src/utils/file/dictUtil.ts @@ -0,0 +1,139 @@ +import { isElectron } from "react-device-detect"; +import { getStorageLocation } from "../common"; +import { ConfigService } from "../../assets/lib/kookit-extra-browser.min"; +import { LocalFileManager } from "./localFile"; +import localforage from "localforage"; +import { Buffer } from "buffer"; + +declare var window: any; + +const DICT_FOLDER = "dict"; + +export interface DictMeta { + id: string; + name: string; + extension: string; +} + +class DictUtil { + /** Save dict file (ArrayBuffer) by id */ + static async saveDict( + id: string, + name: string, + arrayBuffer: ArrayBuffer + ): Promise { + const ext = name.split(".").pop()?.toLowerCase() || "mdx"; + const filename = `${id}.${ext}`; + + if (isElectron) { + const fs = window.require("fs"); + const path = window.require("path"); + const dir = path.join(getStorageLocation() || "", DICT_FOLDER); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(path.join(dir, filename), Buffer.from(arrayBuffer)); + } else { + if (ConfigService.getReaderConfig("isUseLocal") === "yes") { + await LocalFileManager.saveFile(filename, arrayBuffer, DICT_FOLDER); + } else { + await localforage.setItem(`dict_${id}`, arrayBuffer); + } + } + } + + /** Delete dict file by id */ + static async deleteDict(id: string): Promise { + if (isElectron) { + const fs = window.require("fs"); + const path = window.require("path"); + const dir = path.join(getStorageLocation() || "", DICT_FOLDER); + if (!fs.existsSync(dir)) return; + const files: string[] = fs.readdirSync(dir); + const file = files.find((f) => f.startsWith(id + ".")); + if (file) { + const filePath = path.join(dir, file); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } + } else { + if (ConfigService.getReaderConfig("isUseLocal") === "yes") { + for (const ext of ["mdx", "mdd"]) { + await LocalFileManager.deleteFile(`${id}.${ext}`, DICT_FOLDER).catch( + () => {} + ); + } + } else { + await localforage.removeItem(`dict_${id}`); + } + } + } + + /** Get file path for Electron only */ + static getDictFilePath(id: string): string | null { + if (!isElectron) return null; + const fs = window.require("fs"); + const path = window.require("path"); + const dir = path.join(getStorageLocation() || "", DICT_FOLDER); + if (!fs.existsSync(dir)) return null; + const files: string[] = fs.readdirSync(dir); + const file = files.find((f) => f.startsWith(id + ".")); + return file ? path.join(dir, file) : null; + } + + /** Look up a word in the local MDX/MDD dictionary */ + static async lookupWord(id: string, word: string): Promise { + if (isElectron) { + try { + const filePath = this.getDictFilePath(id); + if (!filePath) return ""; + const { MDX } = window.require("js-mdict"); + const mdict = new MDX(filePath); + const result = mdict.lookup(word); + if ( + !result || + result.definition === null || + result.definition === undefined + ) { + return ""; + } + return String(result.definition); + } catch (e) { + console.error("Dict lookup error:", e); + return ""; + } + } else { + // Browser: js-mdict requires file system access; not supported in web mode + return ""; + } + } + + /** Save dict metadata */ + static saveDictMeta(id: string, meta: Omit): void { + ConfigService.setObjectConfig(id, { id, ...meta }, "customDicts"); + } + + /** Get dict metadata */ + static getDictMeta(id: string): DictMeta | null { + return ConfigService.getObjectConfig(id, "customDicts", null); + } + + /** Delete dict metadata */ + static deleteDictMeta(id: string): void { + ConfigService.setObjectConfig(id, null, "customDicts"); + } + + /** Return all stored dict ids */ + static getDictIds(): string[] { + return ConfigService.getAllListConfig("dictList") || []; + } + + static addDictId(id: string): void { + ConfigService.setListConfig(id, "dictList"); + } + + static removeDictId(id: string): void { + ConfigService.deleteListConfig(id, "dictList"); + } +} + +export default DictUtil;