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:
an-lee
2024-11-08 22:00:57 +08:00
committed by GitHub
parent f62bd88b4f
commit 76bee71750
69 changed files with 3361 additions and 267 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "关闭"
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -14,3 +14,4 @@ export * from "./speech";
export * from "./user-setting";
export * from "./transcription";
export * from "./video";
export * from "./document";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -174,7 +174,7 @@ const ChatAgentMessageActions = (props: {
setTranslation,
autoSpeech,
} = props;
const { chat, setShadowing, deleteMessage } = useContext(
const { setShadowing, deleteMessage } = useContext(
ChatSessionProviderContext
);
const { EnjoyApp } = useContext(AppSettingsProviderContext);

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,9 @@
type TranslationType = {
id: string;
md5: string;
content: string;
translatedContent: string;
language: string;
translatedLanguage: string;
engine: string;
};

View File

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

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