feat: implement local dictionary management with import and delete functionality

This commit is contained in:
troyeguo
2026-05-14 11:06:01 +08:00
parent fdd066300b
commit 0261d88ee3
11 changed files with 399 additions and 2 deletions

View File

@@ -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": "拖拽图书到此处",

View File

@@ -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")}
</div>
</div>
@@ -172,6 +183,8 @@ class SettingDialog extends React.Component<
<BackgroundSetting />
) : this.props.settingMode === "chapter" ? (
<ChapterSetting />
) : this.props.settingMode === "dict" ? (
<DictSetting />
) : (
<PluginSetting />
)}

View File

@@ -31,7 +31,7 @@
letter-spacing: 0.5px;
}
.setting-dialog-sidebar-group {
margin-bottom: 8px;
margin-bottom: 0px;
}
.setting-dialog-sidebar-item {
display: flex;

View File

@@ -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<PopupDictProps, PopupDictState> {
private aiTextAccumulator: string = "";
@@ -62,6 +63,7 @@ class PopupDict extends React.Component<PopupDictProps, PopupDictState> {
}
}
componentDidMount() {
console.log(this.props.plugins, "plugins");
this.handleLookUp();
}
async handleLookUp() {
@@ -305,6 +307,20 @@ class PopupDict extends React.Component<PopupDictProps, PopupDictState> {
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"

View File

@@ -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<SettingInfoProps, SettingInfoState> {
fileInputRef = React.createRef<HTMLInputElement>();
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<HTMLInputElement>) => {
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<DictMeta, "id"> = {
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 (
<>
<div className="dict-setting-list">
{isLoading ? (
<div className="dict-setting-empty">
<Trans>Loading</Trans>...
</div>
) : dicts.length === 0 ? (
<div className="background-setting-empty">
<Trans>No local dictionaries imported yet</Trans>
</div>
) : (
dicts.map((dict) => (
<div className="setting-dialog-new-title" key={dict.id}>
<span>
<span
className="setting-dialog-new-title-name"
style={{ marginLeft: "10px" }}
>
{dict.name}
</span>
<span
className="setting-dialog-new-title-tag"
style={{
marginLeft: "10px",
color: "#888",
fontSize: "12px",
}}
>
{dict.extension.toUpperCase()}
</span>
</span>
<span
className="change-location-button"
onClick={() => this.handleDelete(dict)}
>
<Trans>Delete</Trans>
</span>
</div>
))
)}
</div>
{/* Hidden file input */}
<input
ref={this.fileInputRef}
type="file"
accept=".mdx,.mdd"
style={{ display: "none" }}
onChange={this.handleFileChange}
/>
{/* Import button */}
<div
className="setting-dialog-new-plugin"
onClick={this.handleImportClick}
>
<span style={{ fontWeight: "bold" }}>
<Trans>Import dictionary</Trans>
</span>
</div>
</>
);
}
}
export default DictSetting;

View File

@@ -0,0 +1,9 @@
.dict-setting-list {
margin: 10px 25px 60px 25px;
}
.dict-setting-empty {
margin: 20px 30px;
color: #888;
font-size: 14px;
}

View File

@@ -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);

View File

@@ -0,0 +1,12 @@
import { RouteComponentProps } from "react-router-dom";
import { DictMeta } from "../../../utils/file/dictUtil";
export interface SettingInfoProps extends RouteComponentProps<any> {
t: (title: string) => string;
handleFetchPlugins: () => void;
}
export interface SettingInfoState {
dicts: DictMeta[];
isLoading: boolean;
}

View File

@@ -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 (
<div className="setting-dialog-new-title" key={item.key}>

View File

@@ -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 (

139
src/utils/file/dictUtil.ts Normal file
View File

@@ -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<void> {
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<void> {
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<string> {
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<DictMeta, "id">): 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;