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

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