feat: 🎸 add dicts (#1022)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -131,6 +131,7 @@ ffmpeg-core.worker.js
|
||||
|
||||
# dict
|
||||
cam_dict.refined.sqlite
|
||||
enjoy/lib/dictionaries
|
||||
|
||||
.vitepress/cache/
|
||||
public/jupyter-notebooks/*.mp3
|
||||
|
||||
@@ -54,9 +54,11 @@
|
||||
"@types/mark.js": "^8.11.12",
|
||||
"@types/mustache": "^4.2.5",
|
||||
"@types/node": "^22.5.0",
|
||||
"@types/prop-types": "^15",
|
||||
"@types/rails__actioncable": "^6.1.11",
|
||||
"@types/react": "^18.3.4",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/unzipper": "^0",
|
||||
"@types/validator": "^13.12.1",
|
||||
"@types/wavesurfer.js": "^6.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^8.2.0",
|
||||
@@ -72,6 +74,7 @@
|
||||
"flora-colossus": "^2.0.0",
|
||||
"octokit": "^4.0.2",
|
||||
"progress": "^2.0.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss": "^3.4.10",
|
||||
@@ -151,6 +154,7 @@
|
||||
"js-md5": "^0.8.3",
|
||||
"langchain": "^0.2.17",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.0.0",
|
||||
"lucide-react": "^0.436.0",
|
||||
"mark.js": "^8.11.1",
|
||||
"microsoft-cognitiveservices-speech-sdk": "^1.40.0",
|
||||
@@ -165,12 +169,14 @@
|
||||
"react-audio-visualize": "^1.1.3",
|
||||
"react-audio-voice-recorder": "^2.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-frame-component": "^5.2.7",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^2.1.1",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"react-shadow-root": "^6.2.0",
|
||||
"react-tooltip": "^5.28.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rimraf": "^6.0.1",
|
||||
@@ -180,6 +186,7 @@
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-scrollbar-hide": "^1.1.7",
|
||||
"umzug": "^3.8.1",
|
||||
"unzipper": "^0.12.3",
|
||||
"update-electron-app": "^3.0.0",
|
||||
"wavesurfer.js": "^7.8.4",
|
||||
"zod": "^3.23.8",
|
||||
|
||||
68
enjoy/src/constants/dicts.ts
Normal file
68
enjoy/src/constants/dicts.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export const DICTS = [
|
||||
{
|
||||
name: "ldoce5",
|
||||
fileName: "ldoce5.zip",
|
||||
title: "Longman Dictionary of Contemporary English",
|
||||
pronunciation: true,
|
||||
lang: "En-En",
|
||||
downloadUrl: "https://dl.enjoy.bot/dicts/ldoce5.zip",
|
||||
size: "1.63GB",
|
||||
hash: "4a03ce291ea7b6e0ea46f4c2fc335ad4",
|
||||
addition: '<link href="theme.css" rel="stylesheet" type="text/css" />',
|
||||
},
|
||||
{
|
||||
name: "oxford_en_mac",
|
||||
fileName: "oxford_en_mac.zip",
|
||||
title: "Oxford Dictionary of English (Mac)",
|
||||
pronunciation: false,
|
||||
lang: "En-En",
|
||||
downloadUrl: "https://dl.enjoy.bot/dicts/oxford_en_mac.zip",
|
||||
hash: "cffaef4b3ed6ec7d3ee7209b18e05c6f",
|
||||
size: "33.6MB",
|
||||
addition: '<link href="theme.css" rel="stylesheet" type="text/css" />',
|
||||
},
|
||||
{
|
||||
name: "koen_mac",
|
||||
fileName: "koen_mac.zip",
|
||||
title: "Korean English Dictionary (Mac)",
|
||||
pronunciation: false,
|
||||
lang: "Ko-En",
|
||||
downloadUrl: "https://dl.enjoy.bot/dicts/koen_mac.zip",
|
||||
size: "52.1MB",
|
||||
hash: "fa028c585de10e54a7028c6683738499",
|
||||
addition: '<link href="theme.css" rel="stylesheet" type="text/css" />',
|
||||
},
|
||||
{
|
||||
name: "jaen_mac",
|
||||
fileName: "jaen_mac.zip",
|
||||
title: "Sanseido The WISDOM English-Japanese Japanese-English Dictionary",
|
||||
pronunciation: false,
|
||||
lang: "Ja-En",
|
||||
downloadUrl: "https://dl.enjoy.bot/dicts/jaen_mac.zip",
|
||||
hash: "3008e1cd2a8b6f224f90d14a8e1de9cb",
|
||||
size: "39.8MB",
|
||||
addition: '<link href="theme.css" rel="stylesheet" type="text/css" />',
|
||||
},
|
||||
{
|
||||
name: "deen_mac",
|
||||
fileName: "deen_mac.zip",
|
||||
title: "German English Dictionary (Mac)",
|
||||
pronunciation: false,
|
||||
lang: "Ge-En",
|
||||
downloadUrl: "https://dl.enjoy.bot/dicts/deen_mac.zip",
|
||||
hash: "3fedde07108236f6e6cfe907bd60faba",
|
||||
size: "32.1MB",
|
||||
addition: '<link href="theme.css" rel="stylesheet" type="text/css" />',
|
||||
},
|
||||
{
|
||||
name: "ruen_mac",
|
||||
fileName: "ruen_mac.zip",
|
||||
title: "Russian English Dictionary (Mac)",
|
||||
pronunciation: false,
|
||||
lang: "Ru-En",
|
||||
downloadUrl: "https://dl.enjoy.bot/dicts/ruen_mac.zip",
|
||||
hash: "5b98fc0e5c3de9df43189cb79d5bf4cc",
|
||||
size: "18.1MB",
|
||||
addition: '<link href="theme.css" rel="stylesheet" type="text/css" />',
|
||||
},
|
||||
];
|
||||
@@ -743,5 +743,31 @@
|
||||
"recordingIsTooLongToAssess": "Recording is too long to assess. The maximum duration is 60 seconds.",
|
||||
"recorderConfig": "Recorder config",
|
||||
"recorderConfigSaved": "Recorder config saved",
|
||||
"recorderConfigDescription": "Advanced settings for recorder"
|
||||
"recorderConfigDescription": "Advanced settings for recorder",
|
||||
"lookupOnMouseOver": "Lookup On MouseOver",
|
||||
"selectDictFile": "Select Dict Files (extension with .mdx and .mdd files)",
|
||||
"dictFiles": "Dict Files",
|
||||
"dictFileRequired": "Dict file (the extension is mdx) not found.",
|
||||
"dictFileExist": "{{name}} has already been imported",
|
||||
"dictFileAddSuccess": "Add {{name}} successfully",
|
||||
"dictFileRemoveSuccess": "Remove {{name}} successfully",
|
||||
"dictFileSetDefaultSuccess": "Set default successfully",
|
||||
"dictionaries": "Dictionaries",
|
||||
"import": "Import",
|
||||
"default": "Default",
|
||||
"setDefault": "Set Default",
|
||||
"dictSettings": "Dict Settings",
|
||||
"dictSettingsShort": "Dict Settings",
|
||||
"importDict": "Import Dictionary",
|
||||
"dictRemoved": "Dictionary removed successfully",
|
||||
"interrupted": "Interrupted",
|
||||
"paused": "Paused",
|
||||
"completedAndChecking": "Completed (Checking)",
|
||||
"decompressing": "Decompressing",
|
||||
"resume": "Resume",
|
||||
"dictEmpty": "No dictionaries imported",
|
||||
"removing": "Removing",
|
||||
"removeDictTitle": "Are you sure you want to delete this dictionary? ",
|
||||
"removeDictDescription": "It will delete the dictionary file from your local computer and you will have to download it again next time.",
|
||||
"downloadingDict": "Downloading"
|
||||
}
|
||||
|
||||
@@ -743,5 +743,31 @@
|
||||
"recordingIsTooLongToAssess": "录音时长过长,无法评估。最长支持 1 分钟。",
|
||||
"recorderConfig": "录音设置",
|
||||
"recorderConfigSaved": "录音设置已保存",
|
||||
"recorderConfigDescription": "调整录音高级设置"
|
||||
"recorderConfigDescription": "调整录音高级设置",
|
||||
"lookupOnMouseOver": "鼠标悬停查询单词",
|
||||
"selectDictFile": "选择字典文件 (扩展名为 .mdx 和 .mdd 的文件)",
|
||||
"dictFiles": "字典文件",
|
||||
"dictFileRequired": "未找到字典文件 (扩展名为 .mdx 的文件是必须的) ",
|
||||
"dictFileExist": "{{name}} 已经存在了",
|
||||
"dictFileAddSuccess": "已添加 {{name}}",
|
||||
"dictFileRemoveSuccess": "已删除 {{name}}",
|
||||
"dictFileSetDefaultSuccess": "设置成功",
|
||||
"dictionaries": "词典",
|
||||
"import": "导入",
|
||||
"default": "默认",
|
||||
"setDefault": "设为默认",
|
||||
"dictSettings": "词典设置",
|
||||
"dictSettingsShort": "词典设置",
|
||||
"importDict": "导入词典",
|
||||
"dictRemoved": "成功删除词典",
|
||||
"interrupted": "已中断",
|
||||
"paused": "已暂停",
|
||||
"completedAndChecking": "已完成 (正在检查)",
|
||||
"decompressing": "正在解压",
|
||||
"resume": "恢复",
|
||||
"dictEmpty": "没有导入的词典",
|
||||
"removing": "正在删除",
|
||||
"removeDictTitle": "你确定要删除词典吗?",
|
||||
"removeDictDescription": "此操作将会从本地删除词典文件,下次安装需要重新下载",
|
||||
"downloadingDict": "正在下载"
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ if (!process.env.CI) {
|
||||
});
|
||||
}
|
||||
|
||||
if (!app.isPackaged) {
|
||||
app.disableHardwareAcceleration();
|
||||
app.commandLine.appendSwitch("disable-software-rasterizer");
|
||||
}
|
||||
|
||||
// Add context menu
|
||||
contextMenu({
|
||||
showSearchWithGoogle: false,
|
||||
@@ -64,7 +69,10 @@ contextMenu({
|
||||
!parameters.selectionText.trim().includes(" "),
|
||||
click: () => {
|
||||
const { x, y, selectionText } = parameters;
|
||||
browserWindow.webContents.send("on-lookup", selectionText, { x, y });
|
||||
browserWindow.webContents.send("on-lookup", selectionText, "", {
|
||||
x,
|
||||
y,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -66,6 +66,7 @@ class Camdict {
|
||||
const item = await this.db?.findOne({
|
||||
where: { word: word.trim().toLowerCase() },
|
||||
});
|
||||
|
||||
return item?.toJSON();
|
||||
}
|
||||
|
||||
|
||||
83
enjoy/src/main/decompresser.ts
Normal file
83
enjoy/src/main/decompresser.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ipcMain } from "electron";
|
||||
import fs from "fs-extra";
|
||||
import { readdirSync } from "fs";
|
||||
import unzipper from "unzipper";
|
||||
import mainWin from "@main/window";
|
||||
|
||||
class Decompresser {
|
||||
public tasks: DecompressTask[];
|
||||
|
||||
constructor() {
|
||||
this.tasks = [];
|
||||
}
|
||||
|
||||
async depress(task: DecompressTask) {
|
||||
if (this.tasks.find(({ id }) => task.id === id)) return;
|
||||
this.add(task);
|
||||
|
||||
const tempPath = task.destPath + ".depressing";
|
||||
|
||||
if (fs.existsSync(tempPath)) await fs.remove(tempPath);
|
||||
const directory = await unzipper.Open.file(task.filePath);
|
||||
this.onProgress(task, directory.numberOfRecords);
|
||||
|
||||
await directory.extract({ path: task.destPath + ".depressing" });
|
||||
await fs.rename(task.destPath + ".depressing", task.destPath);
|
||||
await fs.remove(task.filePath);
|
||||
|
||||
this.remove(task);
|
||||
}
|
||||
|
||||
async onProgress(task: DecompressTask, total: number) {
|
||||
let progress = "0";
|
||||
|
||||
if (fs.existsSync(task.destPath + ".depressing")) {
|
||||
const dir = readdirSync(task.destPath + ".depressing", {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
progress = ((dir.length / total) * 100).toFixed(0);
|
||||
}
|
||||
|
||||
const currentTask = this.tasks.find(({ id }) => id === task.id);
|
||||
|
||||
this.update({ ...currentTask, progress });
|
||||
|
||||
if (currentTask) {
|
||||
setTimeout(() => {
|
||||
this.onProgress(task, total);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
add(task: DecompressTask) {
|
||||
this.tasks = [...this.tasks, task];
|
||||
this.notify();
|
||||
}
|
||||
|
||||
remove(task: DecompressTask) {
|
||||
this.tasks = this.tasks.filter(({ id }) => id !== task.id);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
update(task: DecompressTask) {
|
||||
const index = this.tasks.findIndex(({ id }) => id === task.id);
|
||||
|
||||
if (index > -1) {
|
||||
this.tasks.splice(index, 1, task);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
notify() {
|
||||
mainWin.win.webContents.send("decompress-tasks-update", this.tasks);
|
||||
}
|
||||
|
||||
registerIpcHandlers() {
|
||||
ipcMain.handle("decompress-tasks", () => {
|
||||
return this.tasks;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Decompresser();
|
||||
188
enjoy/src/main/dict.ts
Normal file
188
enjoy/src/main/dict.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import { ipcMain } from "electron";
|
||||
import { LRUCache } from "lru-cache";
|
||||
import log from "@main/logger";
|
||||
import { DICTS } from "@/constants/dicts";
|
||||
import sqlite3, { Database } from "sqlite3";
|
||||
import settings from "./settings";
|
||||
import downloader from "./downloader";
|
||||
import decompresser from "./decompresser";
|
||||
import { hashFile } from "@/main/utils";
|
||||
|
||||
const logger = log.scope("dict");
|
||||
const sqlite = sqlite3.verbose();
|
||||
|
||||
export class DictHandler {
|
||||
private cache = new LRUCache({ max: 20 });
|
||||
private db: Database;
|
||||
private currentDict: string;
|
||||
|
||||
get dictsPath() {
|
||||
const _path = path.join(settings.libraryPath(), "dictionaries");
|
||||
fs.ensureDirSync(_path);
|
||||
|
||||
return _path;
|
||||
}
|
||||
|
||||
async isDictFileValid(dict: Dict) {
|
||||
const filePath = path.join(this.dictsPath, dict.fileName);
|
||||
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
|
||||
const hash = await hashFile(filePath, { algo: "md5" });
|
||||
|
||||
return hash === dict.hash;
|
||||
}
|
||||
|
||||
async download(dict: Dict) {
|
||||
const filePath = path.join(this.dictsPath, dict.fileName);
|
||||
const dictPath = path.join(this.dictsPath, dict.name);
|
||||
|
||||
if (fs.existsSync(dictPath)) {
|
||||
throw new Error("Dictionary already exists");
|
||||
}
|
||||
|
||||
const isDictFileValid = await this.isDictFileValid(dict);
|
||||
|
||||
if (isDictFileValid) {
|
||||
this.decompress(dict);
|
||||
} else {
|
||||
if (fs.existsSync(filePath)) {
|
||||
await fs.remove(filePath);
|
||||
}
|
||||
|
||||
downloader.download(dict.downloadUrl, {
|
||||
savePath: this.dictsPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async decompress(dict: Dict) {
|
||||
const filePath = path.join(this.dictsPath, dict.fileName);
|
||||
const dictPath = path.join(this.dictsPath, dict.name);
|
||||
const isDictFileValid = await this.isDictFileValid(dict);
|
||||
|
||||
if (isDictFileValid) {
|
||||
await decompresser.depress({
|
||||
filePath,
|
||||
hash: dict.hash,
|
||||
destPath: dictPath,
|
||||
id: `dict-${dict.fileName}`,
|
||||
});
|
||||
}
|
||||
|
||||
downloader.remove(dict.fileName);
|
||||
}
|
||||
|
||||
async remove(dict: Dict) {
|
||||
await fs.remove(path.join(this.dictsPath, dict.name));
|
||||
}
|
||||
|
||||
async lookup(word: string, dict: Dict) {
|
||||
if (this.currentDict !== dict.name) {
|
||||
this.db = new sqlite.Database(
|
||||
path.join(this.dictsPath, dict.name, `${dict.name}.sqlite`)
|
||||
);
|
||||
this.currentDict = dict.name;
|
||||
}
|
||||
|
||||
const result = await this.query(word);
|
||||
|
||||
return result ? `${dict.addition}${result}` : null;
|
||||
}
|
||||
|
||||
query(word: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(
|
||||
`SELECT definition FROM definitions WHERE id=(SELECT definition_id FROM words WHERE word="${word}")`,
|
||||
(err, row: any) => {
|
||||
if (err) reject(err);
|
||||
resolve(row?.definition ?? "");
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getDicts() {
|
||||
const dicts = DICTS.map((dict: Dict) => {
|
||||
let state: DictState = "uninstall";
|
||||
let downloadState;
|
||||
let decompressProgress;
|
||||
|
||||
const files = fs.readdirSync(this.dictsPath);
|
||||
const isInstalled = files.find((file) => file === dict.name);
|
||||
|
||||
const decompressTask = decompresser.tasks.find(
|
||||
(task) => task.id === `dict-${dict.fileName}`
|
||||
);
|
||||
|
||||
const downloadTask = downloader.tasks.find(
|
||||
(task) => task.getFilename() === dict.fileName
|
||||
);
|
||||
|
||||
if (decompressTask) {
|
||||
state = "decompressing";
|
||||
decompressProgress = decompressTask.progress;
|
||||
} else if (isInstalled) {
|
||||
state = "installed";
|
||||
} else if (downloadTask) {
|
||||
state = "downloading";
|
||||
downloadState = {
|
||||
name: downloadTask.getFilename(),
|
||||
state: downloadTask.getState(),
|
||||
isPaused: downloadTask.isPaused(),
|
||||
canResume: downloadTask.canResume(),
|
||||
total: downloadTask.getTotalBytes(),
|
||||
received: downloadTask.getReceivedBytes(),
|
||||
};
|
||||
}
|
||||
|
||||
return { ...dict, state, downloadState, decompressProgress };
|
||||
});
|
||||
|
||||
return dicts;
|
||||
}
|
||||
|
||||
getResource(key: string, dict: Dict) {
|
||||
const filePath = path.join(this.dictsPath, dict.name, key);
|
||||
const cachedValue = this.cache.get(filePath);
|
||||
if (cachedValue) return cachedValue;
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(filePath, { encoding: "base64" });
|
||||
this.cache.set(filePath, data);
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to read file ${filePath}`, err);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
registerIpcHandlers() {
|
||||
ipcMain.handle("dict-download", async (_event, dict: Dict) =>
|
||||
this.download(dict)
|
||||
);
|
||||
|
||||
ipcMain.handle("dict-decompress", async (_event, dict: Dict) =>
|
||||
this.decompress(dict)
|
||||
);
|
||||
|
||||
ipcMain.handle("dict-remove", async (_event, dict: Dict) =>
|
||||
this.remove(dict)
|
||||
);
|
||||
|
||||
ipcMain.handle("dict-list", async (_event) => this.getDicts());
|
||||
|
||||
ipcMain.handle("dict-read-file", async (_event, path: string, dict: Dict) =>
|
||||
this.getResource(path, dict)
|
||||
);
|
||||
|
||||
ipcMain.handle("dict-lookup", async (_event, word: string, dict: Dict) =>
|
||||
this.lookup(word, dict)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new DictHandler();
|
||||
@@ -20,8 +20,10 @@ class Downloader {
|
||||
}
|
||||
): Promise<string | undefined> {
|
||||
const { webContents = mainWin.win.webContents, savePath } = options || {};
|
||||
|
||||
return new Promise((resolve, _reject) => {
|
||||
webContents.downloadURL(url);
|
||||
|
||||
webContents.session.on("will-download", (_event, item, _webContents) => {
|
||||
if (savePath) {
|
||||
try {
|
||||
@@ -115,8 +117,27 @@ class Downloader {
|
||||
});
|
||||
}
|
||||
|
||||
pause(filename: string) {
|
||||
this.tasks
|
||||
.filter(
|
||||
(t) => t.getFilename() === filename && t.getState() === "progressing"
|
||||
)
|
||||
.forEach((t) => {
|
||||
t.pause();
|
||||
});
|
||||
}
|
||||
|
||||
resume(filename: string) {
|
||||
this.tasks
|
||||
.filter(
|
||||
(t) => t.getFilename() === filename && t.getState() === "progressing"
|
||||
)
|
||||
.forEach((t) => {
|
||||
t.resume();
|
||||
});
|
||||
}
|
||||
|
||||
cancel(filename: string) {
|
||||
logger.debug("dashboard", this.dashboard());
|
||||
this.tasks
|
||||
.filter(
|
||||
(t) => t.getFilename() === filename && t.getState() === "progressing"
|
||||
@@ -126,6 +147,11 @@ class Downloader {
|
||||
});
|
||||
}
|
||||
|
||||
remove(filename: string) {
|
||||
this.cancel(filename);
|
||||
this.tasks = this.tasks.filter((t) => t.getFilename() !== filename);
|
||||
}
|
||||
|
||||
cancelAll() {
|
||||
for (const task of this.tasks) {
|
||||
task.cancel();
|
||||
@@ -154,9 +180,17 @@ class Downloader {
|
||||
});
|
||||
});
|
||||
ipcMain.handle("download-cancel", (_event, filename) => {
|
||||
logger.debug("download-cancel", filename);
|
||||
this.cancel(filename);
|
||||
});
|
||||
ipcMain.handle("download-pause", (_event, filename) => {
|
||||
this.pause(filename);
|
||||
});
|
||||
ipcMain.handle("download-resume", (_event, filename) => {
|
||||
this.resume(filename);
|
||||
});
|
||||
ipcMain.handle("download-remove", (_event, filename) => {
|
||||
this.remove(filename);
|
||||
});
|
||||
ipcMain.handle("download-cancel-all", () => {
|
||||
this.cancelAll();
|
||||
});
|
||||
|
||||
@@ -182,6 +182,14 @@ export default {
|
||||
return settings.setSync("defaultHotkeys", records);
|
||||
});
|
||||
|
||||
ipcMain.handle("settings-get-dict", (_event) => {
|
||||
return settings.getSync("dicts");
|
||||
});
|
||||
|
||||
ipcMain.handle("settings-set-dicts", (_event, dict) => {
|
||||
return settings.setSync("dicts", dict);
|
||||
});
|
||||
|
||||
ipcMain.handle("settings-get-api-url", (_event) => {
|
||||
return settings.getSync("apiUrl");
|
||||
});
|
||||
@@ -189,6 +197,14 @@ export default {
|
||||
ipcMain.handle("settings-set-api-url", (_event, url) => {
|
||||
return settings.setSync("apiUrl", url);
|
||||
});
|
||||
|
||||
ipcMain.handle("settings-get-vocabulary-config", (_event) => {
|
||||
return settings.getSync("vocabularyConfig");
|
||||
});
|
||||
|
||||
ipcMain.handle("settings-set-vocabulary-config", (_event, records) => {
|
||||
return settings.setSync("vocabularyConfig", records);
|
||||
});
|
||||
},
|
||||
cachePath,
|
||||
libraryPath,
|
||||
|
||||
@@ -23,6 +23,8 @@ import { Waveform } from "./waveform";
|
||||
import url from "url";
|
||||
import echogarden from "./echogarden";
|
||||
import camdict from "./camdict";
|
||||
import dict from "./dict";
|
||||
import decompresser from "./decompresser";
|
||||
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -50,6 +52,7 @@ main.init = () => {
|
||||
db.registerIpcHandlers();
|
||||
|
||||
camdict.registerIpcHandlers();
|
||||
dict.registerIpcHandlers();
|
||||
|
||||
// Prepare Settings
|
||||
settings.registerIpcHandlers();
|
||||
@@ -66,6 +69,8 @@ main.init = () => {
|
||||
// Downloader
|
||||
downloader.registerIpcHandlers();
|
||||
|
||||
decompresser.registerIpcHandlers();
|
||||
|
||||
// ffmpeg
|
||||
ffmpeg.registerIpcHandlers();
|
||||
|
||||
|
||||
@@ -141,10 +141,16 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
onNotification: (
|
||||
callback: (event: IpcRendererEvent, notification: NotificationType) => void
|
||||
) => ipcRenderer.on("on-notification", callback),
|
||||
lookup: (
|
||||
selection: string,
|
||||
context: string,
|
||||
position: { x: number; y: number }
|
||||
) => ipcRenderer.emit("on-lookup", null, selection, context, position),
|
||||
onLookup: (
|
||||
callback: (
|
||||
event: IpcRendererEvent,
|
||||
selection: string,
|
||||
context: string,
|
||||
position: { x: number; y: number }
|
||||
) => void
|
||||
) => ipcRenderer.on("on-lookup", callback),
|
||||
@@ -158,6 +164,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
position: { x: number; y: number }
|
||||
) => void
|
||||
) => ipcRenderer.on("on-translate", callback),
|
||||
offTranslate: () => {
|
||||
ipcRenderer.removeAllListeners("on-translate");
|
||||
},
|
||||
shell: {
|
||||
openExternal: (url: string) =>
|
||||
ipcRenderer.invoke("shell-open-external", url),
|
||||
@@ -225,12 +234,24 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
setDefaultHotkeys: (records: Record<string, string>) => {
|
||||
return ipcRenderer.invoke("settings-set-default-hotkeys", records);
|
||||
},
|
||||
getDictSettings: () => {
|
||||
return ipcRenderer.invoke("settings-get-dict");
|
||||
},
|
||||
setDictSettings: (dict: DictSettingType) => {
|
||||
return ipcRenderer.invoke("settings-set-dicts", dict);
|
||||
},
|
||||
getApiUrl: () => {
|
||||
return ipcRenderer.invoke("settings-get-api-url");
|
||||
},
|
||||
setApiUrl: (url: string) => {
|
||||
return ipcRenderer.invoke("settings-set-api-url", url);
|
||||
},
|
||||
getVocabularyConfig: () => {
|
||||
return ipcRenderer.invoke("settings-get-vocabulary-config");
|
||||
},
|
||||
setVocabularyConfig: (records: Record<string, string>) => {
|
||||
return ipcRenderer.invoke("settings-set-vocabulary-config", records);
|
||||
},
|
||||
},
|
||||
path: {
|
||||
join: (...paths: string[]) => {
|
||||
@@ -258,6 +279,16 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
return ipcRenderer.invoke("camdict-lookup", word);
|
||||
},
|
||||
},
|
||||
dict: {
|
||||
getDicts: () => ipcRenderer.invoke("dict-list"),
|
||||
download: (dict: Dict) => ipcRenderer.invoke("dict-download", dict),
|
||||
decompress: (dict: Dict) => ipcRenderer.invoke("dict-decompress", dict),
|
||||
remove: (dict: Dict) => ipcRenderer.invoke("dict-remove", dict),
|
||||
getResource: (key: string, dict: Dict) =>
|
||||
ipcRenderer.invoke("dict-read-file", key, dict),
|
||||
lookup: (word: string, dict: Dict) =>
|
||||
ipcRenderer.invoke("dict-lookup", word, dict),
|
||||
},
|
||||
audios: {
|
||||
findAll: (params: {
|
||||
offset: number | undefined;
|
||||
@@ -526,28 +557,33 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
return ipcRenderer.invoke("ffmpeg-transcode", input, output, options);
|
||||
},
|
||||
},
|
||||
decompress: {
|
||||
onUpdate: (
|
||||
callback: (event: IpcRendererEvent, tasks: DecompressTask[]) => void
|
||||
) => ipcRenderer.on("decompress-tasks-update", callback),
|
||||
dashboard: () => ipcRenderer.invoke("decompress-tasks"),
|
||||
removeAllListeners: () =>
|
||||
ipcRenderer.removeAllListeners("decompress-tasks-update"),
|
||||
},
|
||||
download: {
|
||||
onState: (
|
||||
callback: (event: IpcRendererEvent, state: DownloadStateType) => void
|
||||
) => ipcRenderer.on("download-on-state", callback),
|
||||
start: (url: string, savePath?: string) => {
|
||||
return ipcRenderer.invoke("download-start", url, savePath);
|
||||
},
|
||||
printAsPdf: (content: string, savePath: string) => {
|
||||
return ipcRenderer.invoke("print-as-pdf", content, savePath);
|
||||
},
|
||||
cancel: (filename: string) => {
|
||||
ipcRenderer.invoke("download-cancel", filename);
|
||||
},
|
||||
cancelAll: () => {
|
||||
ipcRenderer.invoke("download-cancel-all");
|
||||
},
|
||||
dashboard: () => {
|
||||
return ipcRenderer.invoke("download-dashboard");
|
||||
},
|
||||
removeAllListeners: () => {
|
||||
ipcRenderer.removeAllListeners("download-on-error");
|
||||
},
|
||||
start: (url: string, savePath?: string) =>
|
||||
ipcRenderer.invoke("download-start", url, savePath),
|
||||
printAsPdf: (content: string, savePath: string) =>
|
||||
ipcRenderer.invoke("print-as-pdf", content, savePath),
|
||||
cancel: (filename: string) =>
|
||||
ipcRenderer.invoke("download-cancel", filename),
|
||||
pause: (filename: string) => ipcRenderer.invoke("download-pause", filename),
|
||||
remove: (filename: string) =>
|
||||
ipcRenderer.invoke("download-remove", filename),
|
||||
resume: (filename: string) =>
|
||||
ipcRenderer.invoke("download-resume", filename),
|
||||
cancelAll: () => ipcRenderer.invoke("download-cancel-all"),
|
||||
dashboard: () => ipcRenderer.invoke("download-dashboard"),
|
||||
removeAllListeners: () =>
|
||||
ipcRenderer.removeAllListeners("download-on-error"),
|
||||
},
|
||||
cacheObjects: {
|
||||
get: (key: string) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AppSettingsProvider,
|
||||
DbProvider,
|
||||
HotKeysSettingsProvider,
|
||||
DictProvider,
|
||||
} from "@renderer/context";
|
||||
import router from "./router";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
@@ -37,13 +38,15 @@ function App() {
|
||||
<AppSettingsProvider>
|
||||
<HotKeysSettingsProvider>
|
||||
<AISettingsProvider>
|
||||
<DbProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster richColors closeButton position="top-center" />
|
||||
<Tooltip id="global-tooltip" />
|
||||
<TranslateWidget />
|
||||
<LookupWidget />
|
||||
</DbProvider>
|
||||
<DictProvider>
|
||||
<DbProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster richColors closeButton position="top-center" />
|
||||
<Tooltip id="global-tooltip" />
|
||||
<TranslateWidget />
|
||||
<LookupWidget />
|
||||
</DbProvider>
|
||||
</DictProvider>
|
||||
</AISettingsProvider>
|
||||
</HotKeysSettingsProvider>
|
||||
</AppSettingsProvider>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "@renderer/context";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import { Button, toast } from "@renderer/components/ui";
|
||||
import { ConversationShortcuts } from "@renderer/components";
|
||||
import { ConversationShortcuts, Vocabulary } from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
BotIcon,
|
||||
@@ -544,7 +544,7 @@ export const Caption = (props: {
|
||||
}`}
|
||||
onClick={() => onClick && onClick(index)}
|
||||
>
|
||||
{word}
|
||||
<Vocabulary word={word} context={caption.text} />
|
||||
</div>
|
||||
|
||||
{displayIpa && (
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
ScrollArea,
|
||||
} from "@/renderer/components/ui";
|
||||
import { UninstallDictList } from ".";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const DictImportButton = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpen = (value: boolean) => {
|
||||
setOpen(value);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">{t("import")}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("importDict")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-72 pr-3">
|
||||
<UninstallDictList onDownload={handleDownload} />
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { t } from "i18next";
|
||||
import { DictImportButton } from "./dict-import-button";
|
||||
import { DownloadingDictList, InstalledDictList } from ".";
|
||||
|
||||
export const DictSettings = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between pt-4 ">
|
||||
<div className="mb-2">{t("dictionaries")}</div>
|
||||
<DictImportButton />
|
||||
</div>
|
||||
|
||||
<div className="my-4">
|
||||
<DownloadingDictList />
|
||||
<InstalledDictList />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
DictProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
} from "@/renderer/context";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Button, toast } from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { LoaderSpin } from "@renderer/components";
|
||||
|
||||
export const DownloadingDictList = function () {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { downloadingDicts, reload } = useContext(DictProviderContext);
|
||||
|
||||
useEffect(() => {
|
||||
listenToDownloadState();
|
||||
listenDecompressState();
|
||||
|
||||
return () => {
|
||||
EnjoyApp.download.removeAllListeners();
|
||||
EnjoyApp.decompress.removeAllListeners();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const listenToDownloadState = () => {
|
||||
EnjoyApp.download.onState((_event, state) => {
|
||||
reload();
|
||||
});
|
||||
};
|
||||
|
||||
const listenDecompressState = () => {
|
||||
EnjoyApp.decompress.onUpdate((_event, tasks) => {
|
||||
reload();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{downloadingDicts.map((item) => (
|
||||
<DownloadingDictItem key={item.name} dict={item} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DownloadingDictItem = function ({ dict }: { dict: Dict }) {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { reload } = useContext(DictProviderContext);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (dict.downloadState?.state === "completed") {
|
||||
EnjoyApp.dict.decompress(dict);
|
||||
}
|
||||
}, [dict]);
|
||||
|
||||
async function handlePause() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await EnjoyApp.download.pause(dict.downloadState.name);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleResume() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await EnjoyApp.download.resume(dict.downloadState.name);
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await EnjoyApp.download.remove(dict.downloadState.name);
|
||||
toast.success(t("dictRemoved"));
|
||||
reload();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function displaySize(bytes: number) {
|
||||
return Number((bytes / 1024 / 1024).toFixed(0)).toLocaleString() + "MB";
|
||||
}
|
||||
|
||||
function renderDownloadState() {
|
||||
const text =
|
||||
dict.downloadState.state === "cancelled"
|
||||
? t("cancelled")
|
||||
: dict.downloadState.state === "completed"
|
||||
? t("completedAndChecking")
|
||||
: dict.downloadState.state === "interrupted"
|
||||
? t("interrupted")
|
||||
: dict.downloadState.isPaused
|
||||
? t("paused")
|
||||
: t("downloadingDict");
|
||||
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="mr-2">{text}</span>
|
||||
<span className="">{displaySize(dict.downloadState.received)}</span>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="">{displaySize(dict.downloadState.total)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDecompressState() {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span>{t("decompressing")}</span>
|
||||
<span className="ml-2">{dict.decompressProgress ?? "0"}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderActions() {
|
||||
if (loading) return <LoaderSpin />;
|
||||
|
||||
if (
|
||||
dict.downloadState?.state === "progressing" &&
|
||||
!dict.downloadState?.isPaused
|
||||
) {
|
||||
return (
|
||||
<Button variant="secondary" size="sm" onClick={handlePause}>
|
||||
{t("pause")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
dict.downloadState?.state === "cancelled" ||
|
||||
dict.downloadState?.state === "interrupted" ||
|
||||
(dict.downloadState?.state === "progressing" &&
|
||||
dict.downloadState?.isPaused)
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{dict.downloadState.canResume && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mr-2"
|
||||
onClick={handleResume}
|
||||
>
|
||||
{t("resume")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="secondary" size="sm" onClick={handleRemove}>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={dict.name} className="flex items-center py-2">
|
||||
<div className="flex-grow">
|
||||
<div>{dict.title}</div>
|
||||
<div className="mt-1">
|
||||
{dict.state === "decompressing" && renderDecompressState()}
|
||||
{dict.downloadState && renderDownloadState()}
|
||||
</div>
|
||||
</div>
|
||||
{renderActions()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./dict-settings";
|
||||
export * from "./dict-import-button";
|
||||
export * from "./downloading-dict-list";
|
||||
export * from "./installed-dict-list";
|
||||
export * from "./uninstall-dict-list";
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
DictProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
} from "@/renderer/context";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
toast,
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const InstalledDictList = function () {
|
||||
const { installedDicts, downloadingDicts, reload } =
|
||||
useContext(DictProviderContext);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, []);
|
||||
|
||||
if (installedDicts.length === 0 && downloadingDicts.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">{t("dictEmpty")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{installedDicts.map((item) => (
|
||||
<InstalledDictItem key={item.name} dict={item} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InstalledDictItem = function ({ dict }: { dict: Dict }) {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { settings, setDefault, reload, remove, removed } =
|
||||
useContext(DictProviderContext);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.removing?.find((v) => v === dict.name)) {
|
||||
handleRemove();
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleSetDefault() {
|
||||
try {
|
||||
await setDefault(dict);
|
||||
toast.success(t("dictFileSetDefaultSuccess"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
setRemoving(true);
|
||||
|
||||
try {
|
||||
remove(dict);
|
||||
await EnjoyApp.dict.remove(dict);
|
||||
removed(dict);
|
||||
toast.success(t("dictRemoved"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
|
||||
setRemoving(false);
|
||||
reload();
|
||||
}
|
||||
|
||||
function renderActions() {
|
||||
if (removing) {
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">{t("removing")}</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (dict.state === "installed") {
|
||||
return (
|
||||
<div className="hidden group-hover:inline-flex ">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="text-destructive mr-2"
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("removeDictTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("removeDictDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-destructive mr-2"
|
||||
onClick={handleRemove}
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Button size="sm" variant="secondary" onClick={handleSetDefault}>
|
||||
{t("setDefault")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dict.name}
|
||||
className="flex justify-between items-center group cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center text-sm text-left h-8 hover:opacity-80">
|
||||
<span className="mr-2">{dict.title}</span>
|
||||
{settings.default === dict.name && (
|
||||
<span className="text-indigo bg-secondary text-xs py-1 px-2 rounded">
|
||||
{t("default")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderActions()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
DictProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
} from "@/renderer/context";
|
||||
import { useContext, useState } from "react";
|
||||
import { Button, toast } from "@renderer/components/ui";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const UninstallDictList = function ({
|
||||
onDownload,
|
||||
}: {
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
const { uninstallDicts } = useContext(DictProviderContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
{uninstallDicts.map((item) => (
|
||||
<UninstallDictItem
|
||||
key={item.name}
|
||||
dict={item}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const UninstallDictItem = function ({
|
||||
dict,
|
||||
onDownload,
|
||||
}: {
|
||||
dict: Dict;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { reload, removed } = useContext(DictProviderContext);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleDownload() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
removed(dict);
|
||||
await EnjoyApp.dict.download(dict);
|
||||
reload();
|
||||
onDownload();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={dict.name} className="flex items-center py-2">
|
||||
<div className="flex-grow">
|
||||
<div>{dict.title}</div>
|
||||
<div className="text-sm mt-1 text-muted-foreground">
|
||||
<span className="mr-2">{dict.lang}</span>
|
||||
<span>{dict.size}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
{loading && <LoaderIcon className="animate-spin w-4 mr-2" />}
|
||||
{t("download")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -30,4 +30,7 @@ export * from "./proxy-settings";
|
||||
export * from "./whisper-model-options";
|
||||
export * from "./network-state";
|
||||
|
||||
export * from "./recorder-settings";
|
||||
export * from "./recorder-settings";
|
||||
|
||||
export * from "./vocabulary-settings";
|
||||
export * from "./dict-settings";
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
LearningLanguageSettings,
|
||||
NetworkState,
|
||||
RecorderSettings,
|
||||
VocabularySettings,
|
||||
DictSettings,
|
||||
} from "@renderer/components";
|
||||
import { useState } from "react";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
@@ -46,6 +48,21 @@ export const Preferences = () => {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "dict",
|
||||
label: t("dictSettingsShort"),
|
||||
component: () => (
|
||||
<div className="pr-1">
|
||||
<div className="font-semibold mb-4 capitilized">
|
||||
{t("dictSettings")}
|
||||
</div>
|
||||
<VocabularySettings />
|
||||
<Separator />
|
||||
<DictSettings />
|
||||
<Separator />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "advanced",
|
||||
label: t("advancedSettingsShort"),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { t } from "i18next";
|
||||
import { Switch } from "@renderer/components/ui";
|
||||
import { useContext } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
|
||||
export const VocabularySettings = () => {
|
||||
const { vocabularyConfig, setVocabularyConfig } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between py-4">
|
||||
<div className="">
|
||||
<div className="mb-2">{t("lookupOnMouseOver")}</div>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<Switch
|
||||
checked={vocabularyConfig.lookupOnMouseOver}
|
||||
onCheckedChange={() => {
|
||||
setVocabularyConfig({
|
||||
...vocabularyConfig,
|
||||
lookupOnMouseOver: !vocabularyConfig.lookupOnMouseOver,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground hover:outline-none hover:ring-1 hover:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from "./lookup-widget";
|
||||
export * from "./lookup";
|
||||
export * from "./translate-widget";
|
||||
export * from "./vocabulary";
|
||||
export * from "./lookup/dict-lookup-result";
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
import { useEffect, useContext, useState } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
ScrollArea,
|
||||
Separator,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { useAiCommand, useCamdict } from "@renderer/hooks";
|
||||
import { LoaderIcon, Volume2Icon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { md5 } from "js-md5";
|
||||
|
||||
export const LookupWidget = () => {
|
||||
const { EnjoyApp, learningLanguage } = useContext(AppSettingsProviderContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<{
|
||||
word: string;
|
||||
context?: string;
|
||||
sourceType?: string;
|
||||
sourceId?: string;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}>();
|
||||
|
||||
const handleSelectionChanged = (position: { x: number; y: number }) => {
|
||||
const selection = document.getSelection();
|
||||
if (!selection?.anchorNode?.parentElement) return;
|
||||
|
||||
const word = selection
|
||||
.toString()
|
||||
.trim()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]+$/, "");
|
||||
if (!word) return;
|
||||
// can only lookup single word
|
||||
if (word.indexOf(" ") > -1) return;
|
||||
|
||||
const context = selection.anchorNode.parentElement
|
||||
.closest(".sentence, h2, p, div")
|
||||
?.textContent?.trim();
|
||||
|
||||
const sourceType = selection.anchorNode.parentElement
|
||||
.closest("[data-source-type]")
|
||||
?.getAttribute("data-source-type");
|
||||
const sourceId = selection.anchorNode.parentElement
|
||||
.closest("[data-source-id]")
|
||||
?.getAttribute("data-source-id");
|
||||
|
||||
setSelected({
|
||||
word,
|
||||
context,
|
||||
position: {
|
||||
x: position.x,
|
||||
y: position.y + window.scrollY + 10,
|
||||
},
|
||||
sourceType,
|
||||
sourceId,
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.onLookup((_event, _selection, position) => {
|
||||
handleSelectionChanged(position);
|
||||
});
|
||||
|
||||
return () => EnjoyApp.offLookup();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverAnchor
|
||||
className="absolute w-0 h-0"
|
||||
style={{
|
||||
top: selected?.position?.y,
|
||||
left: selected?.position?.x,
|
||||
}}
|
||||
></PopoverAnchor>
|
||||
<PopoverContent
|
||||
className="w-full p-0 z-50"
|
||||
updatePositionStrategy="always"
|
||||
>
|
||||
{selected?.word && (
|
||||
<ScrollArea className="py-2 w-96 h-96 relative">
|
||||
<div className="px-4 pb-2 mb-2 font-bold text-lg sticky top-0 bg-background border-b">
|
||||
{selected?.word}
|
||||
</div>
|
||||
<div className="px-4">
|
||||
{learningLanguage.startsWith("en") && (
|
||||
<>
|
||||
<CamdictLookupResult word={selected?.word} />
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
<AiLookupResult
|
||||
word={selected?.word}
|
||||
context={selected?.context}
|
||||
sourceId={selected?.sourceId}
|
||||
sourceType={selected?.sourceType}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export const AiLookupResult = (props: {
|
||||
word: string;
|
||||
context?: string;
|
||||
sourceType?: string;
|
||||
sourceId?: string;
|
||||
}) => {
|
||||
const { word, context = "", sourceType, sourceId } = props;
|
||||
const { webApi, EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const [lookingUp, setLookingUp] = useState<boolean>(false);
|
||||
const [result, setResult] = useState<LookupType>();
|
||||
const { lookupWord } = useAiCommand();
|
||||
|
||||
const handleLookup = async (options?: { force: boolean }) => {
|
||||
if (lookingUp) return;
|
||||
if (!word) return;
|
||||
const { force = false } = options || {};
|
||||
|
||||
setLookingUp(true);
|
||||
lookupWord({
|
||||
word,
|
||||
context,
|
||||
sourceId,
|
||||
sourceType,
|
||||
cacheKey: `lookup-${md5(`${word}-${context}`)}`,
|
||||
force,
|
||||
})
|
||||
.then((lookup) => {
|
||||
if (lookup?.meaning) {
|
||||
setResult(lookup);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setLookingUp(false);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchCachedLookup = async () => {
|
||||
const remoteLookup = await webApi.lookup({
|
||||
word,
|
||||
context,
|
||||
sourceId,
|
||||
sourceType,
|
||||
});
|
||||
if (remoteLookup?.meaning) {
|
||||
setResult(remoteLookup);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = await EnjoyApp.cacheObjects.get(
|
||||
`lookup-${md5(`${word}-${context}`)}`
|
||||
);
|
||||
if (cached?.meaning) {
|
||||
setResult(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setResult(undefined);
|
||||
};
|
||||
|
||||
/*
|
||||
* Fetch cached lookup result.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!word) return;
|
||||
|
||||
fetchCachedLookup();
|
||||
}, [word, context]);
|
||||
|
||||
if (!word) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm italic text-muted-foreground mb-2">
|
||||
{t("aiDictionary")}
|
||||
</div>
|
||||
{result ? (
|
||||
<>
|
||||
<div className="mb-4 select-text">
|
||||
<div className="mb-2 font-semibord font-serif">{word}</div>
|
||||
<div className="mb-2">
|
||||
{result.meaning?.pos && (
|
||||
<span className="italic text-sm text-muted-foreground mr-2">
|
||||
{result.meaning.pos}
|
||||
</span>
|
||||
)}
|
||||
{result.meaning?.pronunciation && (
|
||||
<span className="text-sm font-code mr-2">
|
||||
/{result.meaning.pronunciation.replaceAll("/", "")}/
|
||||
</span>
|
||||
)}
|
||||
{result.meaning?.lemma &&
|
||||
result.meaning.lemma !== result.meaning.word && (
|
||||
<span className="text-sm">({result.meaning.lemma})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-serif">{result.meaning.translation}</div>
|
||||
<div className="text-serif">{result.meaning.definition}</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={lookingUp}
|
||||
onClick={() => handleLookup({ force: true })}
|
||||
asChild
|
||||
>
|
||||
<a>
|
||||
{lookingUp && (
|
||||
<LoaderIcon className="animate-spin w-4 h-4 mr-2" />
|
||||
)}
|
||||
{t("reLookup")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 py-2">
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
size="sm"
|
||||
asChild
|
||||
onClick={() => handleLookup()}
|
||||
>
|
||||
<a>
|
||||
{lookingUp && (
|
||||
<LoaderIcon className="animate-spin w-4 h-4 mr-2" />
|
||||
)}
|
||||
<span>{t("aiLookup")}</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const CamdictLookupResult = (props: { word: string }) => {
|
||||
const { word } = props;
|
||||
const { result } = useCamdict(word);
|
||||
|
||||
if (!word) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm italic text-muted-foreground mb-2">
|
||||
{t("cambridgeDictionary")}
|
||||
</div>
|
||||
{result ? (
|
||||
<div className="select-text">
|
||||
<div className="mb-2 font-semibord font-serif">{word}</div>
|
||||
{result.posItems.map((posItem, index) => (
|
||||
<div key={index} className="mb-4">
|
||||
<div className="flex items-center space-x-4 mb-2 flex-wrap">
|
||||
<div className="italic text-sm text-muted-foreground">
|
||||
{posItem.type}
|
||||
</div>
|
||||
|
||||
{posItem.pronunciations.map((pron, i) => (
|
||||
<div
|
||||
key={`pron-${i}`}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<span className="uppercase text-xs font-serif text-muted-foreground">
|
||||
[{pron.region}]
|
||||
</span>
|
||||
<span className="text-sm font-code">
|
||||
/{pron.pronunciation}/
|
||||
</span>
|
||||
{pron.audio && pron.audio.match(/\.mp3/i) && (
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full p-0 w-6 h-6"
|
||||
onClick={() => {
|
||||
const audio = new Audio(pron.audio);
|
||||
audio.play();
|
||||
}}
|
||||
>
|
||||
<Volume2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ul className="list-disc pl-4">
|
||||
{posItem.definitions.map((def, i) => (
|
||||
<li key={`pos-${i}`} className="">
|
||||
{def.definition}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm font-serif text-muted-foreground py-2 text-center">
|
||||
- {t("noResultsFound")} -
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useContext, useState } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { Button, toast } from "@renderer/components/ui";
|
||||
import { useAiCommand } from "@renderer/hooks";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { md5 } from "js-md5";
|
||||
|
||||
export const AiLookupResult = (props: {
|
||||
word: string;
|
||||
context?: string;
|
||||
sourceType?: string;
|
||||
sourceId?: string;
|
||||
}) => {
|
||||
const { word, context = "", sourceType, sourceId } = props;
|
||||
const { webApi, EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const [lookingUp, setLookingUp] = useState<boolean>(false);
|
||||
const [result, setResult] = useState<LookupType>();
|
||||
const { lookupWord } = useAiCommand();
|
||||
|
||||
const handleLookup = async (options?: { force: boolean }) => {
|
||||
if (lookingUp) return;
|
||||
if (!word) return;
|
||||
const { force = false } = options || {};
|
||||
|
||||
setLookingUp(true);
|
||||
lookupWord({
|
||||
word,
|
||||
context,
|
||||
sourceId,
|
||||
sourceType,
|
||||
cacheKey: `lookup-${md5(`${word}-${context}`)}`,
|
||||
force,
|
||||
})
|
||||
.then((lookup) => {
|
||||
if (lookup?.meaning) {
|
||||
setResult(lookup);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setLookingUp(false);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchCachedLookup = async () => {
|
||||
const remoteLookup = await webApi.lookup({
|
||||
word,
|
||||
context,
|
||||
sourceId,
|
||||
sourceType,
|
||||
});
|
||||
if (remoteLookup?.meaning) {
|
||||
setResult(remoteLookup);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = await EnjoyApp.cacheObjects.get(
|
||||
`lookup-${md5(`${word}-${context}`)}`
|
||||
);
|
||||
if (cached?.meaning) {
|
||||
setResult(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setResult(undefined);
|
||||
};
|
||||
|
||||
/*
|
||||
* Fetch cached lookup result.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!word || !context) return;
|
||||
|
||||
fetchCachedLookup();
|
||||
}, [word, context]);
|
||||
|
||||
if (!word) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{result ? (
|
||||
<>
|
||||
<div className="mb-4 select-text">
|
||||
<div className="mb-2 font-semibord font-serif">{word}</div>
|
||||
<div className="mb-2">
|
||||
{result.meaning?.pos && (
|
||||
<span className="italic text-sm text-muted-foreground mr-2">
|
||||
{result.meaning.pos}
|
||||
</span>
|
||||
)}
|
||||
{result.meaning?.pronunciation && (
|
||||
<span className="text-sm font-code mr-2">
|
||||
/{result.meaning.pronunciation.replaceAll("/", "")}/
|
||||
</span>
|
||||
)}
|
||||
{result.meaning?.lemma &&
|
||||
result.meaning.lemma !== result.meaning.word && (
|
||||
<span className="text-sm">({result.meaning.lemma})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-serif">{result.meaning.translation}</div>
|
||||
<div className="text-serif">{result.meaning.definition}</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={lookingUp}
|
||||
onClick={() => handleLookup({ force: true })}
|
||||
asChild
|
||||
>
|
||||
<a>
|
||||
{lookingUp && (
|
||||
<LoaderIcon className="animate-spin w-4 h-4 mr-2" />
|
||||
)}
|
||||
{t("reLookup")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 py-2">
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
size="sm"
|
||||
asChild
|
||||
onClick={() => handleLookup()}
|
||||
>
|
||||
<a>
|
||||
{lookingUp && (
|
||||
<LoaderIcon className="animate-spin w-4 h-4 mr-2" />
|
||||
)}
|
||||
<span>{t("aiLookup")}</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Button } from "@renderer/components/ui";
|
||||
import { useCamdict } from "@renderer/hooks";
|
||||
import { Volume2Icon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const CamdictLookupResult = (props: { word: string }) => {
|
||||
const { word } = props;
|
||||
const { result } = useCamdict(word);
|
||||
|
||||
if (!word) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{result ? (
|
||||
<div className="select-text">
|
||||
<div className="mb-2 font-semibord font-serif">{word}</div>
|
||||
{result.posItems.map((posItem, index) => (
|
||||
<div key={index} className="mb-4">
|
||||
<div className="flex items-center space-x-4 mb-2 flex-wrap">
|
||||
<div className="italic text-sm text-muted-foreground">
|
||||
{posItem.type}
|
||||
</div>
|
||||
|
||||
{posItem.pronunciations.map((pron, i) => (
|
||||
<div
|
||||
key={`pron-${i}`}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<span className="uppercase text-xs font-serif text-muted-foreground">
|
||||
[{pron.region}]
|
||||
</span>
|
||||
<span className="text-sm font-code">
|
||||
/{pron.pronunciation}/
|
||||
</span>
|
||||
{pron.audio && pron.audio.match(/\.mp3/i) && (
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full p-0 w-6 h-6"
|
||||
onClick={() => {
|
||||
const audio = new Audio(pron.audio);
|
||||
audio.play();
|
||||
}}
|
||||
>
|
||||
<Volume2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ul className="list-disc pl-4">
|
||||
{posItem.definitions.map((def, i) => (
|
||||
<li key={`pos-${i}`} className="">
|
||||
{def.definition}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm font-serif text-muted-foreground py-2 text-center">
|
||||
- {t("noResultsFound")} -
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useState, useContext } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DictProviderContext,
|
||||
ThemeProviderContext,
|
||||
} from "@/renderer/context";
|
||||
import Frame, { useFrame } from "react-frame-component";
|
||||
import { getExtension } from "@/utils";
|
||||
import { DictDefinitionNormalizer } from "@renderer/lib/dict";
|
||||
import { LoaderSpin } from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
css: "text/css",
|
||||
img: "image",
|
||||
jpg: "image/jpeg",
|
||||
png: "image/png",
|
||||
spx: "audio/x-speex",
|
||||
wav: "audio/wav",
|
||||
mp3: "audio/mp3",
|
||||
js: "text/javascript",
|
||||
};
|
||||
|
||||
export function DictLookupResult({
|
||||
word,
|
||||
onJump,
|
||||
}: {
|
||||
word: string;
|
||||
onJump: (v: string) => void;
|
||||
}) {
|
||||
const { colorScheme } = useContext(ThemeProviderContext);
|
||||
const initialContent = `<!DOCTYPE html><html class=${colorScheme}><head></head><body></body></html>`;
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { currentDict } = useContext(DictProviderContext);
|
||||
const [definition, setDefinition] = useState("");
|
||||
const [looking, setLooking] = useState(false);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentDict && word) {
|
||||
lookup();
|
||||
}
|
||||
}, [currentDict, word]);
|
||||
|
||||
async function lookup() {
|
||||
revoke();
|
||||
setLooking(true);
|
||||
|
||||
const _word = word.trim().indexOf(" ") > -1 ? word : word.toLowerCase();
|
||||
|
||||
EnjoyApp.dict
|
||||
.lookup(_word, currentDict)
|
||||
.then((result) => {
|
||||
if (!result) {
|
||||
setNotFound(true);
|
||||
} else {
|
||||
setDefinition(result);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
setLooking(false);
|
||||
});
|
||||
}
|
||||
|
||||
function revoke() {
|
||||
setNotFound(false);
|
||||
setLooking(false);
|
||||
setError(false);
|
||||
}
|
||||
|
||||
if (looking) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<LoaderSpin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-sm font-serif text-destructive py-2 text-center">
|
||||
- {"Lookup Error"} -
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="text-sm font-serif text-muted-foreground py-2 text-center">
|
||||
- {t("noResultsFound")} -
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame
|
||||
initialContent={initialContent}
|
||||
mountTarget="body"
|
||||
className="h-full"
|
||||
>
|
||||
<DictLookupResultInner text={definition} onJump={onJump} />
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
export const DictLookupResultInner = ({
|
||||
text,
|
||||
onJump,
|
||||
}: {
|
||||
text: string;
|
||||
onJump: (v: string) => void;
|
||||
}) => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { currentDict } = useContext(DictProviderContext);
|
||||
const { document: innerDocument } = useFrame();
|
||||
const [html, setHtml] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
normalize();
|
||||
|
||||
return () => {
|
||||
normalizer.revoke();
|
||||
};
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
registerAudioHandler();
|
||||
registerJumpHandler();
|
||||
}, [html]);
|
||||
|
||||
const handleInjectScript = (url: string) => {
|
||||
const tag = innerDocument.createElement("script");
|
||||
tag.src = url;
|
||||
|
||||
innerDocument.body.appendChild(tag);
|
||||
innerDocument.body.removeChild(tag);
|
||||
};
|
||||
|
||||
const handleReadResource = async (key: string) => {
|
||||
return EnjoyApp.dict.getResource(key, currentDict);
|
||||
};
|
||||
|
||||
const normalizer = new DictDefinitionNormalizer({
|
||||
onInjectScript: handleInjectScript,
|
||||
onReadResource: handleReadResource,
|
||||
});
|
||||
|
||||
async function normalize() {
|
||||
const html = await normalizer.normalize(text);
|
||||
setHtml(html);
|
||||
}
|
||||
|
||||
async function handlePlayAudio(audio: Element) {
|
||||
const href = audio.getAttribute("data-source");
|
||||
const data = await handleReadResource(href);
|
||||
const ext: string = getExtension(href, "wav");
|
||||
const url = await normalizer.createUrl(MIME[ext] || "audio", data);
|
||||
const _audio = new Audio(url);
|
||||
|
||||
_audio.play();
|
||||
}
|
||||
|
||||
function handleJump(el: Element) {
|
||||
const word = el.getAttribute("data-word");
|
||||
onJump(word);
|
||||
}
|
||||
|
||||
const registerAudioHandler = () => {
|
||||
const audios = innerDocument?.querySelectorAll("[data-type='audio']");
|
||||
if (!audios.length) return;
|
||||
|
||||
audios.forEach((audio: Element) => {
|
||||
audio.addEventListener("click", () => handlePlayAudio(audio));
|
||||
});
|
||||
|
||||
return () => {
|
||||
audios.forEach((audio: Element) => {
|
||||
audio.removeEventListener("click", () => handlePlayAudio(audio));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const registerJumpHandler = () => {
|
||||
const links = innerDocument?.querySelectorAll("[data-type='jump']");
|
||||
if (!links.length) return;
|
||||
|
||||
links.forEach((el: Element) => {
|
||||
el.addEventListener("click", () => handleJump(el));
|
||||
});
|
||||
|
||||
return () => {
|
||||
links.forEach((el: Element) => {
|
||||
el.removeEventListener("click", () => handleJump(el));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }}></div>;
|
||||
};
|
||||
32
enjoy/src/renderer/components/widgets/lookup/dict-select.tsx
Normal file
32
enjoy/src/renderer/components/widgets/lookup/dict-select.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useContext } from "react";
|
||||
import { DictProviderContext } from "@renderer/context";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@renderer/components/ui";
|
||||
|
||||
export const DictSelect = () => {
|
||||
const { currentDictValue, dictSelectItems, handleSetCurrentDict } =
|
||||
useContext(DictProviderContext);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={currentDictValue}
|
||||
onValueChange={(value: string) => handleSetCurrentDict(value)}
|
||||
>
|
||||
<SelectTrigger className="text-sm italic text-muted-foreground h-8">
|
||||
<SelectValue title="asdf"></SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dictSelectItems.map((item) => (
|
||||
<SelectItem value={item.value} key={item.value} className="text-xs">
|
||||
{item.text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
5
enjoy/src/renderer/components/widgets/lookup/index.tsx
Normal file
5
enjoy/src/renderer/components/widgets/lookup/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./ai-lookup-result";
|
||||
export * from "./camdict-lookup-result";
|
||||
export * from "./dict-lookup-result";
|
||||
export * from "./dict-select";
|
||||
export * from "./lookup-widget";
|
||||
165
enjoy/src/renderer/components/widgets/lookup/lookup-widget.tsx
Normal file
165
enjoy/src/renderer/components/widgets/lookup/lookup-widget.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useContext, useState } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DictProviderContext,
|
||||
} from "@renderer/context";
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
ScrollArea,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
DictLookupResult,
|
||||
DictSelect,
|
||||
AiLookupResult,
|
||||
} from "@renderer/components";
|
||||
import { ChevronLeft, ChevronFirst } from "lucide-react";
|
||||
|
||||
export const LookupWidget = () => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { currentDictValue } = useContext(DictProviderContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<{
|
||||
word: string;
|
||||
context?: string;
|
||||
sourceType?: string;
|
||||
sourceId?: string;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}>();
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [current, setCurrent] = useState("");
|
||||
|
||||
const handleSelectionChanged = (
|
||||
_word: string,
|
||||
_context: string,
|
||||
position: { x: number; y: number }
|
||||
) => {
|
||||
let word = _word;
|
||||
let context = _context;
|
||||
|
||||
if (word) {
|
||||
if (word.indexOf(" ") > -1) return;
|
||||
setSelected({ word, context, position });
|
||||
} else {
|
||||
const selection = document.getSelection();
|
||||
if (!selection?.anchorNode?.parentElement) return;
|
||||
|
||||
word = selection
|
||||
.toString()
|
||||
.trim()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]+$/, "");
|
||||
|
||||
if (!word) return;
|
||||
// can only lookup single word
|
||||
if (word.indexOf(" ") > -1) return;
|
||||
|
||||
context = selection?.anchorNode.parentElement
|
||||
.closest(".sentence, h2, p, div")
|
||||
?.textContent?.trim();
|
||||
|
||||
const sourceType = selection?.anchorNode.parentElement
|
||||
.closest("[data-source-type]")
|
||||
?.getAttribute("data-source-type");
|
||||
const sourceId = selection?.anchorNode.parentElement
|
||||
.closest("[data-source-id]")
|
||||
?.getAttribute("data-source-id");
|
||||
|
||||
setSelected({ word, context, position, sourceType, sourceId });
|
||||
}
|
||||
|
||||
handleLookup(word);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.onLookup((_event, selection, context, position) => {
|
||||
handleSelectionChanged(selection, context, position);
|
||||
});
|
||||
|
||||
return () => EnjoyApp.offLookup();
|
||||
}, []);
|
||||
|
||||
function handleLookup(word: string) {
|
||||
setCurrent(word);
|
||||
setHistory([...history, word]);
|
||||
}
|
||||
|
||||
function handleViewFirst() {
|
||||
setCurrent(history[0]);
|
||||
setHistory(history.slice(0, 1));
|
||||
}
|
||||
|
||||
function handleViewLast() {
|
||||
setCurrent(history[history.length - 2]);
|
||||
setHistory(history.slice(0, -1));
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverAnchor
|
||||
className="absolute w-0 h-0"
|
||||
style={{
|
||||
top: selected?.position?.y,
|
||||
left: selected?.position?.x,
|
||||
}}
|
||||
></PopoverAnchor>
|
||||
<PopoverContent
|
||||
className="w-full p-0 z-50"
|
||||
updatePositionStrategy="always"
|
||||
>
|
||||
{selected?.word && (
|
||||
<ScrollArea>
|
||||
<div className="w-96 h-96 flex flex-col">
|
||||
<div className="p-2 border-b flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
{history.length > 1 && (
|
||||
<div className="mr-1 flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-6 h-6 p-0"
|
||||
onClick={handleViewFirst}
|
||||
>
|
||||
<ChevronFirst />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-6 h-6 p-0"
|
||||
onClick={handleViewLast}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="font-bold">{current}</div>
|
||||
</div>
|
||||
|
||||
<div className="w-40">
|
||||
<DictSelect />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 pr-1 flex-1">
|
||||
{currentDictValue === "ai" ? (
|
||||
<AiLookupResult
|
||||
word={selected?.word}
|
||||
context={selected?.context}
|
||||
sourceId={selected?.sourceId}
|
||||
sourceType={selected?.sourceType}
|
||||
/>
|
||||
) : (
|
||||
<DictLookupResult word={current} onJump={handleLookup} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
38
enjoy/src/renderer/components/widgets/vocabulary.tsx
Normal file
38
enjoy/src/renderer/components/widgets/vocabulary.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { useContext } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
|
||||
export const Vocabulary = ({
|
||||
word,
|
||||
context,
|
||||
children,
|
||||
}: {
|
||||
word: string;
|
||||
context?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const { vocabularyConfig, EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent) => {
|
||||
timeout = setTimeout(() => {
|
||||
EnjoyApp.lookup(word, context, { x: e.clientX, y: e.clientY });
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
|
||||
return vocabularyConfig.lookupOnMouseOver ? (
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span>{word || children}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>{word || children}</span>
|
||||
);
|
||||
};
|
||||
@@ -27,6 +27,8 @@ type AppSettingsProviderState = {
|
||||
switchLearningLanguage?: (lang: string) => void;
|
||||
proxy?: ProxyConfigType;
|
||||
setProxy?: (config: ProxyConfigType) => Promise<void>;
|
||||
vocabularyConfig?: VocabularyConfigType;
|
||||
setVocabularyConfig?: (config: VocabularyConfigType) => Promise<void>;
|
||||
cable?: Consumer;
|
||||
ahoy?: typeof ahoy;
|
||||
recorderConfig?: RecorderConfigType;
|
||||
@@ -58,6 +60,8 @@ export const AppSettingsProvider = ({
|
||||
const [language, setLanguage] = useState<"en" | "zh-CN">();
|
||||
const [nativeLanguage, setNativeLanguage] = useState<string>("zh-CN");
|
||||
const [learningLanguage, setLearningLanguage] = useState<string>("en-US");
|
||||
const [vocabularyConfig, setVocabularyConfig] =
|
||||
useState<VocabularyConfigType>(null);
|
||||
const [proxy, setProxy] = useState<ProxyConfigType>();
|
||||
const EnjoyApp = window.__ENJOY_APP__;
|
||||
const [recorderConfig, setRecorderConfig] = useState<RecorderConfigType>();
|
||||
@@ -202,12 +206,23 @@ export const AppSettingsProvider = ({
|
||||
});
|
||||
};
|
||||
|
||||
const fetchVocabularyConfig = async () => {
|
||||
const config = await EnjoyApp.settings.getVocabularyConfig();
|
||||
config && setVocabularyConfig(config);
|
||||
};
|
||||
|
||||
const setVocabularyConfigHandler = async (config: VocabularyConfigType) => {
|
||||
await EnjoyApp.settings.setVocabularyConfig(config);
|
||||
setVocabularyConfig(config);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchVersion();
|
||||
fetchUser();
|
||||
fetchLibraryPath();
|
||||
fetchLanguages();
|
||||
fetchProxyConfig();
|
||||
fetchVocabularyConfig();
|
||||
initSentry();
|
||||
fetchRecorderConfig();
|
||||
}, []);
|
||||
@@ -261,6 +276,8 @@ export const AppSettingsProvider = ({
|
||||
setLibraryPath: setLibraryPathHandler,
|
||||
proxy,
|
||||
setProxy: setProxyConfigHandler,
|
||||
vocabularyConfig,
|
||||
setVocabularyConfig: setVocabularyConfigHandler,
|
||||
initialized: Boolean(user && libraryPath),
|
||||
ahoy,
|
||||
cable,
|
||||
|
||||
176
enjoy/src/renderer/context/dict-provider.tsx
Normal file
176
enjoy/src/renderer/context/dict-provider.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { createContext, useState, useEffect, useContext, useMemo } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
|
||||
type DictProviderState = {
|
||||
settings: DictSettingType;
|
||||
dicts: Dict[];
|
||||
downloadingDicts: Dict[];
|
||||
uninstallDicts: Dict[];
|
||||
installedDicts: Dict[];
|
||||
dictSelectItems: { text: string; value: string }[];
|
||||
reload?: () => void;
|
||||
remove?: (v: Dict) => void;
|
||||
removed?: (v: Dict) => void;
|
||||
currentDict?: Dict | null;
|
||||
currentDictValue?: string;
|
||||
handleSetCurrentDict?: (v: string) => void;
|
||||
setDefault?: (v: Dict) => Promise<void>;
|
||||
};
|
||||
|
||||
const AIDict = {
|
||||
text: t("aiLookup"),
|
||||
value: "ai",
|
||||
};
|
||||
|
||||
const initialState: DictProviderState = {
|
||||
dicts: [],
|
||||
downloadingDicts: [],
|
||||
uninstallDicts: [],
|
||||
installedDicts: [],
|
||||
dictSelectItems: [AIDict],
|
||||
settings: {
|
||||
default: "",
|
||||
removing: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const DictProviderContext =
|
||||
createContext<DictProviderState>(initialState);
|
||||
|
||||
export const DictProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [dicts, setDicts] = useState<Dict[]>([]);
|
||||
const [settings, setSettings] = useState<DictSettingType>({
|
||||
default: "",
|
||||
removing: [],
|
||||
});
|
||||
const [currentDictValue, setCurrentDictValue] = useState<string>("");
|
||||
const [currentDict, setCurrentDict] = useState<Dict | null>();
|
||||
|
||||
const availableDicts = useMemo(
|
||||
() =>
|
||||
dicts.filter((dict) => {
|
||||
return (
|
||||
dict.state === "installed" &&
|
||||
!settings.removing?.find((v) => v === dict.name)
|
||||
);
|
||||
}),
|
||||
[dicts, settings]
|
||||
);
|
||||
|
||||
const dictSelectItems = useMemo(() => {
|
||||
return [
|
||||
AIDict,
|
||||
...availableDicts.map((item) => ({
|
||||
text: item.title,
|
||||
value: item.name,
|
||||
})),
|
||||
];
|
||||
}, [availableDicts]);
|
||||
|
||||
const downloadingDicts = useMemo(() => {
|
||||
return dicts.filter(
|
||||
(dict) => dict.state === "downloading" || dict.state === "decompressing"
|
||||
);
|
||||
}, [dicts]);
|
||||
|
||||
const uninstallDicts = useMemo(() => {
|
||||
return dicts.filter((dict) => dict.state === "uninstall");
|
||||
}, [dicts]);
|
||||
|
||||
const installedDicts = useMemo(() => {
|
||||
return dicts.filter((dict) => dict.state === "installed");
|
||||
}, [dicts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (availableDicts.length) {
|
||||
const _currentDict = availableDicts.find(
|
||||
(dict) => dict.name === settings.default
|
||||
);
|
||||
|
||||
if (_currentDict) {
|
||||
handleSetCurrentDict(_currentDict.name);
|
||||
} else {
|
||||
setDefault(availableDicts[0]);
|
||||
}
|
||||
} else {
|
||||
setCurrentDictValue(AIDict.value);
|
||||
}
|
||||
}, [availableDicts, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
fetchDicts();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
return EnjoyApp.settings.getDictSettings().then((res) => {
|
||||
res && setSettings(res);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchDicts = async () => {
|
||||
return EnjoyApp.dict.getDicts().then((dicts) => {
|
||||
setDicts(dicts);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetCurrentDict = (value: string) => {
|
||||
setCurrentDictValue(value);
|
||||
|
||||
const dict = dicts.find((dict) => dict.name === value);
|
||||
if (dict) setCurrentDict(dict);
|
||||
};
|
||||
|
||||
const setDefault = async (dict: Dict) => {
|
||||
const _settings = { ...settings, default: dict?.name ?? "" };
|
||||
|
||||
EnjoyApp.settings
|
||||
.setDictSettings(_settings)
|
||||
.then(() => setSettings(_settings));
|
||||
};
|
||||
|
||||
const remove = (dict: Dict) => {
|
||||
if (!settings.removing?.find((name) => dict.name === name)) {
|
||||
const removing = [...(settings.removing ?? []), dict.name];
|
||||
const _settings = { ...settings, removing };
|
||||
|
||||
EnjoyApp.settings
|
||||
.setDictSettings(_settings)
|
||||
.then(() => setSettings(_settings));
|
||||
}
|
||||
};
|
||||
|
||||
const removed = (dict: Dict) => {
|
||||
const removing =
|
||||
settings.removing?.filter((name) => name !== dict.name) ?? [];
|
||||
const _settings = { ...settings, removing };
|
||||
|
||||
EnjoyApp.settings
|
||||
.setDictSettings(_settings)
|
||||
.then(() => setSettings(_settings));
|
||||
};
|
||||
|
||||
return (
|
||||
<DictProviderContext.Provider
|
||||
value={{
|
||||
settings,
|
||||
dicts,
|
||||
remove,
|
||||
removed,
|
||||
reload: fetchDicts,
|
||||
dictSelectItems,
|
||||
downloadingDicts,
|
||||
uninstallDicts,
|
||||
installedDicts,
|
||||
currentDict,
|
||||
currentDictValue,
|
||||
handleSetCurrentDict,
|
||||
setDefault,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DictProviderContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -7,3 +7,4 @@ export * from "./db-provider";
|
||||
export * from "./hotkeys-settings-provider";
|
||||
export * from "./media-player-provider";
|
||||
export * from "./theme-provider";
|
||||
export * from "./dict-provider";
|
||||
|
||||
131
enjoy/src/renderer/lib/dict.ts
Normal file
131
enjoy/src/renderer/lib/dict.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as cheerio from "cheerio";
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
css: "text/css",
|
||||
img: "image",
|
||||
jpg: "image/jpeg",
|
||||
png: "image/png",
|
||||
spx: "audio/x-speex",
|
||||
wav: "audio/wav",
|
||||
mp3: "audio/mp3",
|
||||
js: "text/javascript",
|
||||
};
|
||||
|
||||
export class DictDefinitionNormalizer {
|
||||
$: cheerio.CheerioAPI;
|
||||
urls: string[] = [];
|
||||
onInjectScript: (url: string) => void;
|
||||
onReadResource: (key: string) => Promise<string>;
|
||||
|
||||
constructor({
|
||||
onInjectScript,
|
||||
onReadResource,
|
||||
}: {
|
||||
onInjectScript: (url: string) => void;
|
||||
onReadResource: (key: string) => Promise<string>;
|
||||
}) {
|
||||
this.onInjectScript = onInjectScript;
|
||||
this.onReadResource = onReadResource;
|
||||
}
|
||||
|
||||
async createUrl(mime: string, data: string) {
|
||||
const resp = await fetch(`data:${mime};base64,${data}`);
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
this.urls.push(url);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async normalizeImages() {
|
||||
return Promise.all(
|
||||
this.$("img[src]")
|
||||
.toArray()
|
||||
.map(async (img) => {
|
||||
const $img = this.$(img);
|
||||
const src = $img.attr("src");
|
||||
const paths = /^file:\/\/(.*)/.exec(src);
|
||||
const key = paths ? paths[1] : src;
|
||||
const data = await this.onReadResource(key);
|
||||
const url = await this.createUrl(MIME["img"], data);
|
||||
|
||||
$img.attr("src", url).attr("_src", src);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async normalizeStyles() {
|
||||
return Promise.all(
|
||||
this.$("link[rel=stylesheet]")
|
||||
.toArray()
|
||||
.map(async (link) => {
|
||||
const $link = this.$(link);
|
||||
const data = await this.onReadResource($link.attr("href"));
|
||||
const url = await this.createUrl(MIME["css"], data);
|
||||
|
||||
$link.replaceWith(
|
||||
this.$("<style scoped>").text('@import url("' + url + '")')
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async normalizeScripts() {
|
||||
const hrefs = this.$("script[src]")
|
||||
.toArray()
|
||||
.map((script) => this.$(script).attr("src"));
|
||||
|
||||
for (const href of hrefs) {
|
||||
const data = await this.onReadResource(href);
|
||||
const url = await this.createUrl(MIME["js"], data);
|
||||
|
||||
this.onInjectScript(url);
|
||||
}
|
||||
}
|
||||
|
||||
async normalizeAudios() {
|
||||
return Promise.all(
|
||||
this.$('a[href^="sound://"]')
|
||||
.toArray()
|
||||
.map((link) => {
|
||||
const $link = this.$(link);
|
||||
const href = $link.attr("_href") || $link.attr("href").substring(8);
|
||||
|
||||
$link.attr("data-type", "audio").attr("data-source", href);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async normalizeInternalLink() {
|
||||
return Promise.all(
|
||||
[
|
||||
...this.$('a[href^="entry://"]').toArray(),
|
||||
...this.$('a[href^="bword://"]').toArray(),
|
||||
].map((link) => {
|
||||
const $link = this.$(link);
|
||||
const word = $link.attr("_href") || $link.attr("href").substring(8);
|
||||
|
||||
$link.attr("data-type", "jump").attr("data-word", word);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async normalize(definition: string) {
|
||||
this.$ = cheerio.load(definition, null, false);
|
||||
|
||||
await this.normalizeImages();
|
||||
await this.normalizeStyles();
|
||||
await this.normalizeScripts();
|
||||
await this.normalizeAudios();
|
||||
await this.normalizeInternalLink();
|
||||
|
||||
return this.$.html();
|
||||
}
|
||||
|
||||
revoke() {
|
||||
this.urls.map((url) => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
VideosSegment,
|
||||
YoutubeVideosSegment,
|
||||
EnrollmentSegment,
|
||||
Vocabulary,
|
||||
} from "@renderer/components";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
|
||||
16
enjoy/src/types/dict.d.ts
vendored
Normal file
16
enjoy/src/types/dict.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
type Dict = {
|
||||
name: string;
|
||||
fileName: string;
|
||||
title: string;
|
||||
pronunciation: boolean;
|
||||
lang: string;
|
||||
downloadUrl: string;
|
||||
size: string;
|
||||
addition: string;
|
||||
hash: string;
|
||||
state?: DictState;
|
||||
downloadState?: DownloadStateType;
|
||||
decompressProgress?: string;
|
||||
};
|
||||
|
||||
type DictState = "installed" | "decompressing" | "downloading" | "uninstall";
|
||||
28
enjoy/src/types/enjoy-app.d.ts
vendored
28
enjoy/src/types/enjoy-app.d.ts
vendored
@@ -78,10 +78,16 @@ type EnjoyAppType = {
|
||||
onNotification: (
|
||||
callback: (event, notification: NotificationType) => void
|
||||
) => void;
|
||||
lookup: (
|
||||
selection: string,
|
||||
context: string,
|
||||
position: { x: number; y: number }
|
||||
) => void;
|
||||
onLookup: (
|
||||
callback: (
|
||||
event,
|
||||
event: IpcRendererEvent,
|
||||
selection: string,
|
||||
context: string,
|
||||
position: { x: number; y: number }
|
||||
) => void
|
||||
) => void;
|
||||
@@ -130,8 +136,12 @@ type EnjoyAppType = {
|
||||
switchLanguage: (language: string) => Promise<void>;
|
||||
getDefaultHotkeys: () => Promise<Record<string, string> | undefined>;
|
||||
setDefaultHotkeys: (records: Record<string, string>) => Promise<void>;
|
||||
getDictSettings: () => Promise<DictSettingType>;
|
||||
setDictSettings: (dict: DictSettingType) => Promise<void>;
|
||||
getApiUrl: () => Promise<string>;
|
||||
setApiUrl: (url: string) => Promise<void>;
|
||||
getVocabularyConfig: () => Promise<VocabularyConfigType | undefined>;
|
||||
setVocabularyConfig: (records: VocabularyConfigType) => Promise<void>;
|
||||
};
|
||||
fs: {
|
||||
ensureDir: (path: string) => Promise<boolean>;
|
||||
@@ -149,6 +159,14 @@ type EnjoyAppType = {
|
||||
camdict: {
|
||||
lookup: (word: string) => Promise<CamdictWordType | null>;
|
||||
};
|
||||
dict: {
|
||||
getDicts: () => Promise<Dict[]>;
|
||||
download: (dict: Dict) => Promise<void>;
|
||||
decompress: (dict: Dict) => Promise<void>;
|
||||
remove: (dict: Dict) => Promise<void>;
|
||||
getResource: (key: string, dict: Dict) => Promise<string | null>;
|
||||
lookup: (word: string, dict: Dict) => Promise<string | null>;
|
||||
};
|
||||
audios: {
|
||||
findAll: (params: any) => Promise<AudioType[]>;
|
||||
findOne: (params: any) => Promise<AudioType>;
|
||||
@@ -299,10 +317,18 @@ type EnjoyAppType = {
|
||||
options?: string[]
|
||||
) => Promise<string>;
|
||||
};
|
||||
decompress: {
|
||||
onUpdate: (callback: (event, tasks: DecompressTask[]) => void) => void;
|
||||
dashboard: () => Promise<DecompressTask[]>;
|
||||
removeAllListeners: () => void;
|
||||
};
|
||||
download: {
|
||||
onState: (callback: (event, state) => void) => void;
|
||||
start: (url: string, savePath?: string) => Promise<string | undefined>;
|
||||
cancel: (filename: string) => Promise<void>;
|
||||
pause: (filename: string) => Promis<void>;
|
||||
resume: (filename: string) => Promise<void>;
|
||||
remove: (filename: string) => Promise<void>;
|
||||
cancelAll: () => void;
|
||||
dashboard: () => Promise<DownloadStateType[]>;
|
||||
removeAllListeners: () => void;
|
||||
|
||||
28
enjoy/src/types/index.d.ts
vendored
28
enjoy/src/types/index.d.ts
vendored
@@ -17,12 +17,22 @@ type LlmProviderType = {
|
||||
|
||||
type DownloadStateType = {
|
||||
name: string;
|
||||
isPaused: boolean;
|
||||
canResume: boolean;
|
||||
state: "progressing" | "interrupted" | "completed" | "cancelled";
|
||||
received: number;
|
||||
total: number;
|
||||
speed?: string;
|
||||
};
|
||||
|
||||
type DecompressTask = {
|
||||
id: string;
|
||||
filePath: string;
|
||||
destPath: string;
|
||||
hash: string;
|
||||
progress?: string;
|
||||
};
|
||||
|
||||
type NotificationType = {
|
||||
type: "info" | "error" | "warning" | "success";
|
||||
message: string;
|
||||
@@ -158,6 +168,10 @@ type ProxyConfigType = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
type VocabularyConfigType = {
|
||||
lookupOnMouseOver: boolean;
|
||||
};
|
||||
|
||||
type YoutubeVideoType = {
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
@@ -197,3 +211,17 @@ type RecorderConfigType = {
|
||||
sampleRate: number;
|
||||
sampleSize: number;
|
||||
};
|
||||
|
||||
type DictSettingItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
description: string;
|
||||
isKeyCaseSensitive: string;
|
||||
title: string;
|
||||
resources: string[];
|
||||
};
|
||||
|
||||
type DictSettingType = {
|
||||
default: string;
|
||||
removing: string[];
|
||||
};
|
||||
|
||||
@@ -148,3 +148,7 @@ export const humanFileSize = (bytes: number, si: boolean = false) => {
|
||||
);
|
||||
return bytes.toFixed(1) + " " + units[u];
|
||||
};
|
||||
|
||||
export function getExtension(filename: string, defaultExt: string) {
|
||||
return /(?:\.([^.]+))?$/.exec(filename)[1] || defaultExt;
|
||||
}
|
||||
|
||||
95
yarn.lock
95
yarn.lock
@@ -8120,7 +8120,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/prop-types@npm:*":
|
||||
"@types/prop-types@npm:*, @types/prop-types@npm:^15":
|
||||
version: 15.7.12
|
||||
resolution: "@types/prop-types@npm:15.7.12"
|
||||
checksum: 10c0/1babcc7db6a1177779f8fde0ccc78d64d459906e6ef69a4ed4dd6339c920c2e05b074ee5a92120fe4e9d9f1a01c952f843ebd550bee2332fc2ef81d1706878f8
|
||||
@@ -8204,6 +8204,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/unzipper@npm:^0":
|
||||
version: 0.10.10
|
||||
resolution: "@types/unzipper@npm:0.10.10"
|
||||
dependencies:
|
||||
"@types/node": "npm:*"
|
||||
checksum: 10c0/10e9da33791be1087adb25adc2fe4d5ab267dae51fbcf7b1f10d0aca3130a13ef5fed994d7be45af8c465ff3946bc360a53eff6e5aab4eb9ac9489477535342f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/uuid@npm:^10.0.0":
|
||||
version: 10.0.0
|
||||
resolution: "@types/uuid@npm:10.0.0"
|
||||
@@ -9703,7 +9712,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"bluebird@npm:^3.1.1":
|
||||
"bluebird@npm:^3.1.1, bluebird@npm:~3.7.2":
|
||||
version: 3.7.2
|
||||
resolution: "bluebird@npm:3.7.2"
|
||||
checksum: 10c0/680de03adc54ff925eaa6c7bb9a47a0690e8b5de60f4792604aae8ed618c65e6b63a7893b57ca924beaf53eee69c5af4f8314148c08124c550fe1df1add897d2
|
||||
@@ -12160,6 +12169,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"duplexer2@npm:~0.1.4":
|
||||
version: 0.1.4
|
||||
resolution: "duplexer2@npm:0.1.4"
|
||||
dependencies:
|
||||
readable-stream: "npm:^2.0.2"
|
||||
checksum: 10c0/0765a4cc6fe6d9615d43cc6dbccff6f8412811d89a6f6aa44828ca9422a0a469625ce023bf81cee68f52930dbedf9c5716056ff264ac886612702d134b5e39b4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"duplexer@npm:^0.1.2":
|
||||
version: 0.1.2
|
||||
resolution: "duplexer@npm:0.1.2"
|
||||
@@ -12580,9 +12598,11 @@ __metadata:
|
||||
"@types/mark.js": "npm:^8.11.12"
|
||||
"@types/mustache": "npm:^4.2.5"
|
||||
"@types/node": "npm:^22.5.0"
|
||||
"@types/prop-types": "npm:^15"
|
||||
"@types/rails__actioncable": "npm:^6.1.11"
|
||||
"@types/react": "npm:^18.3.4"
|
||||
"@types/react-dom": "npm:^18.3.0"
|
||||
"@types/unzipper": "npm:^0"
|
||||
"@types/validator": "npm:^13.12.1"
|
||||
"@types/wavesurfer.js": "npm:^6.0.12"
|
||||
"@typescript-eslint/eslint-plugin": "npm:^8.2.0"
|
||||
@@ -12630,6 +12650,7 @@ __metadata:
|
||||
js-md5: "npm:^0.8.3"
|
||||
langchain: "npm:^0.2.17"
|
||||
lodash: "npm:^4.17.21"
|
||||
lru-cache: "npm:^11.0.0"
|
||||
lucide-react: "npm:^0.436.0"
|
||||
mark.js: "npm:^8.11.1"
|
||||
microsoft-cognitiveservices-speech-sdk: "npm:^1.40.0"
|
||||
@@ -12640,18 +12661,21 @@ __metadata:
|
||||
pitchfinder: "npm:^2.3.2"
|
||||
postcss: "npm:^8.4.41"
|
||||
progress: "npm:^2.0.3"
|
||||
prop-types: "npm:^15.8.1"
|
||||
proxy-agent: "npm:^6.4.0"
|
||||
react: "npm:^18.3.1"
|
||||
react-activity-calendar: "npm:^2.4.0"
|
||||
react-audio-visualize: "npm:^1.1.3"
|
||||
react-audio-voice-recorder: "npm:^2.2.0"
|
||||
react-dom: "npm:^18.3.1"
|
||||
react-frame-component: "npm:^5.2.7"
|
||||
react-hook-form: "npm:^7.53.0"
|
||||
react-hotkeys-hook: "npm:^4.5.0"
|
||||
react-i18next: "npm:^15.0.1"
|
||||
react-markdown: "npm:^9.0.1"
|
||||
react-resizable-panels: "npm:^2.1.1"
|
||||
react-router-dom: "npm:^6.26.1"
|
||||
react-shadow-root: "npm:^6.2.0"
|
||||
react-tooltip: "npm:^5.28.0"
|
||||
reflect-metadata: "npm:^0.2.2"
|
||||
rimraf: "npm:^6.0.1"
|
||||
@@ -12668,6 +12692,7 @@ __metadata:
|
||||
tslib: "npm:^2.7.0"
|
||||
typescript: "npm:^5.5.4"
|
||||
umzug: "npm:^3.8.1"
|
||||
unzipper: "npm:^0.12.3"
|
||||
update-electron-app: "npm:^3.0.0"
|
||||
vite: "npm:^5.4.2"
|
||||
vite-plugin-static-copy: "npm:^1.0.6"
|
||||
@@ -14666,7 +14691,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9":
|
||||
"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.2, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9":
|
||||
version: 4.2.11
|
||||
resolution: "graceful-fs@npm:4.2.11"
|
||||
checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2
|
||||
@@ -16844,7 +16869,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0":
|
||||
"loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0":
|
||||
version: 1.4.0
|
||||
resolution: "loose-envify@npm:1.4.0"
|
||||
dependencies:
|
||||
@@ -18415,6 +18440,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-int64@npm:^0.4.0":
|
||||
version: 0.4.0
|
||||
resolution: "node-int64@npm:0.4.0"
|
||||
checksum: 10c0/a6a4d8369e2f2720e9c645255ffde909c0fbd41c92ea92a5607fc17055955daac99c1ff589d421eee12a0d24e99f7bfc2aabfeb1a4c14742f6c099a51863f31a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-releases@npm:^2.0.18":
|
||||
version: 2.0.18
|
||||
resolution: "node-releases@npm:2.0.18"
|
||||
@@ -20395,6 +20427,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prop-types@npm:^15.8.1":
|
||||
version: 15.8.1
|
||||
resolution: "prop-types@npm:15.8.1"
|
||||
dependencies:
|
||||
loose-envify: "npm:^1.4.0"
|
||||
object-assign: "npm:^4.1.1"
|
||||
react-is: "npm:^16.13.1"
|
||||
checksum: 10c0/59ece7ca2fb9838031d73a48d4becb9a7cc1ed10e610517c7d8f19a1e02fa47f7c27d557d8a5702bec3cfeccddc853579832b43f449e54635803f277b1c78077
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"property-information@npm:^6.0.0":
|
||||
version: 6.5.0
|
||||
resolution: "property-information@npm:6.5.0"
|
||||
@@ -20666,6 +20709,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-frame-component@npm:^5.2.7":
|
||||
version: 5.2.7
|
||||
resolution: "react-frame-component@npm:5.2.7"
|
||||
peerDependencies:
|
||||
prop-types: ^15.5.9
|
||||
react: ">= 16.3"
|
||||
react-dom: ">= 16.3"
|
||||
checksum: 10c0/e138602aa98557c021ae825f51468026c53b9939140c5961d5371b65ad07ff9a5adaf2cd4e4a8a77414a05ae0f95a842939c8e102aa576ef21ff096368b905a3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-hook-form@npm:^7.53.0":
|
||||
version: 7.53.0
|
||||
resolution: "react-hook-form@npm:7.53.0"
|
||||
@@ -20703,6 +20757,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-is@npm:^16.13.1":
|
||||
version: 16.13.1
|
||||
resolution: "react-is@npm:16.13.1"
|
||||
checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-markdown@npm:^9.0.1":
|
||||
version: 9.0.1
|
||||
resolution: "react-markdown@npm:9.0.1"
|
||||
@@ -20819,6 +20880,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-shadow-root@npm:^6.2.0":
|
||||
version: 6.2.0
|
||||
resolution: "react-shadow-root@npm:6.2.0"
|
||||
peerDependencies:
|
||||
prop-types: ">=15.6.0"
|
||||
react: ">=16.0.0"
|
||||
react-dom: ">=16.0.0"
|
||||
checksum: 10c0/898ac8c3d025f5d854700dc329b8f9e8b43a489a3f4c55b830d640fca480c6326196449c5241527d09403e6f4291d5953ee4a4db13e80df3e8581f7aa8a04b9a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-style-singleton@npm:^2.2.1":
|
||||
version: 2.2.1
|
||||
resolution: "react-style-singleton@npm:2.2.1"
|
||||
@@ -20922,7 +20994,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readable-stream@npm:^2.0.5, readable-stream@npm:~2.3.6":
|
||||
"readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.5, readable-stream@npm:~2.3.6":
|
||||
version: 2.3.8
|
||||
resolution: "readable-stream@npm:2.3.8"
|
||||
dependencies:
|
||||
@@ -23762,6 +23834,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"unzipper@npm:^0.12.3":
|
||||
version: 0.12.3
|
||||
resolution: "unzipper@npm:0.12.3"
|
||||
dependencies:
|
||||
bluebird: "npm:~3.7.2"
|
||||
duplexer2: "npm:~0.1.4"
|
||||
fs-extra: "npm:^11.2.0"
|
||||
graceful-fs: "npm:^4.2.2"
|
||||
node-int64: "npm:^0.4.0"
|
||||
checksum: 10c0/4cae2ad23bfd47011d5f8a6d61fb1dc0e4b5008bc3896e6f3d5ab946a64e9482714992a988128bce541440aa646e16e5e5c9bf35e49097edbaf833e7f814d36d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"update-browserslist-db@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "update-browserslist-db@npm:1.1.0"
|
||||
|
||||
Reference in New Issue
Block a user