Support document(epub) (#1160)
* add document model * may add document * document card * basic document renderer * may render epub * basic layout * handle book href * refactor epub renderer * refactor translate button * toggle player * cache/restore last read position * refactor * add more columns to speeches * start shadow from document * add compact layout for media shadow * refactor * refactor * refactor * add document config * locales * auto translate * selected notify for update document * refactor * add document provider * fix perf issue * refactor * refactor * may toggle player * clean * refactor * clean code * auto play speech * fix document config update * refactor * fix epub image * fix epub image * html document * refactor * ui * save document source * fix document source * update document model * cache translation remote * update UI * fix package * refactor * fix * support text/markdown files * fix auto speech
This commit is contained in:
@@ -51,6 +51,7 @@
|
||||
"@types/intl-tel-input": "^18.1.4",
|
||||
"@types/lodash": "^4.17.12",
|
||||
"@types/mark.js": "^8.11.12",
|
||||
"@types/mime-types": "^2",
|
||||
"@types/mustache": "^4.2.5",
|
||||
"@types/node": "^22.7.7",
|
||||
"@types/prop-types": "^15.7.13",
|
||||
@@ -120,6 +121,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@rails/actioncable": "7.2.101",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vidstack/react": "^1.12.11",
|
||||
"ahoy.js": "^0.4.4",
|
||||
@@ -145,7 +147,9 @@
|
||||
"electron-settings": "^4.0.4",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
"file-type": "^19.6.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"foliate-js": "https://github.com/johnfactotum/foliate-js",
|
||||
"fs-extra": "^11.2.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.5",
|
||||
@@ -158,6 +162,7 @@
|
||||
"lucide-react": "^0.453.0",
|
||||
"mark.js": "^8.11.1",
|
||||
"microsoft-cognitiveservices-speech-sdk": "^1.41.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"mustache": "^4.2.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"openai": "^4.68.1",
|
||||
@@ -179,6 +184,7 @@
|
||||
"react-shadow-root": "^6.2.0",
|
||||
"react-tooltip": "^5.28.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"semver": "^7.6.3",
|
||||
"sequelize": "^6.37.4",
|
||||
@@ -186,6 +192,7 @@
|
||||
"sonner": "^1.5.0",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-scrollbar-hide": "^1.1.7",
|
||||
"turndown": "^7.2.0",
|
||||
"umzug": "^3.8.2",
|
||||
"unzipper": "^0.12.3",
|
||||
"update-electron-app": "^3.0.0",
|
||||
|
||||
@@ -69,8 +69,8 @@ export class Client {
|
||||
this.logger.error(
|
||||
err.response.status,
|
||||
err.response.config.method.toUpperCase(),
|
||||
err.response.config.baseURL + err.response.config.url,
|
||||
err.response.data
|
||||
err.response.config.baseURL + err.response.config.url
|
||||
// err.response.data
|
||||
);
|
||||
|
||||
if (err.response.data) {
|
||||
@@ -86,7 +86,6 @@ export class Client {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
this.logger.error(err.message);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
@@ -616,4 +615,37 @@ export class Client {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
syncDocument(document: Partial<DocumentEType>) {
|
||||
return this.api.post("/api/mine/documents", decamelizeKeys(document));
|
||||
}
|
||||
|
||||
deleteDocument(id: string) {
|
||||
return this.api.delete(`/api/mine/documents/${id}`);
|
||||
}
|
||||
|
||||
translations(params?: {
|
||||
md5?: string;
|
||||
translatedLanguage?: string;
|
||||
engine?: string;
|
||||
}): Promise<
|
||||
{
|
||||
translations: TranslationType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/translations", {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
createTranslation(params: {
|
||||
md5: string;
|
||||
content: string;
|
||||
translatedContent: string;
|
||||
language: string;
|
||||
translatedLanguage: string;
|
||||
engine: string;
|
||||
}): Promise<TranslationType> {
|
||||
return this.api.post("/api/translations", decamelizeKeys(params));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ export const AudioFormats = ["mp3", "wav", "ogg", "flac", "m4a", "wma", "aac"];
|
||||
|
||||
export const VideoFormats = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm"];
|
||||
|
||||
export const DocumentFormats = ["epub", "md", "markdown", "html", "txt"];
|
||||
|
||||
export const PROCESS_TIMEOUT = 1000 * 60 * 15;
|
||||
|
||||
export const NOT_SUPPORT_JSON_FORMAT_MODELS = [
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"onlyGPTAgentCanBeAddedToThisChat": "Only GPT agent can be added to this chat",
|
||||
"invalidAgentType": "Invalid agent type",
|
||||
"invalidMembers": "Invalid members"
|
||||
},
|
||||
"document": {
|
||||
"fileNotFound": "File not found {{file}}",
|
||||
"fileNotSupported": "File not supported {{file}}",
|
||||
"failedToCopyFile": "Failed to copy file {{file}}"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -199,6 +204,7 @@
|
||||
"audios": "Audios",
|
||||
"videos": "Videos",
|
||||
"stories": "Stories",
|
||||
"documents": "Documents",
|
||||
"books": "Books",
|
||||
"vocabulary": "Vocabulary",
|
||||
"library": "Library",
|
||||
@@ -529,6 +535,8 @@
|
||||
"addedStories": "added stories",
|
||||
"addedAudios": "added audios",
|
||||
"addedVideos": "added videos",
|
||||
"addedDocuments": "added documents",
|
||||
"document": "document",
|
||||
"frontSide": "front side",
|
||||
"backSide": "back side",
|
||||
"aiExtractVocabulary": "AI extract vocabulary",
|
||||
@@ -891,5 +899,13 @@
|
||||
"diskUsageDescription": "The disk usage of Enjoy App.",
|
||||
"releaseDiskSpace": "Release",
|
||||
"bulkDeleteRecordings": "Delete Recordings",
|
||||
"bulkDeleteAborted": "Bulk delete aborted"
|
||||
"bulkDeleteAborted": "Bulk delete aborted",
|
||||
"notFound": "Not found",
|
||||
"saved": "Saved",
|
||||
"autoTranslate": "Auto translate",
|
||||
"autoNextSpeech": "Auto speech",
|
||||
"failedToLoadLink": "Failed to load link",
|
||||
"refreshSpeech": "Refresh speech",
|
||||
"locateParagraph": "Locate paragraph",
|
||||
"close": "Close"
|
||||
}
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"onlyGPTAgentCanBeAddedToThisChat": "只有 GPT 智能体可以添加到此聊天",
|
||||
"invalidAgentType": "无效的智能体类型",
|
||||
"invalidMembers": "无效的成员"
|
||||
},
|
||||
"document": {
|
||||
"fileNotFound": "文件未找到 {{file}}",
|
||||
"fileNotSupported": "文件格式不支持 {{file}}",
|
||||
"failedToCopyFile": "复制文件失败 {{file}}"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -199,6 +204,7 @@
|
||||
"audios": "音频",
|
||||
"videos": "视频",
|
||||
"stories": "文章",
|
||||
"documents": "文档",
|
||||
"books": "电子书",
|
||||
"vocabulary": "生词本",
|
||||
"library": "资料库",
|
||||
@@ -529,6 +535,8 @@
|
||||
"addedStories": "添加的文章",
|
||||
"addedAudios": "添加的音频",
|
||||
"addedVideos": "添加的视频",
|
||||
"addedDocuments": "添加的文档",
|
||||
"document": "文档",
|
||||
"frontSide": "正面",
|
||||
"backSide": "反面",
|
||||
"aiExtractVocabulary": "AI 提取生词",
|
||||
@@ -891,5 +899,13 @@
|
||||
"diskUsageDescription": "Enjoy App 的磁盘使用情况",
|
||||
"releaseDiskSpace": "释放磁盘",
|
||||
"bulkDeleteRecordings": "删除录音",
|
||||
"bulkDeleteAborted": "批量删除已中止"
|
||||
"bulkDeleteAborted": "批量删除已中止",
|
||||
"notFound": "未找到",
|
||||
"saved": "已保存",
|
||||
"autoTranslate": "自动翻译",
|
||||
"autoNextSpeech": "连续朗读",
|
||||
"failedToLoadLink": "加载链接失败",
|
||||
"refreshSpeech": "刷新语音",
|
||||
"locateParagraph": "定位段落",
|
||||
"close": "关闭"
|
||||
}
|
||||
|
||||
@@ -117,7 +117,11 @@ app.on("ready", async () => {
|
||||
|
||||
protocol.handle("enjoy", (request) => {
|
||||
let url = request.url.replace("enjoy://", "");
|
||||
if (url.match(/library\/(audios|videos|recordings|speeches|segments)/g)) {
|
||||
if (
|
||||
url.match(
|
||||
/library\/(audios|videos|recordings|speeches|segments|documents)/g
|
||||
)
|
||||
) {
|
||||
url = url.replace("library/", "");
|
||||
url = path.join(settings.userDataPath(), url);
|
||||
} else if (url.startsWith("library")) {
|
||||
|
||||
158
enjoy/src/main/db/handlers/documents-handler.ts
Normal file
158
enjoy/src/main/db/handlers/documents-handler.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { ipcMain, IpcMainEvent } from "electron";
|
||||
import { Document } from "@main/db/models";
|
||||
import { FindOptions, WhereOptions, Attributes, Op } from "sequelize";
|
||||
import downloader from "@main/downloader";
|
||||
import log from "@main/logger";
|
||||
import { t } from "i18next";
|
||||
|
||||
const logger = log.scope("db/handlers/documents-handler");
|
||||
|
||||
class DocumentsHandler {
|
||||
private async findAll(
|
||||
_event: IpcMainEvent,
|
||||
options: FindOptions<Attributes<Document>> & { query?: string }
|
||||
) {
|
||||
const { query, where = {} } = options || {};
|
||||
delete options.query;
|
||||
delete options.where;
|
||||
|
||||
if (query) {
|
||||
(where as any).title = {
|
||||
[Op.like]: `%${query}%`,
|
||||
};
|
||||
}
|
||||
const documents = await Document.findAll({
|
||||
order: [
|
||||
["lastReadAt", "DESC"],
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
where,
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!documents) {
|
||||
return [];
|
||||
}
|
||||
return documents.map((document) => document.toJSON());
|
||||
}
|
||||
|
||||
private async findOne(
|
||||
_event: IpcMainEvent,
|
||||
where: WhereOptions<Attributes<Document>>
|
||||
) {
|
||||
const document = await Document.findOne({
|
||||
where: {
|
||||
...where,
|
||||
},
|
||||
});
|
||||
if (!document) return;
|
||||
|
||||
if (!document.isSynced) {
|
||||
document.sync().catch(() => {});
|
||||
}
|
||||
|
||||
return document.toJSON();
|
||||
}
|
||||
|
||||
private async create(
|
||||
event: IpcMainEvent,
|
||||
params: {
|
||||
uri: string;
|
||||
title?: string;
|
||||
config?: Record<string, any>;
|
||||
source?: string;
|
||||
}
|
||||
) {
|
||||
let { uri, title, config, source } = params;
|
||||
if (uri.startsWith("http")) {
|
||||
uri = await downloader.download(uri, {
|
||||
webContents: event.sender,
|
||||
});
|
||||
if (!uri) throw new Error("Failed to download file");
|
||||
}
|
||||
|
||||
try {
|
||||
const document = await Document.buildFromLocalFile(uri, {
|
||||
title,
|
||||
config,
|
||||
source,
|
||||
});
|
||||
|
||||
return document.toJSON();
|
||||
} catch (err) {
|
||||
logger.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async update(
|
||||
_event: IpcMainEvent,
|
||||
id: string,
|
||||
params: Attributes<Document>
|
||||
) {
|
||||
const { title, metadata, lastReadPosition, lastReadAt, config } = params;
|
||||
|
||||
const document = await Document.findByPk(id);
|
||||
|
||||
if (!document) {
|
||||
throw new Error(t("models.document.notFound"));
|
||||
}
|
||||
return await document.update({
|
||||
title,
|
||||
metadata,
|
||||
lastReadPosition,
|
||||
lastReadAt,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
private async destroy(_event: IpcMainEvent, id: string) {
|
||||
const document = await Document.findByPk(id);
|
||||
|
||||
if (!document) {
|
||||
throw new Error(t("models.document.notFound"));
|
||||
}
|
||||
return await document.destroy();
|
||||
}
|
||||
|
||||
private async upload(event: IpcMainEvent, id: string) {
|
||||
const document = await Document.findByPk(id);
|
||||
if (!document) {
|
||||
throw new Error(t("models.document.notFound"));
|
||||
}
|
||||
|
||||
return await document.upload();
|
||||
}
|
||||
|
||||
private async cleanUp() {
|
||||
const documents = await Document.findAll();
|
||||
|
||||
for (const document of documents) {
|
||||
if (!document.src) {
|
||||
document.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register() {
|
||||
ipcMain.handle("documents-find-all", this.findAll);
|
||||
ipcMain.handle("documents-find-one", this.findOne);
|
||||
ipcMain.handle("documents-create", this.create);
|
||||
ipcMain.handle("documents-update", this.update);
|
||||
ipcMain.handle("documents-destroy", this.destroy);
|
||||
ipcMain.handle("documents-upload", this.upload);
|
||||
ipcMain.handle("documents-clean-up", this.cleanUp);
|
||||
}
|
||||
|
||||
unregister() {
|
||||
ipcMain.removeHandler("documents-find-all");
|
||||
ipcMain.removeHandler("documents-find-one");
|
||||
ipcMain.removeHandler("documents-create");
|
||||
ipcMain.removeHandler("documents-update");
|
||||
ipcMain.removeHandler("documents-destroy");
|
||||
ipcMain.removeHandler("documents-upload");
|
||||
ipcMain.removeHandler("documents-clean-up");
|
||||
}
|
||||
}
|
||||
|
||||
export const documentsHandler = new DocumentsHandler();
|
||||
@@ -14,3 +14,4 @@ export * from "./segments-handler";
|
||||
export * from "./transcriptions-handler";
|
||||
export * from "./user-settings-handler";
|
||||
export * from "./videos-handler";
|
||||
export * from "./documents-handler";
|
||||
|
||||
@@ -25,6 +25,8 @@ class SpeechesHandler {
|
||||
sourceId: string;
|
||||
sourceType: string;
|
||||
text: string;
|
||||
section?: number;
|
||||
segment?: number;
|
||||
configuration: {
|
||||
engine: string;
|
||||
model: string;
|
||||
@@ -55,14 +57,20 @@ class SpeechesHandler {
|
||||
});
|
||||
}
|
||||
|
||||
private async delete(event: IpcMainEvent, id: string) {
|
||||
await Speech.destroy({ where: { id } });
|
||||
}
|
||||
|
||||
register() {
|
||||
ipcMain.handle("speeches-find-one", this.findOne);
|
||||
ipcMain.handle("speeches-create", this.create);
|
||||
ipcMain.handle("speeches-delete", this.delete);
|
||||
}
|
||||
|
||||
unregister() {
|
||||
ipcMain.removeHandler("speeches-find-one");
|
||||
ipcMain.removeHandler("speeches-create");
|
||||
ipcMain.removeHandler("speeches-delete");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ChatMember,
|
||||
ChatMessage,
|
||||
Conversation,
|
||||
Document,
|
||||
Message,
|
||||
Note,
|
||||
PronunciationAssessment,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
chatMessagesHandler,
|
||||
chatsHandler,
|
||||
conversationsHandler,
|
||||
documentsHandler,
|
||||
messagesHandler,
|
||||
notesHandler,
|
||||
pronunciationAssessmentsHandler,
|
||||
@@ -68,6 +70,7 @@ const handlers = [
|
||||
chatMessagesHandler,
|
||||
chatsHandler,
|
||||
conversationsHandler,
|
||||
documentsHandler,
|
||||
messagesHandler,
|
||||
notesHandler,
|
||||
pronunciationAssessmentsHandler,
|
||||
@@ -107,6 +110,7 @@ db.connect = async () => {
|
||||
ChatMember,
|
||||
ChatMessage,
|
||||
Conversation,
|
||||
Document,
|
||||
Message,
|
||||
Note,
|
||||
PronunciationAssessment,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { DataTypes } from "sequelize";
|
||||
|
||||
async function up({ context: queryInterface }) {
|
||||
queryInterface.createTable(
|
||||
"documents",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
md5: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
language: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
cover_url: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
source: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: false,
|
||||
defaultValue: {},
|
||||
},
|
||||
config: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: false,
|
||||
defaultValue: {},
|
||||
},
|
||||
last_read_position: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: {},
|
||||
},
|
||||
last_read_at: {
|
||||
type: DataTypes.DATE,
|
||||
},
|
||||
synced_at: {
|
||||
type: DataTypes.DATE,
|
||||
},
|
||||
uploaded_at: {
|
||||
type: DataTypes.DATE,
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{
|
||||
fields: ["md5"],
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function down({ context: queryInterface }) {
|
||||
queryInterface.dropTable("documents");
|
||||
}
|
||||
|
||||
export { up, down };
|
||||
@@ -0,0 +1,21 @@
|
||||
import { DataTypes } from "sequelize";
|
||||
|
||||
async function up({ context: queryInterface }) {
|
||||
await queryInterface.addColumn("speeches", "section", {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
});
|
||||
await queryInterface.addColumn("speeches", "segment", {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function down({ context: queryInterface }) {
|
||||
await queryInterface.removeColumn("speeches", "section");
|
||||
await queryInterface.removeColumn("speeches", "segment");
|
||||
}
|
||||
|
||||
export { up, down };
|
||||
344
enjoy/src/main/db/models/document.ts
Normal file
344
enjoy/src/main/db/models/document.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
AfterUpdate,
|
||||
AfterDestroy,
|
||||
Table,
|
||||
Column,
|
||||
Default,
|
||||
IsUUID,
|
||||
Model,
|
||||
DataType,
|
||||
AfterCreate,
|
||||
AfterFind,
|
||||
Unique,
|
||||
} from "sequelize-typescript";
|
||||
import mainWindow from "@main/window";
|
||||
import log from "@main/logger";
|
||||
import { Client } from "@/api";
|
||||
import settings from "@main/settings";
|
||||
import { UserSetting } from "@main/db/models";
|
||||
import fs from "fs-extra";
|
||||
import { t } from "i18next";
|
||||
import path from "path";
|
||||
import { DocumentFormats } from "@/constants";
|
||||
import { enjoyUrlToPath, hashFile } from "@/main/utils";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import { fileTypeFromFile } from "file-type";
|
||||
import mime from "mime-types";
|
||||
import storage from "@/main/storage";
|
||||
|
||||
const logger = log.scope("db/models/document");
|
||||
@Table({
|
||||
modelName: "Document",
|
||||
tableName: "documents",
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
})
|
||||
export class Document extends Model<Document> {
|
||||
@IsUUID("all")
|
||||
@Default(DataType.UUIDV4)
|
||||
@Column({ primaryKey: true, type: DataType.UUID })
|
||||
id: string;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
language: string;
|
||||
|
||||
@Unique
|
||||
@Column(DataType.STRING)
|
||||
md5: string;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
title: string;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
coverUrl: string;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
source: string;
|
||||
|
||||
@Column(DataType.JSON)
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@Column(DataType.JSON)
|
||||
config: Record<string, any>;
|
||||
|
||||
@Column(DataType.JSON)
|
||||
lastReadPosition: Record<string, any>;
|
||||
|
||||
@Column(DataType.DATE)
|
||||
lastReadAt: Date;
|
||||
|
||||
@Column(DataType.DATE)
|
||||
syncedAt: Date;
|
||||
|
||||
@Column(DataType.DATE)
|
||||
uploadedAt: Date;
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get autoTranslate(): boolean {
|
||||
return this.config.autoTranslate || false;
|
||||
}
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get autoNextSpeech(): boolean {
|
||||
return this.config.autoNextSpeech || false;
|
||||
}
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get ttsConfig(): Record<string, any> {
|
||||
return this.config.tts || {};
|
||||
}
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get filePath(): string {
|
||||
const file = path.join(
|
||||
settings.userDataPath(),
|
||||
"documents",
|
||||
`${this.md5}.${this.metadata.extension}`
|
||||
);
|
||||
if (fs.existsSync(file)) {
|
||||
return file;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get src(): string {
|
||||
if (!this.filePath) return null;
|
||||
|
||||
return `enjoy://${path.posix.join(
|
||||
"library",
|
||||
"documents",
|
||||
`${this.md5}.${this.metadata.extension}`
|
||||
)}`;
|
||||
}
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get isSynced(): boolean {
|
||||
return Boolean(this.syncedAt) && this.syncedAt >= this.updatedAt;
|
||||
}
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get isUploaded(): boolean {
|
||||
return Boolean(this.uploadedAt) && this.uploadedAt >= this.updatedAt;
|
||||
}
|
||||
|
||||
async sync(): Promise<void> {
|
||||
if (this.isSynced) return;
|
||||
|
||||
const webApi = new Client({
|
||||
baseUrl: settings.apiUrl(),
|
||||
accessToken: (await UserSetting.accessToken()) as string,
|
||||
logger,
|
||||
});
|
||||
|
||||
return webApi.syncDocument(this.toJSON()).then(() => {
|
||||
const now = new Date();
|
||||
this.update({ syncedAt: now, updatedAt: now });
|
||||
});
|
||||
}
|
||||
|
||||
async upload(force: boolean = false): Promise<void> {
|
||||
if (this.isUploaded && !force) return;
|
||||
|
||||
return storage
|
||||
.put(this.md5, this.filePath)
|
||||
.then((result) => {
|
||||
logger.debug("upload result:", result.data);
|
||||
if (result.data.success) {
|
||||
this.update({ uploadedAt: new Date() });
|
||||
} else {
|
||||
throw new Error(result.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("upload failed:", err.message);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
@AfterFind
|
||||
static async syncAfterFind(documents: Document[]) {
|
||||
if (!documents?.length) return;
|
||||
|
||||
const unsyncedDocuments = documents.filter(
|
||||
(document) => document.id && !document.isSynced
|
||||
);
|
||||
if (!unsyncedDocuments.length) return;
|
||||
|
||||
unsyncedDocuments.forEach((document) => {
|
||||
document.sync().catch((err) => {
|
||||
logger.error(err.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@AfterCreate
|
||||
static syncAndUploadAfterCreate(document: Document) {
|
||||
document.sync().catch((err) => {
|
||||
logger.error(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
@AfterCreate
|
||||
static notifyForCreate(document: Document) {
|
||||
this.notify(document, "create");
|
||||
}
|
||||
|
||||
@AfterUpdate
|
||||
static notifyForUpdate(document: Document) {
|
||||
if (document.changed("config") || document.changed("title")) {
|
||||
this.notify(document, "update");
|
||||
}
|
||||
}
|
||||
|
||||
@AfterUpdate
|
||||
static syncAfterUpdate(document: Document) {
|
||||
document.sync().catch((err) => {
|
||||
logger.error(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
@AfterDestroy
|
||||
static async destroyRemote(document: Document) {
|
||||
const webApi = new Client({
|
||||
baseUrl: settings.apiUrl(),
|
||||
accessToken: (await UserSetting.accessToken()) as string,
|
||||
logger,
|
||||
});
|
||||
|
||||
webApi.deleteDocument(document.id).catch((err) => {
|
||||
logger.error("delete remote document failed:", err.message);
|
||||
});
|
||||
}
|
||||
|
||||
@AfterDestroy
|
||||
static notifyForDestroy(document: Document) {
|
||||
this.notify(document, "destroy");
|
||||
}
|
||||
|
||||
static async buildFromLocalFile(
|
||||
filePath: string,
|
||||
params: {
|
||||
title?: string;
|
||||
config?: Record<string, any>;
|
||||
source?: string;
|
||||
}
|
||||
): Promise<Document> {
|
||||
// Check if file exists
|
||||
if (filePath.startsWith("enjoy://")) {
|
||||
filePath = enjoyUrlToPath(filePath);
|
||||
}
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.R_OK);
|
||||
} catch (error) {
|
||||
throw new Error(t("models.document.fileNotFound", { file: filePath }));
|
||||
}
|
||||
|
||||
// calculate md5
|
||||
const md5 = await hashFile(filePath, { algo: "md5" });
|
||||
|
||||
const existing = await Document.findOne({
|
||||
where: {
|
||||
md5,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Check if file format is supported
|
||||
let mimeType: string;
|
||||
let extension: string;
|
||||
const fileType = await fileTypeFromFile(filePath);
|
||||
if (fileType) {
|
||||
mimeType = fileType.mime;
|
||||
extension = fileType.ext;
|
||||
} else {
|
||||
mimeType = mime.lookup(filePath) || "";
|
||||
extension = mime.extension(mimeType) || "";
|
||||
}
|
||||
|
||||
logger.debug("detected file type", filePath, mimeType, extension);
|
||||
if (!DocumentFormats.includes(extension)) {
|
||||
logger.error("unsupported file type", filePath, extension);
|
||||
throw new Error(
|
||||
t("models.document.fileNotSupported", { file: filePath })
|
||||
);
|
||||
}
|
||||
|
||||
// get file's metadata
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
|
||||
const metadata = {
|
||||
size: stat.size,
|
||||
created: stat.birthtime,
|
||||
modified: stat.mtime,
|
||||
mimeType,
|
||||
extension,
|
||||
extname: extension,
|
||||
};
|
||||
|
||||
// generate ID
|
||||
const userId = settings.getSync("user.id");
|
||||
const id = uuidv5(`${userId}/${md5}`, uuidv5.URL);
|
||||
|
||||
const destDir = path.join(settings.userDataPath(), "documents");
|
||||
fs.ensureDirSync(destDir);
|
||||
const destFile = path.join(destDir, `${md5}.${extension}`);
|
||||
|
||||
try {
|
||||
// copy file to library
|
||||
fs.copyFileSync(filePath, destFile);
|
||||
} catch (error) {
|
||||
logger.error("failed to copy file", filePath, error);
|
||||
throw new Error(
|
||||
t("models.document.failedToCopyFile", { file: filePath })
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
title = path.basename(filePath, `.${extension}`),
|
||||
config = {
|
||||
autoTranslate: false,
|
||||
autoNextSpeech: true,
|
||||
tts: {
|
||||
engine: "enjoyai",
|
||||
model: "openai/tts-1",
|
||||
voice: "alloy",
|
||||
},
|
||||
},
|
||||
source,
|
||||
} = params || {};
|
||||
|
||||
const record = this.build({
|
||||
id,
|
||||
md5,
|
||||
title,
|
||||
metadata,
|
||||
config,
|
||||
source,
|
||||
});
|
||||
|
||||
return record.save().catch((err) => {
|
||||
// remove copied file
|
||||
fs.removeSync(destFile);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
static async notify(
|
||||
document: Document,
|
||||
action: "create" | "update" | "destroy"
|
||||
) {
|
||||
if (!mainWindow.win) return;
|
||||
|
||||
const record = document.toJSON();
|
||||
|
||||
mainWindow.win.webContents.send("db-on-transaction", {
|
||||
model: "Document",
|
||||
id: document.id,
|
||||
action,
|
||||
record,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,4 @@ export * from "./speech";
|
||||
export * from "./user-setting";
|
||||
export * from "./transcription";
|
||||
export * from "./video";
|
||||
export * from "./document";
|
||||
|
||||
@@ -21,7 +21,7 @@ import settings from "@main/settings";
|
||||
import OpenAI, { type ClientOptions } from "openai";
|
||||
import { t } from "i18next";
|
||||
import { hashFile } from "@main/utils";
|
||||
import { Audio, Message, UserSetting } from "@main/db/models";
|
||||
import { Audio, Document, Message, UserSetting } from "@main/db/models";
|
||||
import log from "@main/logger";
|
||||
import proxyAgent from "@main/proxy-agent";
|
||||
|
||||
@@ -55,11 +55,14 @@ export class Speech extends Model<Speech> {
|
||||
sourceType: string;
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
source: Message;
|
||||
source: Message | Document;
|
||||
|
||||
@BelongsTo(() => Message, { foreignKey: "sourceId", constraints: false })
|
||||
message: Message;
|
||||
|
||||
@BelongsTo(() => Document, { foreignKey: "sourceId", constraints: false })
|
||||
document: Document;
|
||||
|
||||
@HasOne(() => Audio, "md5")
|
||||
audio: Audio;
|
||||
|
||||
@@ -67,6 +70,14 @@ export class Speech extends Model<Speech> {
|
||||
@Column(DataType.TEXT)
|
||||
text: string;
|
||||
|
||||
@AllowNull(true)
|
||||
@Column(DataType.INTEGER)
|
||||
section: number;
|
||||
|
||||
@AllowNull(true)
|
||||
@Column(DataType.INTEGER)
|
||||
segment: number;
|
||||
|
||||
@AllowNull(false)
|
||||
@Column(DataType.JSON)
|
||||
configuration: any;
|
||||
@@ -125,9 +136,15 @@ export class Speech extends Model<Speech> {
|
||||
if (!instance) continue;
|
||||
if (instance.sourceType === "Message" && instance.message !== undefined) {
|
||||
instance.source = instance.message;
|
||||
} else if (
|
||||
instance.sourceType === "Document" &&
|
||||
instance.document !== undefined
|
||||
) {
|
||||
instance.source = instance.document;
|
||||
}
|
||||
// To prevent mistakes:
|
||||
delete instance.dataValues.message;
|
||||
delete instance.dataValues.document;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +257,32 @@ main.init = async () => {
|
||||
view.setVisible(false);
|
||||
mainWindow.contentView.addChildView(view);
|
||||
|
||||
// Add timeout handler
|
||||
const timeout = setTimeout(() => {
|
||||
logger.debug("view-scrape timeout", url);
|
||||
event.sender.send("view-on-state", {
|
||||
state: "did-fail-load",
|
||||
error: "Request timed out",
|
||||
url: url,
|
||||
});
|
||||
(view.webContents as any)?.destroy();
|
||||
mainWindow.contentView.removeChildView(view);
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
view.webContents.on("did-start-loading", () => {
|
||||
logger.debug("view-scrape did-start-loading", url);
|
||||
});
|
||||
|
||||
view.webContents.on("did-stop-loading", () => {
|
||||
logger.debug("view-scrape did-stop-loading", url);
|
||||
});
|
||||
|
||||
view.webContents.on("dom-ready", () => {
|
||||
logger.debug("view-scrape dom-ready", url);
|
||||
});
|
||||
|
||||
view.webContents.on("did-navigate", (_event, url) => {
|
||||
clearTimeout(timeout);
|
||||
event.sender.send("view-on-state", {
|
||||
state: "did-navigate",
|
||||
url,
|
||||
@@ -266,6 +291,7 @@ main.init = async () => {
|
||||
view.webContents.on(
|
||||
"did-fail-load",
|
||||
(_event, _errorCode, errrorDescription, validatedURL) => {
|
||||
clearTimeout(timeout);
|
||||
event.sender.send("view-on-state", {
|
||||
state: "did-fail-load",
|
||||
error: errrorDescription,
|
||||
@@ -276,19 +302,31 @@ main.init = async () => {
|
||||
}
|
||||
);
|
||||
view.webContents.on("did-finish-load", () => {
|
||||
clearTimeout(timeout);
|
||||
logger.debug("view-scrape did-finish-load", url);
|
||||
view.webContents
|
||||
.executeJavaScript(`document.documentElement.innerHTML`)
|
||||
.then((html) => {
|
||||
event.sender.send("view-on-state", {
|
||||
state: "did-finish-load",
|
||||
html,
|
||||
url,
|
||||
});
|
||||
(view.webContents as any).destroy();
|
||||
mainWindow.contentView.removeChildView(view);
|
||||
});
|
||||
});
|
||||
|
||||
view.webContents.loadURL(url);
|
||||
view.webContents.loadURL(url).catch((err) => {
|
||||
logger.error("view-scrape loadURL error", err);
|
||||
(view.webContents as any).destroy();
|
||||
mainWindow.contentView.removeChildView(view);
|
||||
event.sender.send("view-on-state", {
|
||||
state: "did-fail-load",
|
||||
error: err.message,
|
||||
url: url,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// App options
|
||||
|
||||
@@ -457,6 +457,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
) => {
|
||||
return ipcRenderer.invoke("speeches-create", params, blob);
|
||||
},
|
||||
delete: (id: string) => {
|
||||
return ipcRenderer.invoke("speeches-delete", id);
|
||||
},
|
||||
},
|
||||
audiowaveform: {
|
||||
generate: (
|
||||
@@ -699,4 +702,27 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
return ipcRenderer.invoke("chat-messages-destroy", id);
|
||||
},
|
||||
},
|
||||
documents: {
|
||||
findAll: (params: any) => {
|
||||
return ipcRenderer.invoke("documents-find-all", params);
|
||||
},
|
||||
findOne: (params: any) => {
|
||||
return ipcRenderer.invoke("documents-find-one", params);
|
||||
},
|
||||
create: (params: any) => {
|
||||
return ipcRenderer.invoke("documents-create", params);
|
||||
},
|
||||
update: (id: string, params: any) => {
|
||||
return ipcRenderer.invoke("documents-update", id, params);
|
||||
},
|
||||
destroy: (id: string) => {
|
||||
return ipcRenderer.invoke("documents-destroy", id);
|
||||
},
|
||||
upload: (id: string) => {
|
||||
return ipcRenderer.invoke("documents-upload", id);
|
||||
},
|
||||
cleanUp: () => {
|
||||
return ipcRenderer.invoke("documents-clean-up");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ export const AudiosSegment = (props: { limit?: number }) => {
|
||||
|
||||
{audios.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
|
||||
<MediaAddButton />
|
||||
<MediaAddButton type="Audio" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea>
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { ChatTTSForm } from "@renderer/components";
|
||||
import { TTSForm } from "@renderer/components";
|
||||
import {
|
||||
AISettingsProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
@@ -366,7 +366,7 @@ export const ChatAgentForm = (props: {
|
||||
)}
|
||||
|
||||
{form.watch("type") === ChatAgentTypeEnum.TTS && (
|
||||
<ChatTTSForm form={form} />
|
||||
<TTSForm form={form} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-4">
|
||||
|
||||
@@ -174,7 +174,7 @@ const ChatAgentMessageActions = (props: {
|
||||
setTranslation,
|
||||
autoSpeech,
|
||||
} = props;
|
||||
const { chat, setShadowing, deleteMessage } = useContext(
|
||||
const { setShadowing, deleteMessage } = useContext(
|
||||
ChatSessionProviderContext
|
||||
);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
@@ -43,8 +43,8 @@ import {
|
||||
SttEngineOptionEnum,
|
||||
} from "@/types/enums";
|
||||
import { ChevronDownIcon, ChevronUpIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { useAiCommand } from "@/renderer/hooks";
|
||||
import { cn } from "@/renderer/lib/utils";
|
||||
import { useAiCommand } from "@renderer/hooks";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
export const ChatForm = (props: { chat: ChatType; onFinish?: () => void }) => {
|
||||
const { chat, onFinish } = props;
|
||||
|
||||
@@ -30,7 +30,7 @@ import { t } from "i18next";
|
||||
import { useContext } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import Mustache from "mustache";
|
||||
import { ChatGPTForm, ChatTTSForm } from "@renderer/components";
|
||||
import { GPTForm, TTSForm } from "@renderer/components";
|
||||
|
||||
export const ChatMemberForm = (props: {
|
||||
chat: ChatType;
|
||||
@@ -164,7 +164,7 @@ export const ChatMemberForm = (props: {
|
||||
{t("models.chatMember.gptSettings")}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 px-2">
|
||||
<ChatGPTForm form={form} />
|
||||
<GPTForm form={form} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -173,7 +173,7 @@ export const ChatMemberForm = (props: {
|
||||
{t("models.chatMember.ttsSettings")}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 px-2">
|
||||
<ChatTTSForm form={form} />
|
||||
<TTSForm form={form} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
|
||||
@@ -13,7 +13,5 @@ export * from "./chat-agent-card";
|
||||
export * from "./chat-settings";
|
||||
export * from "./chat-member-form";
|
||||
export * from "./chat-header";
|
||||
export * from "./chat-tts-form";
|
||||
export * from "./chat-gpt-form";
|
||||
export * from "./chat-suggestion-button";
|
||||
export * from "./chat-mentioning";
|
||||
|
||||
176
enjoy/src/renderer/components/documents/document-add-button.tsx
Normal file
176
enjoy/src/renderer/components/documents/document-add-button.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
Input,
|
||||
Button,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { PlusCircleIcon, LoaderIcon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { useState, useContext, useEffect } from "react";
|
||||
import { DocumentFormats } from "@/constants";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Readability } from "@mozilla/readability";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
export const DocumentAddButton = () => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const navigate = useNavigate();
|
||||
const [uri, setUri] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleOpen = (value: boolean) => {
|
||||
if (submitting) {
|
||||
setOpen(true);
|
||||
} else {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!uri) return;
|
||||
|
||||
setSubmitting(true);
|
||||
if (uri.startsWith("http")) {
|
||||
EnjoyApp.view.scrape(uri);
|
||||
} else {
|
||||
createFromLocalFile(uri, uri);
|
||||
}
|
||||
};
|
||||
|
||||
const createFromLocalFile = async (path: string, source?: string) => {
|
||||
EnjoyApp.documents
|
||||
.create({
|
||||
uri: path,
|
||||
config: {
|
||||
autoTranslate: false,
|
||||
autoNextSpeech: true,
|
||||
tts: {
|
||||
engine: "enjoyai",
|
||||
model: "openai/tts-1",
|
||||
voice: "alloy",
|
||||
},
|
||||
},
|
||||
source,
|
||||
})
|
||||
.then((doc) => {
|
||||
navigate(`/documents/${doc.id}`);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false);
|
||||
setOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const onViewState = async (event: {
|
||||
state: string;
|
||||
url?: string;
|
||||
error?: string;
|
||||
html?: string;
|
||||
}) => {
|
||||
const { state, html, error, url } = event;
|
||||
if (state === "did-finish-load") {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const reader = new Readability(doc);
|
||||
const article = reader.parse();
|
||||
|
||||
const file = await EnjoyApp.cacheObjects.writeFile(
|
||||
`${doc.title}.html`,
|
||||
Buffer.from(article.content)
|
||||
);
|
||||
createFromLocalFile(file, url);
|
||||
} else if (state === "did-fail-load") {
|
||||
setSubmitting(false);
|
||||
toast.error(error || t("failedToLoadLink"));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.view.onViewState((_event, state) => onViewState(state));
|
||||
|
||||
return () => {
|
||||
EnjoyApp.view.removeViewStateListeners();
|
||||
EnjoyApp.view.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="capitalize">
|
||||
<PlusCircleIcon className="mr-2 h-4 w-4" />
|
||||
{t("addResource")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("addResource")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("addResourceFromUrlOrLocal")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
placeholder="https://"
|
||||
value={uri}
|
||||
disabled={submitting}
|
||||
onChange={(element) => {
|
||||
setUri(element.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="capitalize min-w-max"
|
||||
disabled={submitting}
|
||||
onClick={async () => {
|
||||
const selected = await EnjoyApp.dialog.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: t("documents"),
|
||||
extensions: DocumentFormats,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected) {
|
||||
setUri(selected[0]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("localFile")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!uri || submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting && <LoaderIcon className="animate-spin w-4 mr-2" />}
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
104
enjoy/src/renderer/components/documents/document-card.tsx
Normal file
104
enjoy/src/renderer/components/documents/document-card.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
AlertDialogFooter,
|
||||
} from "@renderer/components/ui";
|
||||
import { MoreVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { useState } from "react";
|
||||
|
||||
export const DocumentCard = (props: {
|
||||
document: DocumentEType;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { document, className } = props;
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const handleDelete = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setDeleting(true);
|
||||
};
|
||||
return (
|
||||
<div className={cn("w-full hover:scale-105 transition-all", className)}>
|
||||
<Link to={`/documents/${document.id}`}>
|
||||
<div className="aspect-[3/4] rounded overflow-hidden shadow-md relative flex flex-col">
|
||||
{/* Book body */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #${document.md5.slice(
|
||||
0,
|
||||
6
|
||||
)}22, #${document.md5.slice(-6)}44)`,
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* Book spine */}
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-4 shadow-inner"
|
||||
style={{
|
||||
backgroundColor: `#${document.md5.slice(0, 6)}`,
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* Book title */}
|
||||
<div className="relative flex-grow flex items-center justify-center py-4 pl-6 pr-4 z-10">
|
||||
<h3 className="text-center font-bold text-gray-800 break-words overflow-hidden">
|
||||
{document.title}
|
||||
</h3>
|
||||
</div>
|
||||
{/* drop menu */}
|
||||
<div className="absolute right-1 top-1 z-10">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-transparent w-6 h-6"
|
||||
>
|
||||
<MoreVerticalIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={handleDelete}>
|
||||
<TrashIcon className="w-4 h-4 text-destructive" />
|
||||
<span className="ml-2 text-destructive text-sm">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-1 bottom-1 z-10 bg-black/50 text-xs text-white px-1 rounded-sm">
|
||||
{document.metadata?.extension}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<AlertDialog open={deleting} onOpenChange={setDeleting}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("delete")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteConfirm")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
<Button variant="destructive">{t("delete")}</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { useContext, useState } from "react";
|
||||
import { DocumentConfigForm } from "@renderer/components";
|
||||
import { AppSettingsProviderContext } from "@/renderer/context";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const DocumentConfigButton = (props: { document: DocumentEType }) => {
|
||||
const { document } = props;
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
return (
|
||||
<Popover open={configOpen} onOpenChange={setConfigOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6">
|
||||
<SettingsIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="start">
|
||||
<DocumentConfigForm
|
||||
config={document.config}
|
||||
onSubmit={(data: any) => {
|
||||
return EnjoyApp.documents
|
||||
.update(document.id, {
|
||||
...data,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("saved"));
|
||||
setConfigOpen(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
110
enjoy/src/renderer/components/documents/document-config-form.tsx
Normal file
110
enjoy/src/renderer/components/documents/document-config-form.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
Switch,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { TTSForm } from "@renderer/components";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const documentConfigSchema = z.object({
|
||||
config: z.object({
|
||||
autoTranslate: z.boolean(),
|
||||
autoNextSpeech: z.boolean(),
|
||||
tts: z.object({
|
||||
engine: z.string(),
|
||||
model: z.string(),
|
||||
voice: z.string(),
|
||||
language: z.string(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const DocumentConfigForm = (props: {
|
||||
config?: DocumentEType["config"];
|
||||
onSubmit: (data: z.infer<typeof documentConfigSchema>) => Promise<void>;
|
||||
}) => {
|
||||
const { config, onSubmit } = props;
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
|
||||
const form = useForm<z.infer<typeof documentConfigSchema>>({
|
||||
resolver: zodResolver(documentConfigSchema),
|
||||
defaultValues: config
|
||||
? { config }
|
||||
: {
|
||||
config: {
|
||||
autoTranslate: true,
|
||||
autoNextSpeech: true,
|
||||
tts: {
|
||||
engine: "openai",
|
||||
model: "openai/tts-1",
|
||||
language: "en-US",
|
||||
voice: "alloy",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
setSubmitting(true);
|
||||
onSubmit(data).finally(() => {
|
||||
setSubmitting(false);
|
||||
});
|
||||
})}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.autoTranslate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>{t("autoTranslate")}</FormLabel>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.autoNextSpeech"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>{t("autoNextSpeech")}</FormLabel>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TTSForm form={form} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end my-4">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting && <LoaderIcon className="w-4 h-4 animate-spin mr-2" />}
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
DocumentConfigButton,
|
||||
LoaderSpin,
|
||||
MarkdownWrapper,
|
||||
} from "@renderer/components";
|
||||
import { makeBook } from "foliate-js/view.js";
|
||||
import { EPUB } from "foliate-js/epub.js";
|
||||
import { blobToDataUrl } from "@renderer/lib/utils";
|
||||
import Turndown from "turndown";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
Button,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MenuIcon } from "lucide-react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DocumentProviderContext,
|
||||
} from "@renderer/context";
|
||||
|
||||
export const DocumentEpubRenderer = () => {
|
||||
const {
|
||||
ref,
|
||||
document,
|
||||
onSpeech,
|
||||
section,
|
||||
setSection,
|
||||
onSegmentVisible,
|
||||
content,
|
||||
setContent,
|
||||
} = useContext(DocumentProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const [book, setBook] = useState<typeof EPUB>();
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
const refreshBookMetadata = () => {
|
||||
if (!book) return;
|
||||
|
||||
if (document.title !== book.metadata.title) {
|
||||
EnjoyApp.documents.update(document.id, {
|
||||
title: book.metadata.title,
|
||||
language: book.metadata.language,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderCurrentSection = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const sectionDoc = await book.sections[section].createDocument();
|
||||
const tocItem = book.toc.find((item: any) => item.href === sectionDoc.id);
|
||||
setTitle(tocItem?.label || sectionDoc.title);
|
||||
|
||||
for (const img of sectionDoc.body.querySelectorAll("img")) {
|
||||
let image: any;
|
||||
if (img.src) {
|
||||
image = book.resources.manifest.find((resource: any) =>
|
||||
resource.href.endsWith(new URL(img.src).pathname)
|
||||
);
|
||||
} else if (img.id) {
|
||||
image = book.resources.manifest.find(
|
||||
(resource: any) => resource.id === img.id
|
||||
);
|
||||
}
|
||||
if (!image) continue;
|
||||
|
||||
const blob = new Blob([await book.loadBlob(image.href)], {
|
||||
type: image.mediaType,
|
||||
});
|
||||
const url = await blobToDataUrl(blob);
|
||||
img.setAttribute("src", url);
|
||||
}
|
||||
|
||||
const markdownContent = new Turndown().turndown(
|
||||
sectionDoc.body.innerHTML
|
||||
);
|
||||
setContent(markdownContent);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevSection = () => {
|
||||
if (section === 0) return;
|
||||
if (!book) return;
|
||||
|
||||
setSection(section - 1);
|
||||
};
|
||||
|
||||
const handleNextSection = () => {
|
||||
if (section === book.sections.length - 1) return;
|
||||
if (!book) return;
|
||||
|
||||
setSection(section + 1);
|
||||
};
|
||||
|
||||
const handleSectionClick = useCallback(
|
||||
(id: string) => {
|
||||
const sec = book.sections.findIndex((sec: any) => sec.id.endsWith(id));
|
||||
if (sec === -1) return;
|
||||
|
||||
setSection(sec);
|
||||
},
|
||||
[book]
|
||||
);
|
||||
|
||||
const handleLinkClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
handleSectionClick(new URL(e.currentTarget.href).pathname);
|
||||
e.currentTarget.blur();
|
||||
},
|
||||
[handleSectionClick]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
makeBook(document.src).then((epub: typeof EPUB) => {
|
||||
setBook(epub);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [document?.src]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!book) return;
|
||||
|
||||
refreshBookMetadata();
|
||||
renderCurrentSection();
|
||||
}, [book, section]);
|
||||
|
||||
if (!book) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div className="select-text relative">
|
||||
<div className="flex items-center justify-between space-x-2 sticky top-0 z-10 bg-background py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6">
|
||||
<MenuIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-64 max-h-96 overflow-y-auto"
|
||||
>
|
||||
{(book?.toc as any[]).map((item: any) => (
|
||||
<div key={item.href}>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-sm"
|
||||
key={item.href}
|
||||
onClick={() => handleSectionClick(item.href)}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
{(item.subitems || []).map((subitem: any) => (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer pl-4 text-sm text-muted-foreground"
|
||||
key={subitem.href}
|
||||
onClick={() => handleSectionClick(subitem.href)}
|
||||
>
|
||||
{subitem.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DocumentConfigButton document={document} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{title}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handlePrevSection}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNextSection}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="start-anchor" />
|
||||
{loading ? (
|
||||
<LoaderSpin />
|
||||
) : (
|
||||
<MarkdownWrapper
|
||||
className="mx-auto max-w-full"
|
||||
onLinkClick={handleLinkClick}
|
||||
onSegmentVisible={onSegmentVisible}
|
||||
autoTranslate={document.config.autoTranslate}
|
||||
onSpeech={onSpeech}
|
||||
translatable={true}
|
||||
section={section}
|
||||
>
|
||||
{content}
|
||||
</MarkdownWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Readability } from "@mozilla/readability";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
DocumentConfigButton,
|
||||
LoaderSpin,
|
||||
MarkdownWrapper,
|
||||
} from "@renderer/components";
|
||||
import Turndown from "turndown";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DocumentProviderContext,
|
||||
} from "@/renderer/context";
|
||||
import { Button } from "../ui";
|
||||
import { LinkIcon } from "lucide-react";
|
||||
|
||||
export const DocumentHtmlRenderer = () => {
|
||||
const { document, onSpeech, onSegmentVisible, content, setContent } =
|
||||
useContext(DocumentProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [title, setTitle] = useState<string>("");
|
||||
|
||||
const fetchContent = async () => {
|
||||
const res = await fetch(document.src);
|
||||
const text = await res.text();
|
||||
const doc = new DOMParser().parseFromString(text, "text/html");
|
||||
setTitle(doc.title || document.title);
|
||||
const readability = new Readability(doc);
|
||||
const article = readability.parse();
|
||||
const markdownContent = new Turndown().turndown(article.content);
|
||||
setContent(markdownContent);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchContent();
|
||||
}, [document.src]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!title) return;
|
||||
|
||||
if (document.title !== title) {
|
||||
EnjoyApp.documents.update(document.id, {
|
||||
title,
|
||||
});
|
||||
}
|
||||
}, [title]);
|
||||
|
||||
if (!content) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div className="select-text relative">
|
||||
<div className="flex items-center justify-between space-x-2 sticky top-0 z-10 bg-background py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentConfigButton document={document} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground max-w-full truncate">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{document.metadata?.source && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-6 h-6"
|
||||
onClick={() => {
|
||||
EnjoyApp.shell.openExternal(document.metadata.source);
|
||||
}}
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownWrapper
|
||||
className="mx-auto max-w-full"
|
||||
autoTranslate={document.config.autoTranslate}
|
||||
onSpeech={onSpeech}
|
||||
onSegmentVisible={onSegmentVisible}
|
||||
translatable={true}
|
||||
>
|
||||
{content}
|
||||
</MarkdownWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
238
enjoy/src/renderer/components/documents/document-player.tsx
Normal file
238
enjoy/src/renderer/components/documents/document-player.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DocumentProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { useSpeech } from "@renderer/hooks";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Button, toast } from "@renderer/components/ui";
|
||||
import {
|
||||
AudioPlayer,
|
||||
LoaderSpin,
|
||||
WavesurferPlayer,
|
||||
} from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
LoaderIcon,
|
||||
LocateFixedIcon,
|
||||
RefreshCcwIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export const DocumentPlayer = () => {
|
||||
const {
|
||||
ref,
|
||||
document,
|
||||
section,
|
||||
togglePlayingSegment,
|
||||
locateSegment,
|
||||
playingSegment,
|
||||
nextSegment,
|
||||
} = useContext(DocumentProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [speech, setSpeech] = useState<SpeechType | null>(null);
|
||||
const [speeching, setSpeeching] = useState(false);
|
||||
const [resourcing, setResourcing] = useState(false);
|
||||
const { tts } = useSpeech();
|
||||
const [audio, setAudio] = useState<AudioType | null>(null);
|
||||
|
||||
const startShadow = async () => {
|
||||
if (!speech) return;
|
||||
|
||||
const audio = await EnjoyApp.audios.findOne({
|
||||
md5: speech.md5,
|
||||
});
|
||||
|
||||
if (audio) {
|
||||
setAudio(audio);
|
||||
} else {
|
||||
setResourcing(true);
|
||||
EnjoyApp.audios
|
||||
.create(speech.filePath, {
|
||||
name: `[S${section}P${playingSegment.index}]-${document.title}`,
|
||||
originalText: speech.text,
|
||||
})
|
||||
.then((audio) => setAudio(audio))
|
||||
.catch((err) => toast.error(err.message))
|
||||
.finally(() => setResourcing(false));
|
||||
}
|
||||
};
|
||||
|
||||
const findOrCreateSpeech = async () => {
|
||||
if (typeof section !== "number" || !playingSegment) return;
|
||||
|
||||
const existingSpeech = await EnjoyApp.speeches.findOne({
|
||||
sourceId: document.id,
|
||||
sourceType: "Document",
|
||||
section,
|
||||
segment: playingSegment.index,
|
||||
});
|
||||
|
||||
if (existingSpeech) {
|
||||
setSpeech(existingSpeech);
|
||||
} else {
|
||||
createSpeech(playingSegment);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSpeech = async () => {
|
||||
if (speech) {
|
||||
await EnjoyApp.speeches.delete(speech.id);
|
||||
setSpeech(null);
|
||||
}
|
||||
findOrCreateSpeech();
|
||||
};
|
||||
|
||||
const createSpeech = async (segment: { index: number; text: string }) => {
|
||||
if (speeching) return;
|
||||
const { index, text } = segment;
|
||||
|
||||
setSpeeching(true);
|
||||
tts({
|
||||
sourceId: document.id,
|
||||
sourceType: "Document",
|
||||
section,
|
||||
segment: index,
|
||||
text,
|
||||
configuration: document.config.tts,
|
||||
})
|
||||
.then((res) => {
|
||||
setSpeech(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setSpeeching(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof section !== "number" || !playingSegment) return;
|
||||
findOrCreateSpeech();
|
||||
|
||||
return () => {
|
||||
setSpeech(null);
|
||||
setAudio(null);
|
||||
};
|
||||
}, [playingSegment]);
|
||||
|
||||
// Close the player when the section changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
togglePlayingSegment(null);
|
||||
};
|
||||
}, [section]);
|
||||
|
||||
if (typeof section !== "number" || !playingSegment) {
|
||||
return <LoaderSpin />;
|
||||
}
|
||||
|
||||
if (speeching) {
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center h-full">
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<LoaderIcon className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("creatingSpeech")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resourcing) {
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center h-full">
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<LoaderIcon className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("preparingAudio")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!speech) {
|
||||
return (
|
||||
<div className="flex justify-center items-center space-x-4 h-full">
|
||||
<Button onClick={findOrCreateSpeech}>{t("textToSpeech")}</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => togglePlayingSegment(null)}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!audio) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 justify-center items-center h-full">
|
||||
<WavesurferPlayer
|
||||
id={speech.id}
|
||||
src={speech.src}
|
||||
autoplay={true}
|
||||
onEnded={() => {
|
||||
if (nextSegment) {
|
||||
togglePlayingSegment(nextSegment.id);
|
||||
}
|
||||
}}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
<div className="flex justify-center space-x-4">
|
||||
<Button
|
||||
data-tooltip-content={t("refreshSpeech")}
|
||||
data-tooltip-place="bottom"
|
||||
data-tooltip-id="global-tooltip"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={refreshSpeech}
|
||||
>
|
||||
<RefreshCcwIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
data-tooltip-content={t("locateParagraph")}
|
||||
data-tooltip-place="bottom"
|
||||
data-tooltip-id="global-tooltip"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => locateSegment(playingSegment.id)}
|
||||
>
|
||||
<LocateFixedIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={startShadow}>{t("shadowingExercise")}</Button>
|
||||
<Button
|
||||
data-tooltip-content={t("close")}
|
||||
data-tooltip-place="bottom"
|
||||
data-tooltip-id="global-tooltip"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => togglePlayingSegment(null)}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex space-x-1 items-center mb-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => togglePlayingSegment(null)}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-sm line-clamp-1">{audio.name}</span>
|
||||
</div>
|
||||
<AudioPlayer id={audio.id} md5={audio.md5} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useContext, useEffect } from "react";
|
||||
import {
|
||||
DocumentConfigButton,
|
||||
LoaderSpin,
|
||||
MarkdownWrapper,
|
||||
} from "@renderer/components";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DocumentProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { Button } from "@renderer/components/ui";
|
||||
import { LinkIcon } from "lucide-react";
|
||||
|
||||
export const DocumentTextRenderer = () => {
|
||||
const { document, onSpeech, onSegmentVisible, content, setContent } =
|
||||
useContext(DocumentProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const fetchContent = async () => {
|
||||
const res = await fetch(document.src);
|
||||
console.log("res", res);
|
||||
const text = await res.text();
|
||||
console.log("text", text);
|
||||
setContent(text);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchContent();
|
||||
}, [document.src]);
|
||||
|
||||
if (!content) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div className="select-text relative">
|
||||
<div className="flex items-center justify-between space-x-2 sticky top-0 z-10 bg-background py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentConfigButton document={document} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground max-w-full truncate">
|
||||
{document.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{document.metadata?.source && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-6 h-6"
|
||||
onClick={() => {
|
||||
EnjoyApp.shell.openExternal(document.metadata.source);
|
||||
}}
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownWrapper
|
||||
className="mx-auto max-w-full"
|
||||
autoTranslate={document.config.autoTranslate}
|
||||
onSpeech={onSpeech}
|
||||
onSegmentVisible={onSegmentVisible}
|
||||
translatable={true}
|
||||
>
|
||||
{content}
|
||||
</MarkdownWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { DocumentCard } from "@renderer/components";
|
||||
import { Button, ScrollArea, ScrollBar } from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useState, useContext, useEffect } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
|
||||
export const DocumentsSegment = () => {
|
||||
const [documents, setDocuments] = useState<DocumentEType[]>([]);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const fetchDocuments = async () => {
|
||||
EnjoyApp.documents.findAll({ limit: 10 }).then((docs) => {
|
||||
setDocuments(docs);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, []);
|
||||
|
||||
if (documents.length == 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight capitalize">
|
||||
{t("addedDocuments")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="ml-auto mr-4">
|
||||
<Link to="/documents">
|
||||
<Button variant="link" className="capitalize">
|
||||
{t("seeMore")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea>
|
||||
<div className="flex items-center space-x-4 pb-4">
|
||||
{documents.map((document) => (
|
||||
<DocumentCard
|
||||
key={document.id}
|
||||
document={document}
|
||||
className="w-48"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
9
enjoy/src/renderer/components/documents/index.ts
Normal file
9
enjoy/src/renderer/components/documents/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./document-card";
|
||||
export * from "./document-html-renderer";
|
||||
export * from "./document-text-renderer";
|
||||
export * from "./document-epub-renderer";
|
||||
export * from "./document-player";
|
||||
export * from "./document-config-form";
|
||||
export * from "./document-add-button";
|
||||
export * from "./document-config-button";
|
||||
export * from "./documents-segment";
|
||||
@@ -3,6 +3,7 @@ export * from "./chats";
|
||||
export * from "./conversations";
|
||||
export * from "./copilots";
|
||||
export * from "./courses";
|
||||
export * from "./documents";
|
||||
export * from "./llm-chats";
|
||||
export * from "./meanings";
|
||||
export * from "./messages";
|
||||
|
||||
@@ -547,68 +547,7 @@ export const MediaCurrentRecording = () => {
|
||||
];
|
||||
|
||||
if (isRecording || isPaused) {
|
||||
return (
|
||||
<div className="w-full h-full flex justify-center items-center gap-4 border rounded-xl shadow">
|
||||
<LiveAudioVisualizer
|
||||
mediaRecorder={mediaRecorder}
|
||||
barWidth={2}
|
||||
gap={2}
|
||||
width={480}
|
||||
height="100%"
|
||||
fftSize={512}
|
||||
maxDecibels={-10}
|
||||
minDecibels={-80}
|
||||
smoothingTimeConstant={0.4}
|
||||
/>
|
||||
<span className="serif text-muted-foreground text-sm">
|
||||
{Math.floor(recordingTime / 60)}:
|
||||
{String(recordingTime % 60).padStart(2, "0")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("cancel")}
|
||||
onClick={cancelRecording}
|
||||
className="rounded-full shadow w-8 h-8 bg-red-500 hover:bg-red-600"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
>
|
||||
<XIcon fill="white" className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={togglePauseResume}
|
||||
className="rounded-full shadow w-8 h-8"
|
||||
size="icon"
|
||||
>
|
||||
{isPaused ? (
|
||||
<PlayIcon
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("continue")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
) : (
|
||||
<PauseIcon
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("pause")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
id="media-record-button"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("finish")}
|
||||
onClick={stopRecording}
|
||||
className="rounded-full bg-green-500 hover:bg-green-600 shadow w-8 h-8"
|
||||
size="icon"
|
||||
>
|
||||
<CheckIcon className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <MediaRecorder />;
|
||||
}
|
||||
|
||||
if (!currentRecording?.src)
|
||||
@@ -801,3 +740,108 @@ export const MediaRecordButton = () => {
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const MediaRecorder = () => {
|
||||
const {
|
||||
mediaRecorder,
|
||||
recordingTime,
|
||||
isRecording,
|
||||
isPaused,
|
||||
cancelRecording,
|
||||
togglePauseResume,
|
||||
stopRecording,
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
const ref = useRef(null);
|
||||
const [size, setSize] = useState<{ width: number; height: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const calContainerSize = () => {
|
||||
const size = ref?.current?.getBoundingClientRect();
|
||||
if (!size) return;
|
||||
|
||||
setSize({ width: size.width, height: size.height });
|
||||
};
|
||||
const debouncedCalContainerSize = debounce(calContainerSize, 100);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref?.current) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
debouncedCalContainerSize();
|
||||
});
|
||||
observer.observe(ref.current);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="w-full h-full flex justify-center items-center gap-4 border rounded-xl shadow"
|
||||
>
|
||||
{size?.width && size?.width > 1024 && (
|
||||
<LiveAudioVisualizer
|
||||
mediaRecorder={mediaRecorder}
|
||||
barWidth={2}
|
||||
gap={2}
|
||||
width={480}
|
||||
height="100%"
|
||||
fftSize={512}
|
||||
maxDecibels={-10}
|
||||
minDecibels={-80}
|
||||
smoothingTimeConstant={0.4}
|
||||
/>
|
||||
)}
|
||||
<span className="serif text-muted-foreground text-sm">
|
||||
{Math.floor(recordingTime / 60)}:
|
||||
{String(recordingTime % 60).padStart(2, "0")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("cancel")}
|
||||
onClick={cancelRecording}
|
||||
className="rounded-full shadow w-8 h-8 bg-red-500 hover:bg-red-600"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
>
|
||||
<XIcon fill="white" className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={togglePauseResume}
|
||||
className="rounded-full shadow w-8 h-8"
|
||||
size="icon"
|
||||
>
|
||||
{isPaused ? (
|
||||
<PlayIcon
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("continue")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
) : (
|
||||
<PauseIcon
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("pause")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
id="media-record-button"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("finish")}
|
||||
onClick={stopRecording}
|
||||
className="rounded-full bg-green-500 hover:bg-green-600 shadow w-8 h-8"
|
||||
size="icon"
|
||||
>
|
||||
<CheckIcon className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -269,6 +269,7 @@ export const MediaWaveform = () => {
|
||||
variant={`${action.active ? "secondary" : "ghost"}`}
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={action.label}
|
||||
data-tooltip-place="left"
|
||||
className="relative p-0 w-full h-full rounded-none"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
@@ -284,6 +285,7 @@ export const MediaWaveform = () => {
|
||||
size="icon"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("more")}
|
||||
data-tooltip-place="left"
|
||||
className="relative p-0 w-full h-full rounded-none"
|
||||
>
|
||||
<MoreHorizontalIcon className="w-4 h-4" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MediaRecordings,
|
||||
} from "@renderer/components";
|
||||
import {
|
||||
Button,
|
||||
ScrollArea,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -14,9 +15,15 @@ import {
|
||||
TabsTrigger,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
import { ArrowLeftRightIcon } from "lucide-react";
|
||||
|
||||
export const MediaLeftPanel = () => {
|
||||
const { media, decoded } = useContext(MediaShadowProviderContext);
|
||||
export const MediaLeftPanel = (props: {
|
||||
className?: string;
|
||||
setDisplayPanel?: (displayPanel: "left" | "right" | null) => void;
|
||||
}) => {
|
||||
const { className, setDisplayPanel } = props;
|
||||
const { media, decoded, layout } = useContext(MediaShadowProviderContext);
|
||||
const [tab, setTab] = useState("provider");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,36 +35,53 @@ export const MediaLeftPanel = () => {
|
||||
if (!media) return null;
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={setTab} className="h-full flex flex-col">
|
||||
<TabsList
|
||||
className={`grid gap-4 rounded-none w-full px-4 ${
|
||||
media?.mediaType === "Video" ? "grid-cols-4" : "grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{media?.mediaType === "Video" && (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
className={cn("h-full flex flex-col", className)}
|
||||
>
|
||||
<div className="flex items-center bg-muted px-4">
|
||||
{layout === "compact" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-2"
|
||||
onClick={() => setDisplayPanel?.("right")}
|
||||
>
|
||||
<ArrowLeftRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<TabsList
|
||||
className={`grid gap-4 rounded-none w-full ${
|
||||
media?.mediaType === "Video" ? "grid-cols-4" : "grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{media?.mediaType === "Video" && (
|
||||
<TabsTrigger
|
||||
value="provider"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("player")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value="provider"
|
||||
value="transcription"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("player")}
|
||||
{t("transcription")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value="transcription"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("transcription")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="recordings"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("myRecordings")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="capitalize block truncate px-1">
|
||||
{t("mediaInfo")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsTrigger
|
||||
value="recordings"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("myRecordings")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="capitalize block truncate px-1">
|
||||
{t("mediaInfo")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 relative">
|
||||
<TabsContent forceMount={true} value="provider">
|
||||
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
import { TimelineEntry } from "echogarden/dist/utilities/Timeline.d.js";
|
||||
import { milisecondsToTimestamp } from "@/utils";
|
||||
import { toast } from "@renderer/components/ui";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
export const MediaProvider = () => {
|
||||
export const MediaProvider = (props: { className?: string }) => {
|
||||
const { className } = props;
|
||||
const { theme } = useContext(ThemeProviderContext);
|
||||
const { media, setMediaProvider, setDecodeError, transcription } = useContext(
|
||||
MediaShadowProviderContext
|
||||
@@ -63,7 +65,7 @@ export const MediaProvider = () => {
|
||||
if (!media?.src) return null;
|
||||
|
||||
return (
|
||||
<div className="px-2 py-4">
|
||||
<div className={cn("px-2 py-4", className)}>
|
||||
<VidstackMediaPlayer
|
||||
ref={player}
|
||||
className="my-auto"
|
||||
|
||||
@@ -50,6 +50,7 @@ const LoadingContent = () => {
|
||||
transcribingProgress,
|
||||
transcribingOutput,
|
||||
generateTranscription,
|
||||
onCancel,
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
if (decoded) {
|
||||
// Decoded and transcription created but not ready
|
||||
@@ -109,7 +110,12 @@ const LoadingContent = () => {
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<Button variant="secondary" onClick={() => navigate(-1)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onCancel ? onCancel() : navigate(-1);
|
||||
}}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
@@ -132,7 +138,12 @@ const LoadingContent = () => {
|
||||
)}
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<Button variant="secondary" onClick={() => navigate(-1)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onCancel ? onCancel() : navigate(-1);
|
||||
}}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -173,6 +173,7 @@ export const MediaCaptionActions = (props: {
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("displayIpa")}
|
||||
data-tooltip-place="left"
|
||||
onClick={() => setDisplayIpa(!displayIpa)}
|
||||
>
|
||||
<SpeechIcon className="w-4 h-4" />
|
||||
@@ -184,6 +185,7 @@ export const MediaCaptionActions = (props: {
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("displayNotes")}
|
||||
data-tooltip-place="left"
|
||||
onClick={() => setDisplayNotes(!displayNotes)}
|
||||
>
|
||||
<NotebookPenIcon className="w-4 h-4" />
|
||||
@@ -195,6 +197,7 @@ export const MediaCaptionActions = (props: {
|
||||
<Button
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("sendToAIAssistant")}
|
||||
data-tooltip-place="left"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="p-0 w-8 h-8 rounded-full"
|
||||
@@ -210,6 +213,7 @@ export const MediaCaptionActions = (props: {
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("copyText")}
|
||||
data-tooltip-place="left"
|
||||
onClick={() => {
|
||||
if (displayIpa) {
|
||||
const text = caption.timeline
|
||||
@@ -245,6 +249,7 @@ export const MediaCaptionActions = (props: {
|
||||
<CopyIcon
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("copyText")}
|
||||
data-tooltip-place="left"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
)}
|
||||
@@ -256,6 +261,7 @@ export const MediaCaptionActions = (props: {
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("downloadSegment")}
|
||||
data-tooltip-place="left"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useState, useContext, useRef } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { MediaShadowProviderContext } from "@renderer/context";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import {
|
||||
Button,
|
||||
ScrollArea,
|
||||
Tabs,
|
||||
TabsList,
|
||||
@@ -22,8 +20,14 @@ import {
|
||||
MediaCaptionNote,
|
||||
MediaCaptionTranslation,
|
||||
} from "@renderer/components";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
import { ArrowLeftRightIcon } from "lucide-react";
|
||||
|
||||
export const MediaRightPanel = () => {
|
||||
export const MediaRightPanel = (props: {
|
||||
className?: string;
|
||||
setDisplayPanel?: (displayPanel: "left" | "right" | null) => void;
|
||||
}) => {
|
||||
const { className, setDisplayPanel } = props;
|
||||
const {
|
||||
currentSegmentIndex,
|
||||
currentTime,
|
||||
@@ -34,6 +38,7 @@ export const MediaRightPanel = () => {
|
||||
editingRegion,
|
||||
setEditingRegion,
|
||||
setTranscriptionDraft,
|
||||
layout,
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
||||
const [selectedIndices, setSelectedIndices] = useState<number[]>([]);
|
||||
@@ -257,33 +262,45 @@ export const MediaRightPanel = () => {
|
||||
if (!caption) return null;
|
||||
|
||||
return (
|
||||
<div className="h-full relative">
|
||||
<div className={cn("h-full relative", className)}>
|
||||
<div className="flex-1 font-serif h-full">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value)}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<TabsList className="grid grid-cols-3 gap-4 rounded-none w-full px-4">
|
||||
<TabsTrigger
|
||||
value="translation"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.translation")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="note"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.note")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="analysis"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.analysis")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center bg-muted px-4">
|
||||
{layout === "compact" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-2"
|
||||
onClick={() => setDisplayPanel?.("left")}
|
||||
>
|
||||
<ArrowLeftRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<TabsList className="grid grid-cols-3 gap-4 rounded-none w-full">
|
||||
<TabsTrigger
|
||||
value="translation"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.translation")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="note"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.note")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="analysis"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.analysis")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 relative">
|
||||
<MediaCaption
|
||||
caption={caption}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { MediaShadowProviderContext } from "@renderer/context";
|
||||
import {
|
||||
MediaLoadingModal,
|
||||
MediaRightPanel,
|
||||
MediaLeftPanel,
|
||||
MediaBottomPanel,
|
||||
MediaProvider,
|
||||
} from "@renderer/components";
|
||||
import {
|
||||
Button,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@renderer/components/ui";
|
||||
import { useContext, useState } from "react";
|
||||
import { RefreshCcwDotIcon } from "lucide-react";
|
||||
|
||||
export const MediaShadowPlayer = () => {
|
||||
return (
|
||||
@@ -18,15 +23,7 @@ export const MediaShadowPlayer = () => {
|
||||
direction="vertical"
|
||||
>
|
||||
<ResizablePanel defaultSize={60} minSize={50}>
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel defaultSize={40} minSize={20}>
|
||||
<MediaLeftPanel />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel minSize={20}>
|
||||
<MediaRightPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<TopPanel />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
|
||||
@@ -38,3 +35,37 @@ export const MediaShadowPlayer = () => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TopPanel = () => {
|
||||
const { layout } = useContext(MediaShadowProviderContext);
|
||||
const [displayPanel, setDisplayPanel] = useState<"left" | "right" | null>(
|
||||
"right"
|
||||
);
|
||||
|
||||
if (layout === "normal") {
|
||||
return (
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel id="left-panel" order={0} defaultSize={40} minSize={20}>
|
||||
<MediaLeftPanel />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel id="right-panel" order={1} minSize={20}>
|
||||
<MediaRightPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<MediaLeftPanel
|
||||
className={displayPanel === "left" ? "flex-1" : "invisible fixed"}
|
||||
setDisplayPanel={setDisplayPanel}
|
||||
/>
|
||||
<MediaRightPanel
|
||||
className={displayPanel === "right" ? "flex-1" : "invisible fixed"}
|
||||
setDisplayPanel={setDisplayPanel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ import { t } from "i18next";
|
||||
import { useContext } from "react";
|
||||
import { AISettingsProviderContext } from "@renderer/context";
|
||||
|
||||
export const ChatGPTForm = (props: { form: ReturnType<typeof useForm> }) => {
|
||||
export const GPTForm = (props: { form: ReturnType<typeof useForm> }) => {
|
||||
const { form } = props;
|
||||
const { gptProviders } = useContext(AISettingsProviderContext);
|
||||
|
||||
@@ -12,3 +12,5 @@ export * from "./page-placeholder";
|
||||
export * from "./universal-player";
|
||||
export * from "./sidebar";
|
||||
export * from "./wavesurfer-player";
|
||||
export * from "./tts-form";
|
||||
export * from "./gpt-form";
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
import Markdown from "react-markdown";
|
||||
import Markdown, { defaultUrlTransform } from "react-markdown";
|
||||
import { visitParents } from "unist-util-visit-parents";
|
||||
import { Sentence } from "@renderer/components";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
memo,
|
||||
useContext,
|
||||
} from "react";
|
||||
import {
|
||||
LanguagesIcon,
|
||||
LoaderIcon,
|
||||
PlayIcon,
|
||||
RefreshCwIcon,
|
||||
} from "lucide-react";
|
||||
import { Button, toast } from "@renderer/components/ui";
|
||||
import { useIntersectionObserver } from "@uidotdev/usehooks";
|
||||
import { md5 } from "js-md5";
|
||||
import { AppSettingsProviderContext } from "@/renderer/context";
|
||||
import { useAiCommand } from "@/renderer/hooks";
|
||||
|
||||
function rehypeWrapText() {
|
||||
return function wrapTextTransform(tree: any) {
|
||||
visitParents(tree, "text", (node, ancestors) => {
|
||||
const parent = ancestors.at(-1);
|
||||
|
||||
if (parent.tagName !== "vocabulary") {
|
||||
if (parent.tagName !== "vocabulary" && parent.tagName !== "a") {
|
||||
node.type = "element";
|
||||
node.tagName = "vocabulary";
|
||||
node.properties = { text: node.value };
|
||||
@@ -18,35 +38,244 @@ function rehypeWrapText() {
|
||||
};
|
||||
}
|
||||
|
||||
export const MarkdownWrapper = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<Markdown
|
||||
className={cn("prose dark:prose-invert", className)}
|
||||
rehypePlugins={[rehypeWrapText]}
|
||||
components={{
|
||||
a({ node, children, ...props }) {
|
||||
// Memoize the Segment component
|
||||
const Segment = memo(
|
||||
({
|
||||
tag: Tag,
|
||||
index,
|
||||
children,
|
||||
onSegmentVisible,
|
||||
onSpeech,
|
||||
autoTranslate,
|
||||
translatable,
|
||||
section,
|
||||
...props
|
||||
}: {
|
||||
tag: "h1" | "h2" | "h3" | "h4" | "h5" | "p";
|
||||
index: number;
|
||||
children: any;
|
||||
onSegmentVisible?: (id: string) => void;
|
||||
onSpeech?: (id: string) => void;
|
||||
autoTranslate?: boolean;
|
||||
translatable?: boolean;
|
||||
section?: number;
|
||||
}) => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { translate } = useAiCommand();
|
||||
const [translating, setTranslating] = useState(false);
|
||||
const [translation, setTranslation] = useState<string>("");
|
||||
|
||||
const [ref, entry] = useIntersectionObserver({
|
||||
threshold: 0,
|
||||
root: null,
|
||||
rootMargin: "0px",
|
||||
});
|
||||
|
||||
const toggleTranslation = () => {
|
||||
if (translation) {
|
||||
setTranslation("");
|
||||
} else {
|
||||
handleTranslate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTranslate = async (force = false) => {
|
||||
if (translating) return;
|
||||
|
||||
const content = entry.target
|
||||
?.querySelector(".segment-content")
|
||||
?.textContent?.trim();
|
||||
if (!content) return;
|
||||
|
||||
const md5Hash = md5(content);
|
||||
|
||||
const cacheKey = `translate-${md5Hash}`;
|
||||
const cached = await EnjoyApp.cacheObjects.get(cacheKey);
|
||||
if (cached && !force) {
|
||||
setTranslation(cached);
|
||||
} else {
|
||||
setTranslating(true);
|
||||
setTranslation("");
|
||||
translate(content, cacheKey)
|
||||
.then((result) => {
|
||||
setTranslation(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setTranslating(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!entry?.target) return "";
|
||||
return entry.target?.textContent?.trim();
|
||||
}, [entry]);
|
||||
|
||||
const id = `segment-${index}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!onSegmentVisible) return;
|
||||
if (entry?.isIntersecting) {
|
||||
onSegmentVisible(`segment-${index}`);
|
||||
if (autoTranslate) {
|
||||
handleTranslate();
|
||||
}
|
||||
}
|
||||
}, [entry?.isIntersecting, autoTranslate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tag
|
||||
id={`segment-${index}`}
|
||||
ref={ref}
|
||||
data-index={index}
|
||||
data-section={section}
|
||||
className="segment"
|
||||
>
|
||||
<span className="flex items-center gap-2 opacity-50 hover:opacity-100">
|
||||
{content && (onSpeech || translatable) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
#{index + 1}
|
||||
</span>
|
||||
)}
|
||||
{onSpeech && content && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onSpeech(id);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<PlayIcon className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
{translatable && content && (
|
||||
<Button
|
||||
onClick={toggleTranslation}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
{translating ? (
|
||||
<LoaderIcon className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<LanguagesIcon className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<span className="segment-content">{children}</span>
|
||||
</Tag>
|
||||
{translation && (
|
||||
<Tag id={`translation-${index}`}>
|
||||
{translation}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-4 h-4 opacity-50 hover:opacity-100"
|
||||
onClick={() => handleTranslate(true)}
|
||||
>
|
||||
<RefreshCwIcon className="w-3 h-3" />
|
||||
</Button>
|
||||
</Tag>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Wrap MarkdownWrapper with React.memo and memoize callbacks
|
||||
export const MarkdownWrapper = memo(
|
||||
({
|
||||
children,
|
||||
className,
|
||||
onLinkClick,
|
||||
onSpeech,
|
||||
onSegmentVisible,
|
||||
translatable = false,
|
||||
autoTranslate,
|
||||
section,
|
||||
...props
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
onLinkClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
onSpeech?: (id: string) => void;
|
||||
onSegmentVisible?: (id: string) => void;
|
||||
translatable?: boolean;
|
||||
autoTranslate?: boolean;
|
||||
section?: number;
|
||||
}) => {
|
||||
// Memoize component callbacks
|
||||
const handleLinkClick = useCallback(onLinkClick, [onLinkClick]);
|
||||
|
||||
const components = useMemo(() => {
|
||||
let segmentIndex = 0;
|
||||
const HEADER_COMPONENTS = ["h1", "h2", "h3", "h4", "h5", "p"] as const;
|
||||
|
||||
const headerComponents = Object.fromEntries(
|
||||
HEADER_COMPONENTS.map((tag) => [
|
||||
tag,
|
||||
({ node, children, ...props }: any) => (
|
||||
<Segment
|
||||
tag={tag}
|
||||
index={segmentIndex++}
|
||||
onSegmentVisible={onSegmentVisible}
|
||||
onSpeech={onSpeech}
|
||||
autoTranslate={autoTranslate}
|
||||
translatable={translatable}
|
||||
section={section}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Segment>
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
return {
|
||||
a({ node, children, ...props }: any) {
|
||||
try {
|
||||
new URL(props.href ?? "");
|
||||
props.target = "_blank";
|
||||
props.rel = "noopener noreferrer";
|
||||
} catch (e) {}
|
||||
|
||||
return <a {...props}>{children}</a>;
|
||||
return (
|
||||
<a {...props} onClick={handleLinkClick}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
vocabulary({ node, children, ...props }) {
|
||||
vocabulary({ node, children, ...props }: any) {
|
||||
return <Sentence sentence={props.text} />;
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Markdown>
|
||||
);
|
||||
};
|
||||
...headerComponents,
|
||||
};
|
||||
}, [handleLinkClick, onSegmentVisible, onSpeech, autoTranslate, section]);
|
||||
|
||||
return (
|
||||
<Markdown
|
||||
className={cn("prose dark:prose-invert", className)}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeWrapText]}
|
||||
urlTransform={(url) => {
|
||||
if (url.startsWith("blob:") || url.startsWith("data:")) {
|
||||
return url;
|
||||
}
|
||||
return defaultUrlTransform(url);
|
||||
}}
|
||||
components={components}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MarkdownWrapper.displayName = "MarkdownWrapper";
|
||||
|
||||
@@ -155,10 +155,10 @@ export const Sidebar = () => {
|
||||
/>
|
||||
|
||||
<SidebarItem
|
||||
href="/stories"
|
||||
label={t("sidebar.stories")}
|
||||
tooltip={t("sidebar.stories")}
|
||||
active={activeTab.startsWith("/stories")}
|
||||
href="/documents"
|
||||
label={t("sidebar.documents")}
|
||||
tooltip={t("sidebar.documents")}
|
||||
active={activeTab.startsWith("/documents")}
|
||||
Icon={NewspaperIcon}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { t } from "i18next";
|
||||
import { useContext } from "react";
|
||||
import { AISettingsProviderContext } from "@renderer/context";
|
||||
|
||||
export const ChatTTSForm = (props: { form: ReturnType<typeof useForm> }) => {
|
||||
export const TTSForm = (props: { form: ReturnType<typeof useForm> }) => {
|
||||
const { form } = props;
|
||||
const { ttsProviders } = useContext(AISettingsProviderContext);
|
||||
|
||||
@@ -22,6 +22,7 @@ export const WavesurferPlayer = (props: {
|
||||
pitchContourOptions?: any;
|
||||
className?: string;
|
||||
autoplay?: boolean;
|
||||
onEnded?: () => void;
|
||||
}) => {
|
||||
const {
|
||||
id,
|
||||
@@ -33,6 +34,7 @@ export const WavesurferPlayer = (props: {
|
||||
pitchContourOptions,
|
||||
className = "",
|
||||
autoplay = false,
|
||||
onEnded,
|
||||
} = props;
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -95,6 +97,9 @@ export const WavesurferPlayer = (props: {
|
||||
wavesurfer.on("pause", () => {
|
||||
setIsPlaying(false);
|
||||
}),
|
||||
wavesurfer.on("finish", () => {
|
||||
onEnded && onEnded();
|
||||
}),
|
||||
wavesurfer.on("timeupdate", (time: number) => {
|
||||
setCurrentTime(time);
|
||||
onSetCurrentTime && onSetCurrentTime(time);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ChevronLeftIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import { Button } from "@renderer/components/ui";
|
||||
import uniq from "lodash/uniq";
|
||||
import Mark from "mark.js";
|
||||
import { Vocabulary } from "@/renderer/components";
|
||||
import { Vocabulary } from "@renderer/components";
|
||||
|
||||
export const StoryViewer = (props: {
|
||||
story: Partial<StoryType> & Partial<CreateStoryParamsType>;
|
||||
|
||||
@@ -62,7 +62,7 @@ export const VideosSegment = (props: { limit?: number }) => {
|
||||
|
||||
{videos.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
|
||||
<MediaAddButton />
|
||||
<MediaAddButton type="Video" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea>
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
import { memo } from "react";
|
||||
import { Vocabulary } from "@renderer/components";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
export const Sentence = ({
|
||||
sentence,
|
||||
className,
|
||||
}: {
|
||||
sentence: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
// split by space or punctuation
|
||||
// Sentence may be in other languages, so need to handle only English words
|
||||
let words = sentence.split(/(\s+|[a-zA-Z]+)/);
|
||||
export const Sentence = memo(
|
||||
({ sentence, className }: { sentence: string; className?: string }) => {
|
||||
// split by space or punctuation
|
||||
// Sentence may be in other languages, so need to handle only English words
|
||||
let words = sentence.split(/(\s+|[a-zA-Z]+)/);
|
||||
|
||||
return (
|
||||
<span className={cn("break-words align-middle", className)}>
|
||||
{words.map((word, index) => {
|
||||
return (
|
||||
<span key={index}>
|
||||
{word.match(/[a-zA-Z]+/) ? (
|
||||
<Vocabulary word={word} context={sentence} />
|
||||
) : (
|
||||
word
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<span className={cn("break-words align-middle", className)}>
|
||||
{words.map((word, index) => {
|
||||
return (
|
||||
<span key={index}>
|
||||
{word.match(/[a-zA-Z]+/) ? (
|
||||
<Vocabulary word={word} context={sentence} />
|
||||
) : (
|
||||
word
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Sentence.displayName = "Sentence";
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useContext, useState, memo } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
|
||||
export const Vocabulary = ({
|
||||
word,
|
||||
context,
|
||||
children,
|
||||
}: {
|
||||
word: string;
|
||||
context?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
let [timer, setTimer] = useState<ReturnType<typeof setTimeout>>();
|
||||
const { vocabularyConfig, EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
export const Vocabulary = memo(
|
||||
({
|
||||
word,
|
||||
context,
|
||||
children,
|
||||
}: {
|
||||
word: string;
|
||||
context?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
let [timer, setTimer] = useState<ReturnType<typeof setTimeout>>();
|
||||
const { vocabularyConfig, EnjoyApp } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
|
||||
const handleLookup = (e: any) => {
|
||||
if (!context) {
|
||||
context = e.target?.parentElement
|
||||
.closest(".sentence, h2, p, div")
|
||||
?.textContent?.trim();
|
||||
}
|
||||
|
||||
const { x, bottom: y } = e.target.getBoundingClientRect();
|
||||
const _word = word.replace(/[^\w\s]|_/g, "");
|
||||
|
||||
EnjoyApp.lookup(_word, context, { x, y });
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: any) => {
|
||||
let _timer = setTimeout(() => {
|
||||
const handleLookup = (e: any) => {
|
||||
if (!context) {
|
||||
context = e.target?.parentElement
|
||||
.closest(".sentence, h2, p, div")
|
||||
@@ -38,23 +27,40 @@ export const Vocabulary = ({
|
||||
const _word = word.replace(/[^\w\s]|_/g, "");
|
||||
|
||||
EnjoyApp.lookup(_word, context, { x, y });
|
||||
}, 800);
|
||||
};
|
||||
|
||||
setTimer(_timer);
|
||||
};
|
||||
const handleMouseEnter = (e: any) => {
|
||||
let _timer = setTimeout(() => {
|
||||
if (!context) {
|
||||
context = e.target?.parentElement
|
||||
.closest(".sentence, h2, p, div")
|
||||
?.textContent?.trim();
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
const { x, bottom: y } = e.target.getBoundingClientRect();
|
||||
const _word = word.replace(/[^\w\s]|_/g, "");
|
||||
|
||||
return vocabularyConfig.lookupOnMouseOver ? (
|
||||
<span
|
||||
className="cursor-pointer hover:bg-active-word"
|
||||
onClick={handleLookup}
|
||||
>
|
||||
{word || children}
|
||||
</span>
|
||||
) : (
|
||||
<span>{word || children}</span>
|
||||
);
|
||||
};
|
||||
EnjoyApp.lookup(_word, context, { x, y });
|
||||
}, 800);
|
||||
|
||||
setTimer(_timer);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
|
||||
return vocabularyConfig.lookupOnMouseOver ? (
|
||||
<span
|
||||
className="cursor-pointer hover:bg-active-word"
|
||||
onClick={handleLookup}
|
||||
>
|
||||
{word || children}
|
||||
</span>
|
||||
) : (
|
||||
<span>{word || children}</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Vocabulary.displayName = "Vocabulary";
|
||||
|
||||
264
enjoy/src/renderer/context/document-provider.tsx
Normal file
264
enjoy/src/renderer/context/document-provider.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DbProviderContext,
|
||||
MediaShadowProvider,
|
||||
} from "@renderer/context";
|
||||
import { toast } from "@renderer/components/ui";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useSpeech } from "@renderer/hooks";
|
||||
|
||||
type DocumentProviderProps = {
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
document: DocumentEType;
|
||||
playingSegmentId: string | null;
|
||||
playingSegment: {
|
||||
id: string;
|
||||
index: number;
|
||||
text: string;
|
||||
} | null;
|
||||
nextSegment: {
|
||||
id: string;
|
||||
index: number;
|
||||
text: string;
|
||||
} | null;
|
||||
togglePlayingSegment: (segment: string | null) => void;
|
||||
section: number;
|
||||
setSection: (section: number) => void;
|
||||
onSpeech: (segment: string) => void;
|
||||
onSegmentVisible: (id: string) => void;
|
||||
locateSegment: (id: string) => HTMLElement | null;
|
||||
content: string;
|
||||
setContent: (content: string) => void;
|
||||
};
|
||||
|
||||
export const DocumentProviderContext = createContext<DocumentProviderProps>({
|
||||
ref: null,
|
||||
document: null,
|
||||
playingSegmentId: null,
|
||||
playingSegment: null,
|
||||
nextSegment: null,
|
||||
togglePlayingSegment: () => {},
|
||||
section: 0,
|
||||
setSection: () => {},
|
||||
onSpeech: () => {},
|
||||
onSegmentVisible: () => {},
|
||||
locateSegment: () => null,
|
||||
content: "",
|
||||
setContent: () => {},
|
||||
});
|
||||
|
||||
export function DocumentProvider({
|
||||
documentId,
|
||||
children,
|
||||
}: {
|
||||
documentId: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
|
||||
const { tts } = useSpeech();
|
||||
|
||||
const [document, setDocument] = useState<DocumentEType>(null);
|
||||
const [section, setSection] = useState(0);
|
||||
const [playingSegmentId, setPlayingSegmentId] = useState<string | null>(null);
|
||||
const [playingSegment, setPlayingSegment] = useState<{
|
||||
id: string;
|
||||
index: number;
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [nextSegment, setNextSegment] = useState<{
|
||||
id: string;
|
||||
index: number;
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [content, setContent] = useState<string>();
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const locateSegment = (id: string) => {
|
||||
return ref.current?.querySelector(`#${id}`) as HTMLElement | null;
|
||||
};
|
||||
|
||||
const findNextSegment = async (index: number) => {
|
||||
if (!document.config.autoNextSpeech) return;
|
||||
|
||||
const next: HTMLElement | null = ref.current?.querySelector(
|
||||
`[data-index="${index}"]`
|
||||
);
|
||||
if (!next) return;
|
||||
|
||||
const text = next.querySelector(".segment-content")?.textContent?.trim();
|
||||
if (!text) {
|
||||
return findNextSegment(index + 1);
|
||||
}
|
||||
|
||||
const existingSpeech = await EnjoyApp.speeches.findOne({
|
||||
sourceId: document.id,
|
||||
sourceType: "Document",
|
||||
section,
|
||||
segment: index,
|
||||
});
|
||||
|
||||
if (!existingSpeech) {
|
||||
tts({
|
||||
sourceId: document.id,
|
||||
sourceType: "Document",
|
||||
section,
|
||||
segment: index,
|
||||
text,
|
||||
configuration: document.config.tts,
|
||||
});
|
||||
}
|
||||
setNextSegment({
|
||||
id: next.id,
|
||||
index,
|
||||
text,
|
||||
});
|
||||
};
|
||||
|
||||
const onSegmentVisible = useCallback(
|
||||
(id: string) => {
|
||||
updateDocumentPosition(id);
|
||||
},
|
||||
[document]
|
||||
);
|
||||
|
||||
const updateDocumentPosition = debounce((id: string) => {
|
||||
if (!id) return;
|
||||
|
||||
const segment = locateSegment(id);
|
||||
if (!segment) return;
|
||||
|
||||
const index = segment.dataset.index || "0";
|
||||
const sectionIndex = segment.dataset.section || "0";
|
||||
|
||||
EnjoyApp.documents.update(document.id, {
|
||||
lastReadPosition: {
|
||||
section: parseInt(sectionIndex),
|
||||
segment: parseInt(index),
|
||||
},
|
||||
lastReadAt: new Date(),
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
const togglePlayingSegment = useCallback((segment: string | null) => {
|
||||
setPlayingSegmentId((prev) => (prev === segment ? null : segment));
|
||||
}, []);
|
||||
|
||||
const onSpeech = useCallback(
|
||||
(segment: string) => {
|
||||
togglePlayingSegment(segment);
|
||||
},
|
||||
[togglePlayingSegment]
|
||||
);
|
||||
|
||||
const fetchDocument = async () => {
|
||||
if (!documentId) return;
|
||||
|
||||
EnjoyApp.documents
|
||||
.findOne({ id: documentId })
|
||||
.then((doc) => {
|
||||
setDocument(doc);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDocumentUpdate = (event: CustomEvent) => {
|
||||
const { action, record } = event.detail;
|
||||
if (action === "update" && record.id === documentId) {
|
||||
setDocument(record as DocumentEType);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
if (!playingSegmentId) return;
|
||||
|
||||
const element = locateSegment(playingSegmentId);
|
||||
if (!element) return;
|
||||
|
||||
const index = parseInt(element.dataset.index || "0");
|
||||
findNextSegment(index + 1);
|
||||
setPlayingSegment({
|
||||
id: element.id,
|
||||
index,
|
||||
text: element.querySelector(".segment-content")?.textContent?.trim(),
|
||||
});
|
||||
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
element.classList.add("playing-segment", "bg-yellow-100");
|
||||
|
||||
return () => {
|
||||
setPlayingSegment(null);
|
||||
element?.classList?.remove("playing-segment", "bg-yellow-100");
|
||||
};
|
||||
}, [ref, playingSegmentId]);
|
||||
|
||||
// auto scroll to the top when new section is rendered
|
||||
useEffect(() => {
|
||||
if (!content) return;
|
||||
if (!ref?.current) return;
|
||||
|
||||
if (document.lastReadPosition.section === section) {
|
||||
const element = locateSegment(
|
||||
`segment-${document.lastReadPosition.segment || 0}`
|
||||
);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}, [section, content]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!document) return;
|
||||
setSection(document.lastReadPosition.section || 0);
|
||||
}, [document]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocument();
|
||||
addDblistener(handleDocumentUpdate);
|
||||
|
||||
return () => {
|
||||
removeDbListener(handleDocumentUpdate);
|
||||
setDocument(null);
|
||||
};
|
||||
}, [documentId]);
|
||||
|
||||
return (
|
||||
<DocumentProviderContext.Provider
|
||||
value={{
|
||||
document,
|
||||
ref,
|
||||
playingSegmentId,
|
||||
playingSegment,
|
||||
nextSegment,
|
||||
togglePlayingSegment,
|
||||
section,
|
||||
setSection,
|
||||
onSpeech,
|
||||
onSegmentVisible,
|
||||
locateSegment,
|
||||
content,
|
||||
setContent,
|
||||
}}
|
||||
>
|
||||
<MediaShadowProvider
|
||||
layout="compact"
|
||||
onCancel={() => togglePlayingSegment(null)}
|
||||
>
|
||||
<div ref={ref}>{children}</div>
|
||||
</MediaShadowProvider>
|
||||
</DocumentProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export * from "./hotkeys-settings-provider";
|
||||
export * from "./media-shadow-provider";
|
||||
export * from "./theme-provider";
|
||||
export * from "./dict-provider";
|
||||
export * from "./document-provider";
|
||||
|
||||
@@ -23,6 +23,8 @@ const ONE_MINUTE = 60;
|
||||
const TEN_MINUTES = 10 * ONE_MINUTE;
|
||||
|
||||
type MediaShadowContextType = {
|
||||
layout: "compact" | "normal";
|
||||
onCancel?: () => void;
|
||||
media: AudioType | VideoType;
|
||||
setMedia: (media: AudioType | VideoType) => void;
|
||||
setMediaProvider: (mediaProvider: HTMLAudioElement | null) => void;
|
||||
@@ -103,8 +105,12 @@ export const MediaShadowProviderContext =
|
||||
|
||||
export const MediaShadowProvider = ({
|
||||
children,
|
||||
layout = "normal",
|
||||
onCancel,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
layout?: "compact" | "normal";
|
||||
onCancel?: () => void;
|
||||
}) => {
|
||||
const minPxPerSec = 150;
|
||||
const { EnjoyApp, learningLanguage, recorderConfig } = useContext(
|
||||
@@ -636,6 +642,8 @@ export const MediaShadowProvider = ({
|
||||
<>
|
||||
<MediaShadowProviderContext.Provider
|
||||
value={{
|
||||
layout,
|
||||
onCancel,
|
||||
media,
|
||||
setMedia,
|
||||
setMediaProvider,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
refineCommand,
|
||||
chatSuggestionCommand,
|
||||
} from "@commands";
|
||||
import { md5 as md5Hash } from "js-md5";
|
||||
|
||||
export const useAiCommand = () => {
|
||||
const { EnjoyApp, webApi, nativeLanguage, learningLanguage } = useContext(
|
||||
@@ -99,17 +100,48 @@ export const useAiCommand = () => {
|
||||
text: string,
|
||||
cacheKey?: string
|
||||
): Promise<string> => {
|
||||
return translateCommand(text, nativeLanguage, {
|
||||
key: currentGptEngine.key,
|
||||
modelName:
|
||||
currentGptEngine.models.translate || currentGptEngine.models.default,
|
||||
baseUrl: currentGptEngine.baseUrl,
|
||||
}).then((res) => {
|
||||
if (cacheKey) {
|
||||
EnjoyApp.cacheObjects.set(cacheKey, res);
|
||||
let translatedContent = "";
|
||||
const md5 = md5Hash(text.trim());
|
||||
const engine = currentGptEngine.key;
|
||||
const modelName =
|
||||
currentGptEngine.models.translate || currentGptEngine.models.default;
|
||||
|
||||
try {
|
||||
const res = await webApi.translations({
|
||||
md5,
|
||||
translatedLanguage: nativeLanguage,
|
||||
engine: modelName,
|
||||
});
|
||||
|
||||
if (res.translations.length > 0) {
|
||||
translatedContent = res.translations[0].translatedContent;
|
||||
}
|
||||
return res;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (!translatedContent) {
|
||||
translatedContent = await translateCommand(text, nativeLanguage, {
|
||||
key: engine,
|
||||
modelName,
|
||||
baseUrl: currentGptEngine.baseUrl,
|
||||
});
|
||||
|
||||
webApi.createTranslation({
|
||||
md5,
|
||||
content: text,
|
||||
translatedContent,
|
||||
language: learningLanguage,
|
||||
translatedLanguage: nativeLanguage,
|
||||
engine: modelName,
|
||||
});
|
||||
}
|
||||
|
||||
if (cacheKey) {
|
||||
EnjoyApp.cacheObjects.set(cacheKey, translatedContent);
|
||||
}
|
||||
|
||||
return translatedContent;
|
||||
};
|
||||
|
||||
const analyzeText = async (text: string, cacheKey?: string) => {
|
||||
|
||||
@@ -29,6 +29,8 @@ export const useSpeech = () => {
|
||||
text: params.text,
|
||||
sourceType: params.sourceType,
|
||||
sourceId: params.sourceId,
|
||||
section: params.section,
|
||||
segment: params.segment,
|
||||
configuration: {
|
||||
engine,
|
||||
model,
|
||||
|
||||
@@ -156,3 +156,12 @@ export function imgErrorToDefalut(
|
||||
target.onerror = null;
|
||||
target.src = "assets/default-img.jpg";
|
||||
}
|
||||
|
||||
export function blobToDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
78
enjoy/src/renderer/pages/document.tsx
Normal file
78
enjoy/src/renderer/pages/document.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
Button,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
ScrollArea,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
DocumentHtmlRenderer,
|
||||
DocumentEpubRenderer,
|
||||
DocumentPlayer,
|
||||
LoaderSpin,
|
||||
DocumentTextRenderer,
|
||||
} from "@renderer/components";
|
||||
import { useContext } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { DocumentProvider, DocumentProviderContext } from "@renderer/context";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
|
||||
export default () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
return (
|
||||
<DocumentProvider documentId={id}>
|
||||
<DocumentComponent />
|
||||
</DocumentProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentComponent = () => {
|
||||
const { document, playingSegment } = useContext(DocumentProviderContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!document) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col justify-center items-center relative">
|
||||
<LoaderSpin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col relative">
|
||||
<div className="flex space-x-1 items-center px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<span className="text-sm line-clamp-1">{document.title}</span>
|
||||
</div>
|
||||
|
||||
<ResizablePanelGroup direction="horizontal" className="p-4">
|
||||
<ResizablePanel id="document" order={0}>
|
||||
<ScrollArea
|
||||
className={`h-full px-4 pb-6 border rounded-lg shadow-lg ${
|
||||
playingSegment ? "" : "max-w-screen-md mx-auto"
|
||||
}`}
|
||||
>
|
||||
{document.metadata.extension === "html" && <DocumentHtmlRenderer />}
|
||||
{document.metadata.extension === "epub" && <DocumentEpubRenderer />}
|
||||
{["txt", "md", "markdown"].includes(
|
||||
document.metadata.extension
|
||||
) && <DocumentTextRenderer />}
|
||||
</ScrollArea>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
className={playingSegment ? "invisible mx-2" : "invisible"}
|
||||
/>
|
||||
<ResizablePanel
|
||||
id="player"
|
||||
order={1}
|
||||
className={playingSegment ? "" : "invisible fixed"}
|
||||
>
|
||||
<DocumentPlayer />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
67
enjoy/src/renderer/pages/documents.tsx
Normal file
67
enjoy/src/renderer/pages/documents.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Button, Input } from "@renderer/components/ui";
|
||||
import {
|
||||
DocumentAddButton,
|
||||
DocumentCard,
|
||||
LoaderSpin,
|
||||
} from "@renderer/components";
|
||||
import { useState, useContext, useEffect } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDebounce } from "@uidotdev/usehooks";
|
||||
|
||||
export default () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [documents, setDocuments] = useState<DocumentEType[]>([]);
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const debouncedQuery = useDebounce(query, 500);
|
||||
|
||||
const fetchDocuments = () => {
|
||||
setLoading(true);
|
||||
EnjoyApp.documents
|
||||
.findAll({ query: debouncedQuery })
|
||||
.then((documents) => {
|
||||
setDocuments(documents);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, [debouncedQuery]);
|
||||
|
||||
return (
|
||||
<div className="h-full max-w-5xl mx-auto px-4 py-6">
|
||||
<div className="flex space-x-1 items-center mb-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<span>{t("sidebar.documents")}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||
<Input
|
||||
className="max-w-48"
|
||||
placeholder={t("search")}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<DocumentAddButton />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<LoaderSpin />
|
||||
) : (
|
||||
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{documents.map((document) => (
|
||||
<DocumentCard key={document.id} document={document} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
AudiosSegment,
|
||||
AudibleBooksSegment,
|
||||
StoriesSegment,
|
||||
DocumentsSegment,
|
||||
VideosSegment,
|
||||
YoutubeVideosSegment,
|
||||
EnrollmentSegment,
|
||||
@@ -41,7 +41,7 @@ export default () => {
|
||||
<EnrollmentSegment />
|
||||
<AudiosSegment />
|
||||
<VideosSegment />
|
||||
<StoriesSegment />
|
||||
<DocumentsSegment />
|
||||
<AudibleBooksSegment />
|
||||
{channels.map((channel) => (
|
||||
<YoutubeVideosSegment key={channel} channel={channel} />
|
||||
|
||||
@@ -11,6 +11,8 @@ import Audios from "./pages/audios";
|
||||
import Videos from "./pages/videos";
|
||||
import Stories from "./pages/stories";
|
||||
import Story from "./pages/story";
|
||||
import Documents from "./pages/documents";
|
||||
import Document from "./pages/document";
|
||||
import Books from "./pages/books";
|
||||
import Profile from "./pages/profile";
|
||||
import User from "./pages/user";
|
||||
@@ -96,6 +98,14 @@ export default createHashRouter([
|
||||
path: "/videos/:id",
|
||||
element: <Video />,
|
||||
},
|
||||
{
|
||||
path: "/documents",
|
||||
element: <Documents />,
|
||||
},
|
||||
{
|
||||
path: "/documents/:id",
|
||||
element: <Document />,
|
||||
},
|
||||
{
|
||||
path: "/stories",
|
||||
element: <Stories />,
|
||||
|
||||
22
enjoy/src/types/document.d.ts
vendored
Normal file
22
enjoy/src/types/document.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
type DocumentEType = {
|
||||
id: string;
|
||||
language: string;
|
||||
md5: string;
|
||||
title: string;
|
||||
metadata: Record<string, any>;
|
||||
config: Record<string, any>;
|
||||
autoTranslate: boolean;
|
||||
autoNextSpeech: boolean;
|
||||
ttsConfig: Record<string, any>;
|
||||
lastReadPosition: Record<string, any>;
|
||||
lastReadAt: Date;
|
||||
syncedAt: Date;
|
||||
uploadedAt: Date;
|
||||
updatedAt: Date;
|
||||
createdAt: Date;
|
||||
src?: string;
|
||||
filePath?: string;
|
||||
isSynced?: boolean;
|
||||
isUploaded?: boolean;
|
||||
sync(): Promise<void>;
|
||||
};
|
||||
12
enjoy/src/types/enjoy-app.d.ts
vendored
12
enjoy/src/types/enjoy-app.d.ts
vendored
@@ -260,6 +260,8 @@ type EnjoyAppType = {
|
||||
sourceId: string;
|
||||
sourceType: string;
|
||||
text: string;
|
||||
section?: number;
|
||||
segment?: number;
|
||||
configuration: {
|
||||
engine: string;
|
||||
model: string;
|
||||
@@ -271,6 +273,7 @@ type EnjoyAppType = {
|
||||
arrayBuffer: ArrayBuffer;
|
||||
}
|
||||
) => Promise<SpeechType>;
|
||||
delete: (id: string) => Promise<void>;
|
||||
};
|
||||
echogarden: {
|
||||
recognize: (
|
||||
@@ -395,4 +398,13 @@ type EnjoyAppType = {
|
||||
update: (id: string, params: any) => Promise<ChatMessageType>;
|
||||
destroy: (id: string) => Promise<ChatMessageType>;
|
||||
};
|
||||
documents: {
|
||||
findAll: (params?: any) => Promise<DocumentEType[]>;
|
||||
findOne: (params: any) => Promise<DocumentEType>;
|
||||
create: (params: any) => Promise<DocumentEType>;
|
||||
update: (id: string, params: any) => Promise<DocumentEType>;
|
||||
destroy: (id: string) => Promise<void>;
|
||||
upload: (id: string) => Promise<void>;
|
||||
cleanUp: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
2
enjoy/src/types/index.d.ts
vendored
2
enjoy/src/types/index.d.ts
vendored
@@ -3,6 +3,8 @@
|
||||
// whether you're running in development or production).
|
||||
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
|
||||
declare const MAIN_WINDOW_VITE_NAME: string;
|
||||
declare module "foliate-js/view.js";
|
||||
declare module "foliate-js/epub.js";
|
||||
declare module "compromise-paragraphs";
|
||||
|
||||
type SupportedLlmProviderType = "enjoyai" | "openai";
|
||||
|
||||
4
enjoy/src/types/speech.d.ts
vendored
4
enjoy/src/types/speech.d.ts
vendored
@@ -4,13 +4,15 @@ type SpeechType = {
|
||||
sourceType: string;
|
||||
source?: MessageType;
|
||||
text: string;
|
||||
section: number;
|
||||
segment: number;
|
||||
engine: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
md5: string;
|
||||
filename: string;
|
||||
filePath: string;
|
||||
configuration: {[key: string]: any};
|
||||
configuration: { [key: string]: any };
|
||||
src?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
9
enjoy/src/types/translation.d.ts
vendored
Normal file
9
enjoy/src/types/translation.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
type TranslationType = {
|
||||
id: string;
|
||||
md5: string;
|
||||
content: string;
|
||||
translatedContent: string;
|
||||
language: string;
|
||||
translatedLanguage: string;
|
||||
engine: string;
|
||||
};
|
||||
@@ -17,6 +17,7 @@ export default defineConfig((env) => {
|
||||
base: "./",
|
||||
build: {
|
||||
outDir: `.vite/renderer/${name}`,
|
||||
target: "esnext",
|
||||
},
|
||||
plugins: [
|
||||
pluginExposeRenderer(name),
|
||||
@@ -36,10 +37,17 @@ export default defineConfig((env) => {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"@renderer": path.resolve(__dirname, "./src/renderer"),
|
||||
"@commands": path.resolve(__dirname, "./src/commands"),
|
||||
"vendor/pdfjs": path.resolve(
|
||||
__dirname,
|
||||
"./node_modules/foliate-js/vendor/pdfjs"
|
||||
),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["@ffmpeg/ffmpeg", "@ffmpeg/util"],
|
||||
esbuildOptions: {
|
||||
target: "esnext",
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
} as UserConfig;
|
||||
|
||||
315
yarn.lock
315
yarn.lock
@@ -3692,6 +3692,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mixmark-io/domino@npm:^2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "@mixmark-io/domino@npm:2.2.0"
|
||||
checksum: 10c0/aa468a15f9217d425220fe6a4b3f9416cbe8e566ee14efc191c6d5cc04fe39338b16a90bbac190f28d44e69465db5f2cf95f479c621ce38060ca6b2a3d346e9d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mozilla/readability@npm:^0.5.0":
|
||||
version: 0.5.0
|
||||
resolution: "@mozilla/readability@npm:0.5.0"
|
||||
@@ -7228,6 +7235,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tokenizer/token@npm:^0.3.0":
|
||||
version: 0.3.0
|
||||
resolution: "@tokenizer/token@npm:0.3.0"
|
||||
checksum: 10c0/7ab9a822d4b5ff3f5bca7f7d14d46bdd8432528e028db4a52be7fbf90c7f495cc1af1324691dda2813c6af8dc4b8eb29de3107d4508165f9aa5b53e7d501f155
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tootallnate/once@npm:1":
|
||||
version: 1.1.2
|
||||
resolution: "@tootallnate/once@npm:1.1.2"
|
||||
@@ -7580,6 +7594,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/mime-types@npm:^2":
|
||||
version: 2.1.4
|
||||
resolution: "@types/mime-types@npm:2.1.4"
|
||||
checksum: 10c0/a10d57881d14a053556b3d09292de467968d965b0a06d06732c748da39b3aa569270b5b9f32529fd0e9ac1e5f3b91abb894f5b1996373254a65cb87903c86622
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/minimatch@npm:*":
|
||||
version: 5.1.2
|
||||
resolution: "@types/minimatch@npm:5.1.2"
|
||||
@@ -7738,6 +7759,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/turndown@npm:^5.0.5":
|
||||
version: 5.0.5
|
||||
resolution: "@types/turndown@npm:5.0.5"
|
||||
checksum: 10c0/d6b4f8451caf72399f36f810461baf5f3b5e958ff216388bb3324a9949079daad31d970a28a140b3571db8793908396e757329334f5dc8bcff414698b8c31113
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/unist@npm:*, @types/unist@npm:^3.0.0":
|
||||
version: 3.0.3
|
||||
resolution: "@types/unist@npm:3.0.3"
|
||||
@@ -12150,6 +12178,7 @@ __metadata:
|
||||
"@types/intl-tel-input": "npm:^18.1.4"
|
||||
"@types/lodash": "npm:^4.17.12"
|
||||
"@types/mark.js": "npm:^8.11.12"
|
||||
"@types/mime-types": "npm:^2"
|
||||
"@types/mustache": "npm:^4.2.5"
|
||||
"@types/node": "npm:^22.7.7"
|
||||
"@types/prop-types": "npm:^15.7.13"
|
||||
@@ -12157,6 +12186,7 @@ __metadata:
|
||||
"@types/react": "npm:^18.3.11"
|
||||
"@types/react-dom": "npm:^18.3.1"
|
||||
"@types/semver": "npm:^7.5.8"
|
||||
"@types/turndown": "npm:^5.0.5"
|
||||
"@types/unzipper": "npm:^0.10.10"
|
||||
"@types/validator": "npm:^13.12.2"
|
||||
"@types/wavesurfer.js": "npm:^6.0.12"
|
||||
@@ -12195,8 +12225,10 @@ __metadata:
|
||||
eslint-import-resolver-typescript: "npm:^3.6.3"
|
||||
eslint-plugin-import: "npm:^2.31.0"
|
||||
ffmpeg-static: "npm:^5.2.0"
|
||||
file-type: "npm:^19.6.0"
|
||||
flora-colossus: "npm:^2.0.0"
|
||||
fluent-ffmpeg: "npm:^2.1.3"
|
||||
foliate-js: "https://github.com/johnfactotum/foliate-js"
|
||||
fs-extra: "npm:^11.2.0"
|
||||
html-to-text: "npm:^9.0.5"
|
||||
https-proxy-agent: "npm:^7.0.5"
|
||||
@@ -12209,6 +12241,7 @@ __metadata:
|
||||
lucide-react: "npm:^0.453.0"
|
||||
mark.js: "npm:^8.11.1"
|
||||
microsoft-cognitiveservices-speech-sdk: "npm:^1.41.0"
|
||||
mime-types: "npm:^2.1.35"
|
||||
mustache: "npm:^4.2.0"
|
||||
next-themes: "npm:^0.3.0"
|
||||
octokit: "npm:^4.0.2"
|
||||
@@ -12233,6 +12266,7 @@ __metadata:
|
||||
react-shadow-root: "npm:^6.2.0"
|
||||
react-tooltip: "npm:^5.28.0"
|
||||
reflect-metadata: "npm:^0.2.2"
|
||||
remark-gfm: "npm:^4.0.0"
|
||||
rimraf: "npm:^6.0.1"
|
||||
semver: "npm:^7.6.3"
|
||||
sequelize: "npm:^6.37.4"
|
||||
@@ -12246,6 +12280,7 @@ __metadata:
|
||||
tailwindcss-animate: "npm:^1.0.7"
|
||||
ts-node: "npm:^10.9.2"
|
||||
tslib: "npm:^2.8.0"
|
||||
turndown: "npm:^7.2.0"
|
||||
typescript: "npm:^5.6.3"
|
||||
umzug: "npm:^3.8.2"
|
||||
unzipper: "npm:^0.12.3"
|
||||
@@ -13350,6 +13385,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"file-type@npm:^19.6.0":
|
||||
version: 19.6.0
|
||||
resolution: "file-type@npm:19.6.0"
|
||||
dependencies:
|
||||
get-stream: "npm:^9.0.1"
|
||||
strtok3: "npm:^9.0.1"
|
||||
token-types: "npm:^6.0.0"
|
||||
uint8array-extras: "npm:^1.3.0"
|
||||
checksum: 10c0/ae90ab618d0e759f26806024eb25ade851406301d2deae4b2dca6e9df0de13b4be575114a5f8129e73f6de537644a45fc4eb7cfa8b7dad63316b01b0e6f3bd2e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"file-uri-to-path@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "file-uri-to-path@npm:1.0.0"
|
||||
@@ -13492,6 +13539,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"foliate-js@https://github.com/johnfactotum/foliate-js":
|
||||
version: 0.0.0
|
||||
resolution: "foliate-js@https://github.com/johnfactotum/foliate-js.git#commit=519c0329b2bb9b0e1ac9a293e76ad8a5f4488812"
|
||||
checksum: 10c0/5b031706cc4a3eb4785cc815a9f993acf8c46018684a8946b517e25a8840c2097635d6928d5865cf2921ccf9fa89ced171205cab5832648ed64ec24ee1e3e0ae
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"follow-redirects@npm:^1.15.6":
|
||||
version: 1.15.9
|
||||
resolution: "follow-redirects@npm:1.15.9"
|
||||
@@ -13942,7 +13996,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-stream@npm:^9.0.0":
|
||||
"get-stream@npm:^9.0.0, get-stream@npm:^9.0.1":
|
||||
version: 9.0.1
|
||||
resolution: "get-stream@npm:9.0.1"
|
||||
dependencies:
|
||||
@@ -16565,6 +16619,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"markdown-table@npm:^3.0.0":
|
||||
version: 3.0.4
|
||||
resolution: "markdown-table@npm:3.0.4"
|
||||
checksum: 10c0/1257b31827629a54c24a5030a3dac952256c559174c95ce3ef89bebd6bff0cb1444b1fd667b1a1bb53307f83278111505b3e26f0c4e7b731e0060d435d2d930b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"marked@npm:^13.0.2":
|
||||
version: 13.0.3
|
||||
resolution: "marked@npm:13.0.3"
|
||||
@@ -16602,6 +16663,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mdast-util-find-and-replace@npm:^3.0.0":
|
||||
version: 3.0.1
|
||||
resolution: "mdast-util-find-and-replace@npm:3.0.1"
|
||||
dependencies:
|
||||
"@types/mdast": "npm:^4.0.0"
|
||||
escape-string-regexp: "npm:^5.0.0"
|
||||
unist-util-is: "npm:^6.0.0"
|
||||
unist-util-visit-parents: "npm:^6.0.0"
|
||||
checksum: 10c0/1faca98c4ee10a919f23b8cc6d818e5bb6953216a71dfd35f51066ed5d51ef86e5063b43dcfdc6061cd946e016a9f0d44a1dccadd58452cf4ed14e39377f00cb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mdast-util-from-markdown@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "mdast-util-from-markdown@npm:2.0.1"
|
||||
@@ -16622,6 +16695,83 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mdast-util-gfm-autolink-literal@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "mdast-util-gfm-autolink-literal@npm:2.0.1"
|
||||
dependencies:
|
||||
"@types/mdast": "npm:^4.0.0"
|
||||
ccount: "npm:^2.0.0"
|
||||
devlop: "npm:^1.0.0"
|
||||
mdast-util-find-and-replace: "npm:^3.0.0"
|
||||
micromark-util-character: "npm:^2.0.0"
|
||||
checksum: 10c0/963cd22bd42aebdec7bdd0a527c9494d024d1ad0739c43dc040fee35bdfb5e29c22564330a7418a72b5eab51d47a6eff32bc0255ef3ccb5cebfe8970e91b81b6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mdast-util-gfm-footnote@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "mdast-util-gfm-footnote@npm:2.0.0"
|
||||
dependencies:
|
||||
"@types/mdast": "npm:^4.0.0"
|
||||
devlop: "npm:^1.1.0"
|
||||
mdast-util-from-markdown: "npm:^2.0.0"
|
||||
mdast-util-to-markdown: "npm:^2.0.0"
|
||||
micromark-util-normalize-identifier: "npm:^2.0.0"
|
||||
checksum: 10c0/c673b22bea24740235e74cfd66765b41a2fa540334f7043fa934b94938b06b7d3c93f2d3b33671910c5492b922c0cc98be833be3b04cfed540e0679650a6d2de
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mdast-util-gfm-strikethrough@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "mdast-util-gfm-strikethrough@npm:2.0.0"
|
||||
dependencies:
|
||||
"@types/mdast": "npm:^4.0.0"
|
||||
mdast-util-from-markdown: "npm:^2.0.0"
|
||||
mdast-util-to-markdown: "npm:^2.0.0"
|
||||
checksum: 10c0/b053e93d62c7545019bd914271ea9e5667ad3b3b57d16dbf68e56fea39a7e19b4a345e781312714eb3d43fdd069ff7ee22a3ca7f6149dfa774554f19ce3ac056
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mdast-util-gfm-table@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "mdast-util-gfm-table@npm:2.0.0"
|
||||
dependencies:
|
||||
"@types/mdast": "npm:^4.0.0"
|
||||
devlop: "npm:^1.0.0"
|
||||
markdown-table: "npm:^3.0.0"
|
||||
mdast-util-from-markdown: "npm:^2.0.0"
|
||||
mdast-util-to-markdown: "npm:^2.0.0"
|
||||
checksum: 10c0/128af47c503a53bd1c79f20642561e54a510ad5e2db1e418d28fefaf1294ab839e6c838e341aef5d7e404f9170b9ca3d1d89605f234efafde93ee51174a6e31e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mdast-util-gfm-task-list-item@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "mdast-util-gfm-task-list-item@npm:2.0.0"
|
||||
dependencies:
|
||||
"@types/mdast": "npm:^4.0.0"
|
||||
devlop: "npm:^1.0.0"
|
||||
mdast-util-from-markdown: "npm:^2.0.0"
|
||||
mdast-util-to-markdown: "npm:^2.0.0"
|
||||
checksum: 10c0/258d725288482b636c0a376c296431390c14b4f29588675297cb6580a8598ed311fc73ebc312acfca12cc8546f07a3a285a53a3b082712e2cbf5c190d677d834
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mdast-util-gfm@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "mdast-util-gfm@npm:3.0.0"
|
||||
dependencies:
|
||||
mdast-util-from-markdown: "npm:^2.0.0"
|
||||
mdast-util-gfm-autolink-literal: "npm:^2.0.0"
|
||||
mdast-util-gfm-footnote: "npm:^2.0.0"
|
||||
mdast-util-gfm-strikethrough: "npm:^2.0.0"
|
||||
mdast-util-gfm-table: "npm:^2.0.0"
|
||||
mdast-util-gfm-task-list-item: "npm:^2.0.0"
|
||||
mdast-util-to-markdown: "npm:^2.0.0"
|
||||
checksum: 10c0/91596fe9bf3e4a0c546d0c57f88106c17956d9afbe88ceb08308e4da2388aff64489d649ddad599caecfdf755fc3ae4c9b82c219b85281bc0586b67599881fca
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mdast-util-mdx-expression@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "mdast-util-mdx-expression@npm:2.0.1"
|
||||
@@ -16874,6 +17024,99 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"micromark-extension-gfm-autolink-literal@npm:^2.0.0":
|
||||
version: 2.1.0
|
||||
resolution: "micromark-extension-gfm-autolink-literal@npm:2.1.0"
|
||||
dependencies:
|
||||
micromark-util-character: "npm:^2.0.0"
|
||||
micromark-util-sanitize-uri: "npm:^2.0.0"
|
||||
micromark-util-symbol: "npm:^2.0.0"
|
||||
micromark-util-types: "npm:^2.0.0"
|
||||
checksum: 10c0/84e6fbb84ea7c161dfa179665dc90d51116de4c28f3e958260c0423e5a745372b7dcbc87d3cde98213b532e6812f847eef5ae561c9397d7f7da1e59872ef3efe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"micromark-extension-gfm-footnote@npm:^2.0.0":
|
||||
version: 2.1.0
|
||||
resolution: "micromark-extension-gfm-footnote@npm:2.1.0"
|
||||
dependencies:
|
||||
devlop: "npm:^1.0.0"
|
||||
micromark-core-commonmark: "npm:^2.0.0"
|
||||
micromark-factory-space: "npm:^2.0.0"
|
||||
micromark-util-character: "npm:^2.0.0"
|
||||
micromark-util-normalize-identifier: "npm:^2.0.0"
|
||||
micromark-util-sanitize-uri: "npm:^2.0.0"
|
||||
micromark-util-symbol: "npm:^2.0.0"
|
||||
micromark-util-types: "npm:^2.0.0"
|
||||
checksum: 10c0/d172e4218968b7371b9321af5cde8c77423f73b233b2b0fcf3ff6fd6f61d2e0d52c49123a9b7910612478bf1f0d5e88c75a3990dd68f70f3933fe812b9f77edc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"micromark-extension-gfm-strikethrough@npm:^2.0.0":
|
||||
version: 2.1.0
|
||||
resolution: "micromark-extension-gfm-strikethrough@npm:2.1.0"
|
||||
dependencies:
|
||||
devlop: "npm:^1.0.0"
|
||||
micromark-util-chunked: "npm:^2.0.0"
|
||||
micromark-util-classify-character: "npm:^2.0.0"
|
||||
micromark-util-resolve-all: "npm:^2.0.0"
|
||||
micromark-util-symbol: "npm:^2.0.0"
|
||||
micromark-util-types: "npm:^2.0.0"
|
||||
checksum: 10c0/ef4f248b865bdda71303b494671b7487808a340b25552b11ca6814dff3fcfaab9be8d294643060bbdb50f79313e4a686ab18b99cbe4d3ee8a4170fcd134234fb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"micromark-extension-gfm-table@npm:^2.0.0":
|
||||
version: 2.1.0
|
||||
resolution: "micromark-extension-gfm-table@npm:2.1.0"
|
||||
dependencies:
|
||||
devlop: "npm:^1.0.0"
|
||||
micromark-factory-space: "npm:^2.0.0"
|
||||
micromark-util-character: "npm:^2.0.0"
|
||||
micromark-util-symbol: "npm:^2.0.0"
|
||||
micromark-util-types: "npm:^2.0.0"
|
||||
checksum: 10c0/c1b564ab68576406046d825b9574f5b4dbedbb5c44bede49b5babc4db92f015d9057dd79d8e0530f2fecc8970a695c40ac2e5e1d4435ccf3ef161038d0d1463b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"micromark-extension-gfm-tagfilter@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "micromark-extension-gfm-tagfilter@npm:2.0.0"
|
||||
dependencies:
|
||||
micromark-util-types: "npm:^2.0.0"
|
||||
checksum: 10c0/995558843fff137ae4e46aecb878d8a4691cdf23527dcf1e2f0157d66786be9f7bea0109c52a8ef70e68e3f930af811828ba912239438e31a9cfb9981f44d34d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"micromark-extension-gfm-task-list-item@npm:^2.0.0":
|
||||
version: 2.1.0
|
||||
resolution: "micromark-extension-gfm-task-list-item@npm:2.1.0"
|
||||
dependencies:
|
||||
devlop: "npm:^1.0.0"
|
||||
micromark-factory-space: "npm:^2.0.0"
|
||||
micromark-util-character: "npm:^2.0.0"
|
||||
micromark-util-symbol: "npm:^2.0.0"
|
||||
micromark-util-types: "npm:^2.0.0"
|
||||
checksum: 10c0/78aa537d929e9309f076ba41e5edc99f78d6decd754b6734519ccbbfca8abd52e1c62df68d41a6ae64d2a3fc1646cea955893c79680b0b4385ced4c52296181f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"micromark-extension-gfm@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "micromark-extension-gfm@npm:3.0.0"
|
||||
dependencies:
|
||||
micromark-extension-gfm-autolink-literal: "npm:^2.0.0"
|
||||
micromark-extension-gfm-footnote: "npm:^2.0.0"
|
||||
micromark-extension-gfm-strikethrough: "npm:^2.0.0"
|
||||
micromark-extension-gfm-table: "npm:^2.0.0"
|
||||
micromark-extension-gfm-tagfilter: "npm:^2.0.0"
|
||||
micromark-extension-gfm-task-list-item: "npm:^2.0.0"
|
||||
micromark-util-combine-extensions: "npm:^2.0.0"
|
||||
micromark-util-types: "npm:^2.0.0"
|
||||
checksum: 10c0/970e28df6ebdd7c7249f52a0dda56e0566fbfa9ae56c8eeeb2445d77b6b89d44096880cd57a1c01e7821b1f4e31009109fbaca4e89731bff7b83b8519690e5d9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"micromark-factory-destination@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "micromark-factory-destination@npm:2.0.0"
|
||||
@@ -17124,7 +17367,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mime-types@npm:^2.1.12, mime-types@npm:^2.1.25, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34":
|
||||
"mime-types@npm:^2.1.12, mime-types@npm:^2.1.25, mime-types@npm:^2.1.35, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34":
|
||||
version: 2.1.35
|
||||
resolution: "mime-types@npm:2.1.35"
|
||||
dependencies:
|
||||
@@ -19100,6 +19343,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"peek-readable@npm:^5.3.1":
|
||||
version: 5.3.1
|
||||
resolution: "peek-readable@npm:5.3.1"
|
||||
checksum: 10c0/49f628e4728887230c158699e422ebb10747f5e02aee930ec10634fa7142e74e67d3fb3a780e7a9b9f092c61bf185f07d167c099b2359b22a58cee3dbfe0e43b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pend@npm:~1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "pend@npm:1.2.0"
|
||||
@@ -20468,6 +20718,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"remark-gfm@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "remark-gfm@npm:4.0.0"
|
||||
dependencies:
|
||||
"@types/mdast": "npm:^4.0.0"
|
||||
mdast-util-gfm: "npm:^3.0.0"
|
||||
micromark-extension-gfm: "npm:^3.0.0"
|
||||
remark-parse: "npm:^11.0.0"
|
||||
remark-stringify: "npm:^11.0.0"
|
||||
unified: "npm:^11.0.0"
|
||||
checksum: 10c0/db0aa85ab718d475c2596e27c95be9255d3b0fc730a4eda9af076b919f7dd812f7be3ac020611a8dbe5253fd29671d7b12750b56e529fdc32dfebad6dbf77403
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"remark-parse@npm:^11.0.0":
|
||||
version: 11.0.0
|
||||
resolution: "remark-parse@npm:11.0.0"
|
||||
@@ -20493,6 +20757,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"remark-stringify@npm:^11.0.0":
|
||||
version: 11.0.0
|
||||
resolution: "remark-stringify@npm:11.0.0"
|
||||
dependencies:
|
||||
"@types/mdast": "npm:^4.0.0"
|
||||
mdast-util-to-markdown: "npm:^2.0.0"
|
||||
unified: "npm:^11.0.0"
|
||||
checksum: 10c0/0cdb37ce1217578f6f847c7ec9f50cbab35df5b9e3903d543e74b405404e67c07defcb23cd260a567b41b769400f6de03c2c3d9cd6ae7a6707d5c8d89ead489f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"repeat-string@npm:^1.5.4":
|
||||
version: 1.6.1
|
||||
resolution: "repeat-string@npm:1.6.1"
|
||||
@@ -21873,6 +22148,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"strtok3@npm:^9.0.1":
|
||||
version: 9.0.1
|
||||
resolution: "strtok3@npm:9.0.1"
|
||||
dependencies:
|
||||
"@tokenizer/token": "npm:^0.3.0"
|
||||
peek-readable: "npm:^5.3.1"
|
||||
checksum: 10c0/ab96030c3d30899fc885ed87b305086b6421d64c1a2b9f5240c6ecffde7b819e174d67bd3b1ecc14a10f539c7a818a0ac47386b4bbb2fa913f673b6ed1c0eb78
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"style-to-object@npm:^1.0.0":
|
||||
version: 1.0.8
|
||||
resolution: "style-to-object@npm:1.0.8"
|
||||
@@ -22377,6 +22662,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"token-types@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "token-types@npm:6.0.0"
|
||||
dependencies:
|
||||
"@tokenizer/token": "npm:^0.3.0"
|
||||
ieee754: "npm:^1.2.1"
|
||||
checksum: 10c0/5bf5eba51d63f71f301659ff70ce10ca43e7038364883437d8b4541cc98377e3e56109b11720e25fe51047014efaccdff90eaf6de9a78270483578814b838ab9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"toposort-class@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "toposort-class@npm:1.0.1"
|
||||
@@ -22542,6 +22837,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"turndown@npm:^7.2.0":
|
||||
version: 7.2.0
|
||||
resolution: "turndown@npm:7.2.0"
|
||||
dependencies:
|
||||
"@mixmark-io/domino": "npm:^2.2.0"
|
||||
checksum: 10c0/6abcdcdf9d35cd79d7a8100a7de1d2226b921d5bd99e73ac14a7ead39c059978f519378913375efb04c68bcfc40f7ffe2dee0ce9ae4d54dc1235b12856a78d4e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"type-check@npm:^0.4.0, type-check@npm:~0.4.0":
|
||||
version: 0.4.0
|
||||
resolution: "type-check@npm:0.4.0"
|
||||
@@ -22691,6 +22995,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uint8array-extras@npm:^1.3.0":
|
||||
version: 1.4.0
|
||||
resolution: "uint8array-extras@npm:1.4.0"
|
||||
checksum: 10c0/eaffd3388634b7e5e1496073b878dd19136043137d3e7e0d2a453e37f566a5a551e640819e1a6596c6df9b9d1f7b70884cc29db6a357bdd424811f3598d504dd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ultrahtml@npm:^1.2.0, ultrahtml@npm:^1.5.3":
|
||||
version: 1.5.3
|
||||
resolution: "ultrahtml@npm:1.5.3"
|
||||
|
||||
Reference in New Issue
Block a user