mirror of
https://github.com/koodo-reader/koodo-reader.git
synced 2026-06-16 11:50:41 -04:00
feat: implement local dictionary management with import and delete functionality
This commit is contained in:
@@ -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": "拖拽图书到此处",
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.setting-dialog-sidebar-group {
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
.setting-dialog-sidebar-item {
|
||||
display: flex;
|
||||
|
||||
@@ -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"
|
||||
|
||||
167
src/containers/settings/dictSetting/component.tsx
Normal file
167
src/containers/settings/dictSetting/component.tsx
Normal 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;
|
||||
9
src/containers/settings/dictSetting/dictSetting.css
Normal file
9
src/containers/settings/dictSetting/dictSetting.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.dict-setting-list {
|
||||
margin: 10px 25px 60px 25px;
|
||||
}
|
||||
|
||||
.dict-setting-empty {
|
||||
margin: 20px 30px;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
14
src/containers/settings/dictSetting/index.tsx
Normal file
14
src/containers/settings/dictSetting/index.tsx
Normal 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);
|
||||
12
src/containers/settings/dictSetting/interface.tsx
Normal file
12
src/containers/settings/dictSetting/interface.tsx
Normal 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;
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
139
src/utils/file/dictUtil.ts
Normal 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;
|
||||
Reference in New Issue
Block a user