feat: 🎸 add dicts (#1022)

This commit is contained in:
divisey
2024-08-29 14:02:52 +08:00
committed by GitHub
parent 7b30628c36
commit a1e7b7a062
44 changed files with 2213 additions and 363 deletions

1
.gitignore vendored
View File

@@ -131,6 +131,7 @@ ffmpeg-core.worker.js
# dict
cam_dict.refined.sqlite
enjoy/lib/dictionaries
.vitepress/cache/
public/jupyter-notebooks/*.mp3

View File

@@ -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",

View 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" />',
},
];

View File

@@ -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"
}

View File

@@ -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": "正在下载"
}

View File

@@ -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,
});
},
},
{

View File

@@ -66,6 +66,7 @@ class Camdict {
const item = await this.db?.findOne({
where: { word: word.trim().toLowerCase() },
});
return item?.toJSON();
}

View 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
View 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();

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"),

View File

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

View File

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

View File

@@ -1,2 +1,4 @@
export * from "./lookup-widget";
export * from "./lookup";
export * from "./translate-widget";
export * from "./vocabulary";
export * from "./lookup/dict-lookup-result";

View File

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

View File

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

View File

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

View File

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

View 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>
);
};

View 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";

View 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>
);
};

View 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>
);
};

View File

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

View 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>
);
};

View File

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

View 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);
});
}
}

View File

@@ -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
View 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";

View File

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

View File

@@ -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[];
};

View File

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

View File

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