├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── docs └── 批量添加书籍,同名自动合并.md ├── forge.config.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── patches └── pdf-parse+1.1.1.patch ├── project2context.sh ├── src ├── __test__ │ └── CoverImageRepository.test.ts ├── app.tsx ├── config │ └── ConfigStore.ts ├── constants.ts ├── core │ └── ipc │ │ ├── ApiResponse.ts │ │ ├── IpcClient.ts │ │ └── IpcWrapper.ts ├── data │ ├── minio │ │ └── MinioClient.ts │ └── processors │ │ ├── BookProcessor.ts │ │ ├── BookProcessorFactory.ts │ │ ├── EpubProcessor.ts │ │ └── PdfProcessor.ts ├── index.css ├── index.html ├── index.ts ├── models │ ├── Book.ts │ └── BookFile.ts ├── pages │ ├── batchupload │ │ └── BatchUploadPage.tsx │ ├── home │ │ ├── HomePage.tsx │ │ ├── components │ │ │ ├── BookCard.tsx │ │ │ └── BookList.tsx │ │ └── modal │ │ │ ├── UploadBookModal.tsx │ │ │ └── components │ │ │ ├── BookFilesManager.tsx │ │ │ └── BookMetadataForm.tsx │ ├── reader │ │ ├── epub │ │ │ ├── ReaderPage.tsx │ │ │ └── hooks │ │ │ │ └── useBookLocation.ts │ │ ├── pdf │ │ │ └── PDFReaderPage.tsx │ │ └── weixin │ │ │ └── WeixinReadPage.tsx │ └── settings │ │ ├── SettingsPage.tsx │ │ └── toolbox │ │ └── Sha256CompletionPage.tsx ├── preload.ts ├── renderer.ts ├── repository │ ├── BookRepository.ts │ ├── BookfileRepository.ts │ └── CoverImageRepostory.ts ├── services │ ├── LocalBookCacheService.ts │ ├── book │ │ ├── BookServiceImpl.ts │ │ └── BookServiceInterface.ts │ ├── bookcover │ │ ├── BookCoverServiceImpl.ts │ │ └── BookCoverServiceInterface.ts │ ├── bookfile │ │ ├── BookFileServiceImpl.ts │ │ └── BookFileServiceInterface.ts │ ├── epub │ │ ├── EpubServiceImpl.ts │ │ └── EpubServiceInterface.ts │ ├── file │ │ ├── FileServiceImpl.ts │ │ └── FileServiceInterface.ts │ └── log │ │ ├── LogServiceImpl.ts │ │ └── LogServiceInterface.ts ├── types │ └── mongoose.ts └── utils │ └── DtoUtils.ts ├── todo.md ├── tsconfig.json ├── webpack.main.config.ts ├── webpack.plugins.ts ├── webpack.renderer.config.ts ├── webpack.rules.ts └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:import/recommended", 12 | "plugin:import/electron", 13 | "plugin:import/typescript" 14 | ], 15 | "parser": "@typescript-eslint/parser" 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | sources.txt 9 | dist/* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | .DS_Store 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # TypeScript cache 45 | *.tsbuildinfo 46 | 47 | # Optional npm cache directory 48 | .npm 49 | 50 | # Optional eslint cache 51 | .eslintcache 52 | 53 | # Optional REPL history 54 | .node_repl_history 55 | 56 | # Output of 'npm pack' 57 | *.tgz 58 | 59 | # Yarn Integrity file 60 | .yarn-integrity 61 | 62 | # dotenv environment variables file 63 | .env 64 | .env.test 65 | 66 | # parcel-bundler cache (https://parceljs.org/) 67 | .cache 68 | 69 | # next.js build output 70 | .next 71 | 72 | # nuxt.js build output 73 | .nuxt 74 | 75 | # vuepress build output 76 | .vuepress/dist 77 | 78 | # Serverless directories 79 | .serverless/ 80 | 81 | # FuseBox cache 82 | .fusebox/ 83 | 84 | # DynamoDB Local files 85 | .dynamodb/ 86 | 87 | # Webpack 88 | .webpack/ 89 | 90 | # Vite 91 | .vite/ 92 | 93 | # Electron-Forge 94 | out/ 95 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Maxiee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RayBook 2 | 3 | RayBook 是一个基于 Electron 和 React 的开源电子书管理应用。它旨在为用户提供一个简洁、高效的电子书阅读和管理平台。 4 | 5 | > **⚠️ 警告:早期开发阶段** 6 | > 7 | > RayBook 目前正处于早期开发阶段。许多功能尚未完成或可能存在问题。我们不建议在生产环境中使用,也不推荐用于管理重要的电子书收藏。如果您对参与开发感兴趣,我们非常欢迎您的贡献! 8 | 9 | ## 特性 10 | 11 | - 📚 支持多种电子书格式 (EPUB, PDF, MOBI 等) 12 | - 🔍 强大的元数据提取和管理 13 | - 📖 内置电子书阅读器 14 | - 🌐 集成微信读书网页版 15 | - 🖼️ 自动提取和管理书籍封面 16 | - 📁 批量导入和管理电子书 17 | - 🔒 文件去重和 SHA256 校验 18 | - 🔄 自动同步阅读进度 19 | - ⚙️ 可自定义的存储和数据库设置 20 | 21 | > **声明:关于微信读书功能** 22 | > 23 | > RayBook 通过浏览器网页提供微信读书网页版访问。我们尊重微信读书的版权和服务条款,不会存储或传播任何微信读书的内容,不侵犯版权和微信读书利益。 24 | > 25 | > 同时,RayBook 仅用于个人学习和研究,不得用于商业用途或侵犯他人权益。 26 | 27 | ## 更新记录 28 | 29 | 2024-08-05 30 | 31 | - 架构重构:引入 Processor 图书类型处理器 32 | - 优化:首页“添加图书”流程,使用 Processor 处理图书类型 33 | - 初步支持 PDF 图书上传、解析元数据 34 | - PDF 阅读器接入,能看 PDF 啦!还带有阅读进度保存! 35 | 36 | 2024-07-28 37 | 38 | - 首页改版 39 | - 新增:最近阅读的书籍功能 40 | 41 | 2024-07-27 42 | 43 | - RayBook 打通微信书架 44 | - 修复书籍信息更新失败的问题 45 | 46 | 2024-07-26 47 | 48 | - 微信读书页工具栏展示书籍标题 49 | 50 | 2024-07-25 51 | 52 | - 持久化记录微信登陆状态 53 | - 微信读书网页版调试功能 54 | - 优化窗口缩放通知逻辑,避免网页频繁刷新 55 | 56 | ## 技术栈 57 | 58 | - Electron 59 | - React 60 | - TypeScript 61 | - MongoDB 62 | - MinIO (对象存储) 63 | - Ant Design (UI 组件库) 64 | 65 | ## 安装 66 | 67 | 1. 克隆仓库: 68 | 69 | ```bash 70 | git clone https://github.com/maxiee/RayBook.git 71 | cd raybook 72 | ``` 73 | 74 | 2. 安装依赖: 75 | 76 | ```bash 77 | npm install 78 | ``` 79 | 80 | 3. 运行应用: 81 | 82 | ```bash 83 | npm start 84 | ``` 85 | 86 | ## 使用方法 87 | 88 | 1. 启动应用后,首次运行需要在设置页面配置 MinIO 和 MongoDB 连接信息。 89 | 2. 在主页面,您可以通过点击 "添加图书" 或 "批量添加书籍" 来导入电子书。 90 | 3. 使用内置阅读器打开 EPUB 格式的电子书,或使用集成的微信读书功能。 91 | 4. 在设置页面,您可以管理存储路径、执行 SHA256 补齐等维护操作。 92 | 93 | ## 开发 94 | 95 | 要在开发模式下运行 RayBook: 96 | 97 | ```bash 98 | npm run dev 99 | ``` 100 | 101 | ## 构建 102 | 103 | 要构建生产版本的 RayBook: 104 | 105 | ```bash 106 | npm run build 107 | ``` 108 | 109 | ## 贡献 110 | 111 | 我们欢迎所有形式的贡献,包括但不限于: 112 | 113 | - 提交 bug 报告 114 | - 改进文档 115 | - 提交功能请求 116 | - 提交代码修复或新功能 117 | 118 | 请查看 [CONTRIBUTING.md](CONTRIBUTING.md) 了解更多详情。 119 | 120 | ## 许可证 121 | 122 | RayBook 使用 [MIT 许可证](LICENSE)。 123 | 124 | ## 联系我们 125 | 126 | 如果您有任何问题或建议,请开启一个 issue 或通过以下方式联系我们: 127 | 128 | - Weibo: [@Maeiee](https://weibo.com/u/1240212845) 129 | 130 | --- 131 | 132 | 感谢您对 RayBook 的关注!我们期待您的参与和反馈。 133 | -------------------------------------------------------------------------------- /docs/批量添加书籍,同名自动合并.md: -------------------------------------------------------------------------------- 1 | 功能名称:批量添加书籍,同名自动合并。 2 | 3 | 背景描述: 4 | 5 | 在一个目录下包含大量电子书文件,并且普遍存在名称相同,文件类型不同的情况。对于,这些文件类型不同、名称相同的电子书,我们希望能够自动合并为一个电子书 IBook,并包含多个 IBookFile,每个 IBookFile 指向同一个 IBook。 6 | 7 | 功能描述: 8 | 9 | 我希望按照如下方式实现这一功能: 10 | 11 | 在首页中添加一个按钮,按钮包含主标题和副标题,主标题为“批量添加书籍”,副标题为“同名自动合并”。 12 | 13 | 点击按钮后,弹出一个目录选择框,供用户进行目录选择。 14 | 15 | 用户选择目录后,跳转到一个新页面,即批量添加书籍页面。 16 | 17 | 在批量添加书籍页面中,显示一个进度条,用于显示扫描进度。 18 | 19 | 除了进度条之外,页面还应该显示两部分内容: 20 | 21 | 1. 整体情况,包含两个数字,分别表示扫描到的文件总数和合并后的 IBook 总数。 22 | 2. 详细情况,包含一个表格,表格中的每一行表示一个 IBook,包含以下列:无后缀文件名,文件大小,对应的多文件类型的文件名(对应于每个 IBookFile),上传状态(IBook 状态、IBookFile 状态等)。 23 | 24 | 下面介绍具体功能实现逻辑(这之间涉及到 Electron IPC 通信,不一一列举,只说整体能力): 25 | 26 | 首先扫描用户选择目录下的所有文件,进行聚类,将同名文件(不考虑后缀)聚类到一起。这样,形成一个 Map,key 为无后缀文件名,value 为文件列表。 27 | 28 | 然后,对于每个聚类,创建一个 IBook,同时创建多个 IBookFile,每个 IBookFile 指向同一个 IBook。 29 | 30 | 在创建 IBook 过程中,如果 value 中包含 epub 类型,则从 epub 文件中进行解析,获取 IBook 的基本信息(如作者、标题等),同时获取封面。用 epub 中的书籍元信息填充 IBook。将图书封面进行上传 MinIO。 31 | 32 | IBook 创建好后,再依次将 IBookFile 上传 MinIO,每个 IBookFile 均关联到 IBook。 33 | 34 | 在上传的过程中,需要更新进度条,同时更新整体情况和详细情况,直至所有文件上传完成。 35 | 36 | 在整个过程的实现中,需要保障最高的可靠性,要求能够处理各种异常情况,如文件解析失败、文件上传失败等。要避免前端进度展示正常,实际逻辑已经错乱的情况。要做到尽可能得严谨,保障功能的正确性。 37 | -------------------------------------------------------------------------------- /forge.config.ts: -------------------------------------------------------------------------------- 1 | import type { ForgeConfig } from "@electron-forge/shared-types"; 2 | import { MakerSquirrel } from "@electron-forge/maker-squirrel"; 3 | import { MakerZIP } from "@electron-forge/maker-zip"; 4 | import { MakerDeb } from "@electron-forge/maker-deb"; 5 | import { MakerRpm } from "@electron-forge/maker-rpm"; 6 | import { AutoUnpackNativesPlugin } from "@electron-forge/plugin-auto-unpack-natives"; 7 | import { WebpackPlugin } from "@electron-forge/plugin-webpack"; 8 | import { FusesPlugin } from "@electron-forge/plugin-fuses"; 9 | import { FuseV1Options, FuseVersion } from "@electron/fuses"; 10 | 11 | import { mainConfig } from "./webpack.main.config"; 12 | import { rendererConfig } from "./webpack.renderer.config"; 13 | 14 | const config: ForgeConfig = { 15 | packagerConfig: { 16 | asar: true, 17 | }, 18 | rebuildConfig: {}, 19 | makers: [ 20 | new MakerSquirrel({}), 21 | // new MakerZIP({}, ["darwin"]), 22 | // new MakerRpm({}), 23 | new MakerDeb({}), 24 | ], 25 | plugins: [ 26 | new AutoUnpackNativesPlugin({}), 27 | new WebpackPlugin({ 28 | mainConfig, 29 | renderer: { 30 | config: rendererConfig, 31 | entryPoints: [ 32 | { 33 | html: "./src/index.html", 34 | js: "./src/renderer.ts", 35 | name: "main_window", 36 | preload: { 37 | js: "./src/preload.ts", 38 | }, 39 | }, 40 | ], 41 | }, 42 | }), 43 | // Fuses are used to enable/disable various Electron functionality 44 | // at package time, before code signing the application 45 | new FusesPlugin({ 46 | version: FuseVersion.V1, 47 | [FuseV1Options.RunAsNode]: false, 48 | [FuseV1Options.EnableCookieEncryption]: true, 49 | [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, 50 | [FuseV1Options.EnableNodeCliInspectArguments]: false, 51 | [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, 52 | [FuseV1Options.OnlyLoadAppFromAsar]: true, 53 | }), 54 | ], 55 | }; 56 | 57 | export default config; 58 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | roots: ['/src'], 5 | testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'], 6 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "raybook", 3 | "productName": "raybook", 4 | "version": "1.0.0", 5 | "description": "RayBook - An open source ebook reader", 6 | "main": ".webpack/main", 7 | "scripts": { 8 | "start": "electron-forge start", 9 | "make": "electron-forge make", 10 | "publish": "electron-forge publish", 11 | "lint": "eslint --ext .ts,.tsx .", 12 | "test": "jest", 13 | "postinstall": "patch-package" 14 | }, 15 | "devDependencies": { 16 | "@electron-forge/cli": "^7.4.0", 17 | "@electron-forge/maker-deb": "^7.4.0", 18 | "@electron-forge/maker-rpm": "^7.4.0", 19 | "@electron-forge/maker-squirrel": "^7.4.0", 20 | "@electron-forge/maker-zip": "^7.4.0", 21 | "@electron-forge/plugin-auto-unpack-natives": "^7.4.0", 22 | "@electron-forge/plugin-fuses": "^7.4.0", 23 | "@electron-forge/plugin-webpack": "^7.4.0", 24 | "@electron/fuses": "^1.8.0", 25 | "@types/jest": "^29.5.12", 26 | "@types/react": "^18.3.3", 27 | "@types/react-dom": "^18.3.0", 28 | "@typescript-eslint/eslint-plugin": "^5.0.0", 29 | "@typescript-eslint/parser": "^5.0.0", 30 | "@vercel/webpack-asset-relocator-loader": "1.7.3", 31 | "copy-webpack-plugin": "^12.0.2", 32 | "css-loader": "^6.0.0", 33 | "electron": "31.0.1", 34 | "eslint": "^8.0.1", 35 | "eslint-plugin-import": "^2.25.0", 36 | "fork-ts-checker-webpack-plugin": "^7.2.13", 37 | "jest": "^29.7.0", 38 | "node-loader": "^2.0.0", 39 | "patch-package": "^8.0.0", 40 | "style-loader": "^3.0.0", 41 | "ts-jest": "^29.1.5", 42 | "ts-loader": "^9.2.2", 43 | "ts-node": "^10.0.0", 44 | "typescript": "~4.5.4" 45 | }, 46 | "keywords": [], 47 | "author": { 48 | "name": "Maxiee", 49 | "email": "maxieewong@gmail.com" 50 | }, 51 | "license": "MIT", 52 | "dependencies": { 53 | "@types/pdf-parse": "^1.1.4", 54 | "antd": "^5.18.3", 55 | "aws-sdk": "^2.1646.0", 56 | "axios": "^1.7.2", 57 | "date-fns": "^3.6.0", 58 | "electron-is-dev": "^3.0.1", 59 | "electron-log": "^5.1.5", 60 | "electron-squirrel-startup": "^1.0.1", 61 | "electron-store": "^8.2.0", 62 | "epub2": "^3.0.2", 63 | "file-type": "^19.3.0", 64 | "mongoose": "^8.4.3", 65 | "pdf-parse": "^1.1.1", 66 | "react": "^18.3.1", 67 | "react-dom": "^18.3.1", 68 | "react-pdf": "^9.1.0", 69 | "react-reader": "^2.0.10", 70 | "react-router-dom": "^6.24.0", 71 | "ts-debounce": "^4.0.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /patches/pdf-parse+1.1.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/pdf-parse/index.js b/node_modules/pdf-parse/index.js 2 | index e9fc367..7d06b0c 100644 3 | --- a/node_modules/pdf-parse/index.js 4 | +++ b/node_modules/pdf-parse/index.js 5 | @@ -3,7 +3,7 @@ const Pdf = require('./lib/pdf-parse.js'); 6 | 7 | module.exports = Pdf; 8 | 9 | -let isDebugMode = !module.parent; 10 | +let isDebugMode = false; 11 | 12 | //process.env.AUTO_KENT_DEBUG 13 | 14 | -------------------------------------------------------------------------------- /project2context.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | python ../ProjectToContext/main.py src -------------------------------------------------------------------------------- /src/__test__/CoverImageRepository.test.ts: -------------------------------------------------------------------------------- 1 | import { CoverIamgeRepostory } from "../repository/CoverImageRepostory"; 2 | 3 | describe('CoverIamgeRepostory', () => { 4 | describe('constructCoverImageFilename', () => { 5 | it('should correctly construct filename with image/jpeg mime type', () => { 6 | const result = CoverIamgeRepostory.constructCoverImageFilename('book123', 'image/jpeg'); 7 | expect(result).toBe('book-cover-images/book123.jpeg'); 8 | }); 9 | 10 | it('should correctly construct filename with image/png mime type', () => { 11 | const result = CoverIamgeRepostory.constructCoverImageFilename('book456', 'image/png'); 12 | expect(result).toBe('book-cover-images/book456.png'); 13 | }); 14 | 15 | it('should handle mime types without subtype', () => { 16 | const result = CoverIamgeRepostory.constructCoverImageFilename('book789', 'image'); 17 | expect(result).toBe('book-cover-images/book789.undefined'); 18 | }); 19 | 20 | it('should handle non-standard mime types', () => { 21 | const result = CoverIamgeRepostory.constructCoverImageFilename('book101', 'application/octet-stream'); 22 | expect(result).toBe('book-cover-images/book101.octet-stream'); 23 | }); 24 | }); 25 | }); -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { 4 | BrowserRouter as Router, 5 | Route, 6 | Routes, 7 | useNavigate, 8 | useLocation, 9 | MemoryRouter, 10 | } from "react-router-dom"; 11 | import HomePage from "./pages/home/HomePage"; 12 | import ReaderPage from "./pages/reader/epub/ReaderPage"; 13 | import { createIpcProxy } from "./core/ipc/IpcClient"; 14 | import { IBookService } from "./services/book/BookServiceInterface"; 15 | import { IBookFileService } from "./services/bookfile/BookFileServiceInterface"; 16 | import { IBookCoverService } from "./services/bookcover/BookCoverServiceInterface"; 17 | import { IFileService } from "./services/file/FileServiceInterface"; 18 | import { IEpubService } from "./services/epub/EpubServiceInterface"; 19 | import BatchUploadPage from "./pages/batchupload/BatchUploadPage"; 20 | import { ILogService } from "./services/log/LogServiceInterface"; 21 | import SettingsPage from "./pages/settings/SettingsPage"; 22 | import { configStore } from "./config/ConfigStore"; 23 | import Sha256CompletionPage from "./pages/settings/toolbox/Sha256CompletionPage"; 24 | import WeixinReadPage from "./pages/reader/weixin/WeixinReadPage"; 25 | import PDFReaderPage from "./pages/reader/pdf/PDFReaderPage"; 26 | 27 | export const bookServiceRender = createIpcProxy("BookService"); 28 | export const bookFileServiceRender = 29 | createIpcProxy("BookFileService"); 30 | export const bookCoverServiceRender = 31 | createIpcProxy("BookCoverService"); 32 | export const fileServiceRender = createIpcProxy("FileService"); 33 | export const epubServiceRender = createIpcProxy("EpubService"); 34 | export const logServiceRender = createIpcProxy("LogService"); 35 | 36 | const AppRoutes: React.FC = () => { 37 | const navigate = useNavigate(); 38 | const location = useLocation(); 39 | 40 | useEffect(() => { 41 | logServiceRender.info("AppRoutes started"); 42 | if (!configStore.hasRequiredConfig()) { 43 | if (location.pathname !== "/settings") { 44 | navigate("/settings"); 45 | } 46 | } 47 | }, [navigate, location]); 48 | 49 | return ( 50 | 51 | } /> 52 | } /> 53 | } /> 54 | } /> 55 | } /> 56 | } /> 57 | } /> 58 | } 61 | /> 62 | 63 | ); 64 | }; 65 | 66 | const App: React.FC = () => { 67 | return ( 68 | 69 | 70 | 71 | ); 72 | }; 73 | 74 | function render() { 75 | const root = ReactDOM.createRoot( 76 | document.getElementById("root") as HTMLElement 77 | ); 78 | root.render(); 79 | } 80 | 81 | render(); 82 | -------------------------------------------------------------------------------- /src/config/ConfigStore.ts: -------------------------------------------------------------------------------- 1 | import Store from "electron-store"; 2 | import path from "path"; 3 | import os from "os"; 4 | 5 | interface Config { 6 | minioEndpoint?: string; 7 | minioAccessKey?: string; 8 | minioSecretKey?: string; 9 | mongoUri?: string; 10 | defaultStoragePath?: string; 11 | } 12 | 13 | class ConfigStore { 14 | private store: Store; 15 | 16 | constructor() { 17 | this.store = new Store(); 18 | // 设置默认存储路径 19 | if (!this.store.has("defaultStoragePath")) { 20 | this.store.set("defaultStoragePath", path.join(os.homedir(), ".raybook")); 21 | } 22 | } 23 | 24 | getConfig(): Config { 25 | return this.store.store; 26 | } 27 | 28 | setConfig(config: Partial): void { 29 | this.store.set(config); 30 | } 31 | 32 | hasRequiredConfig(): boolean { 33 | const config = this.getConfig(); 34 | return !!( 35 | config.minioEndpoint && 36 | config.minioAccessKey && 37 | config.minioSecretKey && 38 | config.mongoUri && 39 | config.defaultStoragePath 40 | ); 41 | } 42 | 43 | getDefaultStoragePath(): string { 44 | return this.store.get("defaultStoragePath") as string; 45 | } 46 | } 47 | 48 | export const configStore = new ConfigStore(); 49 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const BUCKET_NAME = 'raybook'; -------------------------------------------------------------------------------- /src/core/ipc/ApiResponse.ts: -------------------------------------------------------------------------------- 1 | export interface ApiResponse { 2 | success: boolean; 3 | message: string; 4 | payload?: T; 5 | } -------------------------------------------------------------------------------- /src/core/ipc/IpcClient.ts: -------------------------------------------------------------------------------- 1 | const { ipcRenderer } = window.require('electron'); 2 | 3 | // renderer/ipc/ipcClient.ts 4 | export function createIpcProxy(serviceName: string): T { 5 | return new Proxy({} as T, { 6 | get: (target, prop) => { 7 | return (...args: any[]) => ipcRenderer.invoke(`${serviceName}:${prop.toString()}`, ...args); 8 | } 9 | }); 10 | } -------------------------------------------------------------------------------- /src/core/ipc/IpcWrapper.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from "electron"; 2 | import { logService } from "../../services/log/LogServiceImpl"; 3 | 4 | export function registerIpcHandlers(service: any) { 5 | for (const method of Object.getOwnPropertyNames( 6 | Object.getPrototypeOf(service) 7 | )) { 8 | if (typeof service[method] === "function") { 9 | ipcMain.handle( 10 | `${service.constructor.name}:${method}`, 11 | async (event, ...args) => { 12 | try { 13 | return await service[method](...args); 14 | } catch (error) { 15 | logService.error( 16 | `Error in ${service.constructor.name}:${method}:`, 17 | error 18 | ); 19 | throw error; 20 | } 21 | } 22 | ); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/data/minio/MinioClient.ts: -------------------------------------------------------------------------------- 1 | import AWS from "aws-sdk"; 2 | import { logService } from "../../services/log/LogServiceImpl"; 3 | 4 | export const minioEndpoint = process.env.MINIO_ENDPOINT; 5 | const minioAccessKey = process.env.MINIO_ACCESS_KEY; 6 | const minioSecretKey = process.env.MINIO_SECRET_KEY; 7 | 8 | if (!minioEndpoint || !minioAccessKey || !minioSecretKey) { 9 | throw new Error("MinIO configuration not found in environment variables"); 10 | } 11 | 12 | const s3Client = new AWS.S3({ 13 | endpoint: minioEndpoint, 14 | s3ForcePathStyle: true, 15 | signatureVersion: "v4", 16 | accessKeyId: minioAccessKey, 17 | secretAccessKey: minioSecretKey, 18 | }); 19 | 20 | async function checkAndCreateBucket(bucketName: string) { 21 | const params = { Bucket: bucketName }; 22 | 23 | try { 24 | // 尝试获取 Bucket 信息以检查其是否存在 25 | await s3Client.headBucket(params).promise(); 26 | logService.info(`Bucket "${bucketName}" 已经存在。`); 27 | } catch (error) { 28 | // 如果 Bucket 不存在,则根据错误类型决定操作 29 | if (error.statusCode === 404) { 30 | logService.info(`Bucket "${bucketName}" 不存在,正在尝试创建...`); 31 | try { 32 | await s3Client.createBucket(params).promise(); 33 | logService.info(`Bucket "${bucketName}" 创建成功。`); 34 | } catch (createError) { 35 | logService.error("创建 Bucket 失败:", createError); 36 | } 37 | } else { 38 | logService.error("检查 Bucket 时发生错误:", error); 39 | } 40 | } 41 | } 42 | 43 | // 检查 RayBook 桶是否存在,不存在则创建 44 | checkAndCreateBucket("raybook"); 45 | 46 | export default s3Client; 47 | -------------------------------------------------------------------------------- /src/data/processors/BookProcessor.ts: -------------------------------------------------------------------------------- 1 | export interface BookMetadata { 2 | title: string; 3 | author?: string; 4 | publisher?: string; 5 | isbn?: string; 6 | publicationYear?: number; 7 | } 8 | 9 | export interface BookProcessor { 10 | canProcess(filePath: string): boolean; 11 | extractMetadata(filePath: string): Promise; 12 | extractCover(filePath: string): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /src/data/processors/BookProcessorFactory.ts: -------------------------------------------------------------------------------- 1 | import { BookProcessor } from "./BookProcessor"; 2 | import { EpubProcessor } from "./EpubProcessor"; 3 | import { PdfProcessor } from "./PdfProcessor"; 4 | 5 | export class BookProcessorFactory { 6 | private processors: BookProcessor[] = [ 7 | new EpubProcessor(), 8 | new PdfProcessor(), 9 | ]; 10 | 11 | getProcessor(filePath: string): BookProcessor | null { 12 | for (const processor of this.processors) { 13 | if (processor.canProcess(filePath)) { 14 | return processor; 15 | } 16 | } 17 | return null; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/data/processors/EpubProcessor.ts: -------------------------------------------------------------------------------- 1 | import EPub from "epub2"; 2 | import { BookProcessor, BookMetadata } from "./BookProcessor"; 3 | 4 | export class EpubProcessor implements BookProcessor { 5 | canProcess(filePath: string): boolean { 6 | return filePath.toLowerCase().endsWith(".epub"); 7 | } 8 | 9 | async extractMetadata(filePath: string): Promise { 10 | const epub: EPub = await EPub.createAsync(filePath); 11 | const metadata = epub.metadata; 12 | return { 13 | title: metadata.title || "", 14 | author: metadata.creator, 15 | publisher: metadata.publisher, 16 | isbn: metadata.ISBN, 17 | publicationYear: metadata.date 18 | ? new Date(metadata.date).getFullYear() 19 | : undefined, 20 | }; 21 | } 22 | 23 | async extractCover(filePath: string): Promise { 24 | const epub: EPub = await EPub.createAsync(filePath); 25 | const coverPath = epub.metadata.cover; 26 | if (!coverPath) return null; 27 | 28 | return new Promise((resolve, reject) => { 29 | epub.getImage(coverPath, (err: Error, data: Buffer, mimeType: string) => { 30 | if (err) reject(err); 31 | else resolve(data); 32 | }); 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/data/processors/PdfProcessor.ts: -------------------------------------------------------------------------------- 1 | import PdfParse from "pdf-parse"; 2 | import { BookProcessor, BookMetadata } from "./BookProcessor"; 3 | import fs from "fs"; 4 | import { logService } from "../../services/log/LogServiceImpl"; 5 | 6 | export class PdfProcessor implements BookProcessor { 7 | canProcess(filePath: string): boolean { 8 | return filePath.toLowerCase().endsWith(".pdf"); 9 | } 10 | 11 | async extractMetadata(filePath: string): Promise { 12 | const dataBuffer = fs.readFileSync(filePath); 13 | const data = await PdfParse(dataBuffer); 14 | 15 | // PDF metadata extraction is limited, so we'll use what we can 16 | logService.debug("PDF info", data.info); 17 | logService.debug("PDF metadata", data.metadata); 18 | return { 19 | title: data.info.Title || "", 20 | author: data.info.Author, 21 | publisher: data.info.Producer, 22 | // ISBN and publication year are typically not available in PDF metadata 23 | }; 24 | } 25 | 26 | async extractCover(filePath: string): Promise { 27 | // PDF cover extraction is complex and beyond the scope of this example 28 | // For a real implementation, you might need a more specialized library 29 | return null; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, 3 | Arial, 4 | sans-serif; 5 | } 6 | 7 | html, 8 | body, 9 | #app { 10 | height: 100%; 11 | width: 100%; 12 | margin: 0; 13 | } 14 | 15 | #root { 16 | height: 100%; 17 | width: 100%; 18 | } 19 | 20 | .homepage { 21 | height: 100%; 22 | width: 100%; 23 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | app, 3 | BrowserWindow, 4 | BrowserView, 5 | ipcMain, 6 | session, 7 | WebContents, 8 | } from "electron"; 9 | import mongoose from "mongoose"; 10 | import { minioEndpoint } from "./data/minio/MinioClient"; 11 | import { epubService } from "./services/epub/EpubServiceImpl"; 12 | import { registerIpcHandlers } from "./core/ipc/IpcWrapper"; 13 | import { bookFileService } from "./services/bookfile/BookFileServiceImpl"; 14 | import { bookService } from "./services/book/BookServiceImpl"; 15 | import { bookCoverService } from "./services/bookcover/BookCoverServiceImpl"; 16 | import { fileService } from "./services/file/FileServiceImpl"; 17 | import isDev from "electron-is-dev"; 18 | import { logService } from "./services/log/LogServiceImpl"; 19 | import Store from "electron-store"; 20 | import { debounce } from "ts-debounce"; 21 | import { URL } from "url"; 22 | 23 | // This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack 24 | // plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on 25 | // whether you're running in development or production). 26 | declare const MAIN_WINDOW_WEBPACK_ENTRY: string; 27 | declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string; 28 | 29 | const DEBUG_WEIXIN_READ = false; 30 | 31 | // Handle creating/removing shortcuts on Windows when installing/uninstalling. 32 | if (require("electron-squirrel-startup")) { 33 | app.quit(); 34 | } 35 | 36 | Store.initRenderer(); 37 | 38 | let weixinReadBrowserView: BrowserView | null = null; 39 | let mainWindow: BrowserWindow | null = null; 40 | 41 | const createWindow = (): void => { 42 | logService.info("Application started"); 43 | logService.info("Maxiee 是否是测试环境", isDev); 44 | logService.info(MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY); 45 | logService.info(MAIN_WINDOW_WEBPACK_ENTRY); 46 | // Create the browser window. 47 | mainWindow = new BrowserWindow({ 48 | title: "RayBook", 49 | height: 600, 50 | width: 800, 51 | webPreferences: { 52 | nodeIntegration: true, 53 | contextIsolation: false, 54 | webSecurity: false, // 警告:这会禁用所有的 web 安全性检查 55 | preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY, 56 | }, 57 | }); 58 | 59 | // 设置 CSP 60 | session.defaultSession.webRequest.onHeadersReceived((details, callback) => { 61 | let csp; 62 | if (process.env.NODE_ENV === "development") { 63 | // 开发环境的 CSP 64 | csp = [ 65 | "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: file:;", 66 | "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: file:;", 67 | `img-src 'self' data: ${minioEndpoint} cdn.weread.qq.com blob: file:;`, 68 | "style-src 'self' 'unsafe-inline' blob:;", 69 | "font-src 'self' data: file:;", 70 | "worker-src 'self' blob:;", 71 | `connect-src 'self' ws: ${minioEndpoint} http://localhost:* http://0.0.0.0:* file: blob:;`, 72 | ].join(" "); 73 | } else { 74 | // 生产环境的 CSP 75 | csp = [ 76 | "default-src 'self' 'unsafe-inline' data: blob: file:;", 77 | `img-src 'self' data: ${minioEndpoint} file:;`, 78 | "style-src 'self' 'unsafe-inline' blob:;", 79 | "font-src 'self' data: file:;", 80 | "worker-src 'self' blob:;", 81 | `connect-src 'self' ${minioEndpoint} file: blob:;`, 82 | ].join(" "); 83 | } 84 | 85 | callback({ 86 | responseHeaders: { 87 | ...details.responseHeaders, 88 | "Content-Security-Policy": csp, 89 | }, 90 | }); 91 | }); 92 | 93 | // and load the index.html of the app. 94 | mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY); 95 | 96 | mainWindow.webContents.on("will-navigate", (event, url) => { 97 | event.preventDefault(); 98 | mainWindow.loadURL(url); 99 | }); 100 | 101 | // Open the DevTools. 102 | mainWindow.webContents.openDevTools(); 103 | }; 104 | 105 | // This method will be called when Electron has finished 106 | // initialization and is ready to create browser windows. 107 | // Some APIs can only be used after this event occurs. 108 | app.on("ready", createWindow); 109 | 110 | // Quit when all windows are closed, except on macOS. There, it's common 111 | // for applications and their menu bar to stay active until the user quits 112 | // explicitly with Cmd + Q. 113 | app.on("window-all-closed", () => { 114 | logService.info("Application exited"); 115 | if (process.platform !== "darwin") { 116 | app.quit(); 117 | } 118 | }); 119 | 120 | app.on("activate", () => { 121 | logService.info("Application activated"); 122 | // On OS X it's common to re-create a window in the app when the 123 | // dock icon is clicked and there are no other windows open. 124 | if (BrowserWindow.getAllWindows().length === 0) { 125 | createWindow(); 126 | } 127 | }); 128 | 129 | // 连接到 MongoDB, ENV RAYBOOK_MONGO_URI 130 | mongoose.connect(process.env.RAYBOOK_MONGO_URI); 131 | 132 | // In this file you can include the rest of your app's specific main process 133 | // code. You can also put them in separate files and import them here. 134 | 135 | // 注册 IPC 处理程序 136 | registerIpcHandlers(bookService); 137 | registerIpcHandlers(bookFileService); 138 | registerIpcHandlers(bookCoverService); 139 | registerIpcHandlers(fileService); 140 | registerIpcHandlers(epubService); 141 | registerIpcHandlers(logService); 142 | 143 | ipcMain.on("config-updated", () => { 144 | app.relaunch(); 145 | app.exit(0); 146 | }); 147 | 148 | // 定义请求数据的类型 149 | type RequestData = { [key: string]: any }; 150 | 151 | // 定义处理函数类型 152 | type WeReadHandler = (bookKey: string) => void; 153 | 154 | // 定义每本书的请求数据映射 155 | type BookRequestMap = Map; 156 | 157 | // 定义全局数据存储 158 | const globalBookData: Map = new Map(); 159 | 160 | // 当前正在阅读的书籍的 key 161 | let currentBookKey: string | null = null; 162 | 163 | // 创建一个处理函数 164 | const handleWeReadUrl: WeReadHandler = (bookKey: string) => { 165 | logService.info(`Detected WeRead book with Key: ${bookKey}`); 166 | currentBookKey = bookKey; 167 | 168 | // 如果这是一本新书,为它创建一个新的 Map 169 | if (!globalBookData.has(bookKey)) { 170 | globalBookData.set(bookKey, new Map()); 171 | } 172 | 173 | // 向渲染进程发送消息 174 | mainWindow.webContents.send("weixin-read-book-opened", bookKey); 175 | }; 176 | 177 | ipcMain.handle("get-book-data", (event, bookKey: string) => { 178 | const bookData = globalBookData.get(bookKey); 179 | logService.debug("get-book-data 获取书籍数据:", bookData); 180 | return bookData ? Object.fromEntries(bookData) : null; 181 | }); 182 | 183 | ipcMain.on("weixin-read:init", async (event, contentBounds) => { 184 | const win = BrowserWindow.fromWebContents(event.sender); 185 | if (!win) return; 186 | 187 | if (!weixinReadBrowserView) { 188 | // 创建一个新的 session 189 | const weixinReadSession = session.fromPartition("persist:weread"); 190 | 191 | weixinReadSession.webRequest.onHeadersReceived((details, callback) => { 192 | const sites = 193 | "https://cdn.weread.qq.com https://midas.gtimg.cn https://*.myqcloud.com https://*.qq.com https://*.qqmail.com https://*.tencent-cloud.com data:"; 194 | callback({ 195 | responseHeaders: { 196 | ...details.responseHeaders, 197 | "Content-Security-Policy": [ 198 | `default-src 'self' 'unsafe-inline' 'unsafe-eval' ${sites};`, 199 | `script-src 'self' 'unsafe-inline' 'unsafe-eval' ${sites};`, 200 | `style-src 'self' 'unsafe-inline' 'unsafe-eval' ${sites};`, 201 | `img-src 'self' data: ${sites};`, 202 | `font-src 'self' data: ${sites};`, 203 | `connect-src 'self' ${sites};`, 204 | "object-src 'none';", 205 | "base-uri 'self';", 206 | ], 207 | }, 208 | }); 209 | }); 210 | 211 | weixinReadBrowserView = new BrowserView({ 212 | webPreferences: { 213 | nodeIntegration: false, 214 | contextIsolation: true, 215 | sandbox: true, 216 | session: weixinReadSession, 217 | }, 218 | }); 219 | 220 | weixinReadBrowserView.webContents.on("did-navigate", (event, url) => { 221 | logService.info("微信读书页面导航到:", url); 222 | mainWindow.webContents.send("weixin-reader-url-changed", url); 223 | 224 | // 添加对特定 URL 的处理 225 | const parsedUrl = new URL(url); 226 | if ( 227 | parsedUrl.hostname === "weread.qq.com" && 228 | parsedUrl.pathname.startsWith("/web/reader/") 229 | ) { 230 | const match = parsedUrl.pathname.match(/\/web\/reader\/([a-zA-Z0-9]+)/); 231 | if (match && match[1]) { 232 | const bookId = match[1]; 233 | handleWeReadUrl(bookId); 234 | } 235 | } 236 | }); 237 | 238 | win.setBrowserView(weixinReadBrowserView); 239 | weixinReadBrowserView.webContents.loadURL("https://weread.qq.com/"); 240 | 241 | const debouncedResize = debounce(() => { 242 | logService.info("debouncedResize"); 243 | if (mainWindow && weixinReadBrowserView) { 244 | mainWindow.webContents.send("window-resize"); 245 | } 246 | }, 1000); // 1000ms 的延迟,可以根据需要调整 247 | 248 | win.on("resize", debouncedResize); 249 | } 250 | 251 | // 设置BrowserView的位置和大小 252 | updateWeixinReadBrowserViewBounds(contentBounds); 253 | setupRequestInterceptor(weixinReadBrowserView.webContents.session); 254 | await setupNetworkListener(weixinReadBrowserView.webContents); 255 | }); 256 | 257 | function handleNetworkResponse(url: string, responseBody: string) { 258 | if (!currentBookKey) return; // 如果没有当前书籍,直接返回 259 | 260 | const bookData = globalBookData.get(currentBookKey); 261 | if (!bookData) return; // 如果没有找到对应的书籍数据,直接返回 262 | 263 | // 这里添加您的规则来匹配不同类型的请求 264 | if (url.includes("web/book/bookmarklist")) { 265 | bookData.set("bookmarklist", JSON.parse(responseBody)); 266 | } else if (url.includes("web/book/bestbookmarks")) { 267 | bookData.set("bestbookmarks", JSON.parse(responseBody)); 268 | } 269 | 270 | // 更新全局数据 271 | globalBookData.set(currentBookKey, bookData); 272 | } 273 | 274 | async function setupNetworkListener(webContents: WebContents) { 275 | // 启用网络事件 276 | await webContents.debugger.attach("1.3"); 277 | await webContents.debugger.sendCommand("Network.enable"); 278 | 279 | const responseBodyMap = new Map(); 280 | 281 | webContents.debugger.on("message", (event, method, params) => { 282 | if (method === "Network.responseReceived") { 283 | const { requestId, response } = params; 284 | const { url, mimeType } = response; 285 | 286 | if ( 287 | // url.includes("weread.qq.com") && 288 | mimeType.includes("application/json") 289 | ) { 290 | webContents.debugger 291 | .sendCommand("Network.getResponseBody", { requestId }) 292 | .then(({ body, base64Encoded }) => { 293 | const decodedBody = base64Encoded ? atob(body) : body; 294 | responseBodyMap.set(requestId, decodedBody); 295 | if (DEBUG_WEIXIN_READ) { 296 | logService.info(`API 响应: ${url}`); 297 | logService.info("响应体:", decodedBody); 298 | } 299 | 300 | // 调用新的处理函数 301 | handleNetworkResponse(url, decodedBody); 302 | }) 303 | .catch((error) => { 304 | // 打印出错 URL 305 | logService.error("error URL:", url); 306 | logService.error("获取响应体时出错:", error); 307 | }); 308 | } 309 | } 310 | }); 311 | 312 | webContents.debugger.on("detach", (event, reason) => { 313 | logService.info("Debugger detached:", reason); 314 | }); 315 | } 316 | 317 | const BLOCKED_URLS = [ 318 | "https://weread.qq.com/sentry/", 319 | // 可以在这里添加其他需要拦截的 URL 前缀 320 | ]; 321 | 322 | function setupRequestInterceptor(sess: Electron.Session) { 323 | sess.webRequest.onBeforeRequest((details, callback) => { 324 | const shouldBlock = BLOCKED_URLS.some((url) => details.url.startsWith(url)); 325 | if (shouldBlock) { 326 | logService.info("拦截到请求:", details.url); 327 | // logService.info("拦截的请求详情:", details.method, details.uploadData); 328 | callback({ cancel: true }); 329 | } else { 330 | callback({ cancel: false }); 331 | } 332 | }); 333 | } 334 | 335 | // 新增更新 BrowserView 大小的函数 336 | let lastBounds = { width: 0, height: 0 }; 337 | 338 | function updateWeixinReadBrowserViewBounds(contentBounds?: { 339 | x: number; 340 | y: number; 341 | width: number; 342 | height: number; 343 | }) { 344 | if (!weixinReadBrowserView) return; 345 | 346 | if ( 347 | contentBounds.width !== lastBounds.width || 348 | contentBounds.height !== lastBounds.height 349 | ) { 350 | weixinReadBrowserView.setBounds({ 351 | x: contentBounds.x, 352 | y: contentBounds.y, 353 | width: contentBounds.width, 354 | height: contentBounds.height, 355 | }); 356 | lastBounds = { width: contentBounds.width, height: contentBounds.height }; 357 | } 358 | } 359 | 360 | ipcMain.on("weixin-read:resize", (event, contentBounds) => { 361 | const win = BrowserWindow.fromWebContents(event.sender); 362 | if (win) { 363 | updateWeixinReadBrowserViewBounds(contentBounds); 364 | } 365 | }); 366 | 367 | ipcMain.on("weixin-read:cleanup", (event) => { 368 | const win = BrowserWindow.fromWebContents(event.sender); 369 | if (win && weixinReadBrowserView) { 370 | win.removeBrowserView(weixinReadBrowserView); 371 | if (DEBUG_WEIXIN_READ) { 372 | weixinReadBrowserView.webContents.debugger.detach(); 373 | } 374 | weixinReadBrowserView = null; 375 | } 376 | currentBookKey = null; 377 | }); 378 | 379 | ipcMain.on("weixin-read:navigate", (event, url) => { 380 | if (weixinReadBrowserView) { 381 | weixinReadBrowserView.webContents.loadURL(url); 382 | } 383 | }); 384 | 385 | // // 删除书籍文件 386 | // ipcMain.handle('delete-book-file', async (event, fileId) => { 387 | // try { 388 | // const bookFile = await BookFile.findById(fileId); 389 | // if (!bookFile) { 390 | // return { success: false, message: '文件不存在' }; 391 | // } 392 | 393 | // // 从 MinIO 删除文件 394 | // await s3Client.deleteObject({ 395 | // Bucket: 'raybook', 396 | // Key: bookFile.path 397 | // }).promise(); 398 | 399 | // // 从数据库中删除文件记录 400 | // await BookFile.findByIdAndDelete(fileId); 401 | 402 | // // 更新相关的 Book 文档 403 | // await Book.findByIdAndUpdate(bookFile.book, { $pull: { files: fileId } }); 404 | 405 | // return { success: true, message: '文件删除成功' }; 406 | // } catch (error) { 407 | // console.error('删除文件时出错:', error); 408 | // return { success: false, message: '删除文件失败' }; 409 | // } 410 | // }); 411 | -------------------------------------------------------------------------------- /src/models/Book.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema, Document } from "mongoose"; 2 | 3 | export interface IBook extends Document { 4 | _id: Id; 5 | title: string; 6 | subtitle?: string; 7 | series?: string; 8 | author?: string; 9 | translator?: string; 10 | originalTitle?: string; 11 | publisher?: string; 12 | publicationYear?: number; 13 | isbn?: string; 14 | lastReadTime?: Date; 15 | coverImagePath?: string; 16 | weixinBookKey?: string; 17 | weixinBookId?: string; 18 | weixinBookTitle?: string; 19 | weixinBookAuthor?: string; 20 | } 21 | 22 | const BookSchema: Schema = new Schema({ 23 | title: { type: String, required: true }, 24 | subtitle: String, 25 | series: String, 26 | author: String, 27 | translator: String, 28 | originalTitle: String, 29 | publisher: String, 30 | publicationYear: Number, 31 | isbn: String, 32 | lastReadTime: { type: Date }, 33 | coverImagePath: { type: String, required: false }, 34 | weixinBookKey: { type: String }, 35 | weixinBookId: { type: String }, 36 | weixinBookTitle: { type: String }, 37 | weixinBookAuthor: { type: String }, 38 | }); 39 | 40 | export default mongoose.model("Book", BookSchema); 41 | -------------------------------------------------------------------------------- /src/models/BookFile.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema, Document } from "mongoose"; 2 | 3 | export interface IBookFile extends Document { 4 | _id: Id; 5 | filename: string; 6 | format: string; 7 | path: string; 8 | size: number; 9 | book: Id; 10 | sha256?: string; // 新增 sha256 属性 11 | location?: string; 12 | } 13 | 14 | const BookFileSchema: Schema = new Schema({ 15 | filename: { type: String, required: true }, 16 | format: { type: String, required: true }, 17 | path: { type: String, required: true }, 18 | size: { type: Number, required: true }, 19 | book: { type: Schema.Types.ObjectId, ref: "Book", required: true }, 20 | sha256: { type: String, required: false }, // 新增 sha256 字段 21 | location: { type: String, required: false }, 22 | }); 23 | 24 | export const BookFile = mongoose.model("BookFile", BookFileSchema); 25 | -------------------------------------------------------------------------------- /src/pages/batchupload/BatchUploadPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useLocation, useNavigate } from "react-router-dom"; 3 | import { Progress, Table, Typography, Card, message, Button } from "antd"; 4 | import { 5 | bookCoverServiceRender, 6 | bookFileServiceRender, 7 | bookServiceRender, 8 | epubServiceRender, 9 | logServiceRender, 10 | } from "../../app"; 11 | import { BookWithFiles } from "../../services/book/BookServiceInterface"; 12 | import { IMetadata } from "epub2/lib/epub/const"; 13 | 14 | const { Title, Text } = Typography; 15 | 16 | const BatchUploadPage: React.FC = () => { 17 | const [isUploading, setIsUploading] = useState(false); 18 | const [isUploadComplete, setIsUploadComplete] = useState(false); 19 | const [totalFiles, setTotalFiles] = useState(0); 20 | const [totalBooks, setTotalBooks] = useState(0); 21 | const [progress, setProgress] = useState(0); 22 | const [duplicateFiles, setDuplicateFiles] = useState([]); 23 | const [isUploadAllowed, setIsUploadAllowed] = useState(true); 24 | const [bookWithFileList, setBookWithFileList] = useState([]); 25 | const [bookModelSaveStatus, setBookModelSaveStatus] = useState<{ 26 | [key: string]: "pending" | "success" | "error"; 27 | }>({}); 28 | const [bookFileUploadStatus, setBookFileUploadStatus] = useState<{ 29 | [key: string]: { [key: string]: "pending" | "success" | "error" }; 30 | }>({}); 31 | 32 | const location = useLocation(); 33 | const navigate = useNavigate(); 34 | 35 | useEffect(() => { 36 | const searchParams = new URLSearchParams(location.search); 37 | const selectedDir = searchParams.get("dir"); 38 | if (selectedDir) { 39 | logServiceRender.info("Selected directory:", selectedDir); 40 | loadBooks(selectedDir); 41 | } 42 | }, [location]); 43 | 44 | const loadBooks = async (directory: string) => { 45 | logServiceRender.info("Loading books from directory:", directory); 46 | // 已将 epub 类型文件放在 files 的第一位 47 | const bookBatchResult = await bookServiceRender.batchParseBooksInDirectory( 48 | directory 49 | ); 50 | const bookBatch = bookBatchResult.payload; 51 | 52 | if (!bookBatch) { 53 | logServiceRender.error("Failed to batch parse books"); 54 | message.error("批量解析书籍失败"); 55 | return; 56 | } 57 | 58 | setTotalFiles(bookBatch.reduce((acc, book) => acc + book.files.length, 0)); 59 | setTotalBooks(bookBatch.length); 60 | 61 | // 执行 sha256 检查 62 | const allFilePaths = bookBatch.flatMap((book) => 63 | book.files.map((file) => file.fullPath) 64 | ); 65 | const sha256CheckResult = await bookFileServiceRender.batchCheckSHA256( 66 | allFilePaths 67 | ); 68 | 69 | if (sha256CheckResult.success && sha256CheckResult.payload) { 70 | const duplicates = Object.entries(sha256CheckResult.payload) 71 | .filter(([, sha256]) => sha256 !== null) 72 | .map(([filePath]) => filePath); 73 | 74 | setDuplicateFiles(duplicates); 75 | 76 | if (duplicates.length > 0) { 77 | setIsUploadAllowed(false); 78 | message.warning("检测到重复文件,请先处理冲突后再上传。"); 79 | } 80 | } else { 81 | message.error("sha256 检查失败,无法确保文件唯一性。"); 82 | setIsUploadAllowed(false); 83 | } 84 | 85 | setBookWithFileList(bookBatch); 86 | }; 87 | 88 | const startBatchUpload = async () => { 89 | try { 90 | setProgress(0); 91 | const books = bookWithFileList; 92 | 93 | for (let i = 0; i < books.length; i++) { 94 | const bookName = books[i].name; 95 | setBookModelSaveStatus((prevStatus) => ({ 96 | ...prevStatus, 97 | [bookName]: "pending", 98 | })); 99 | 100 | // 提取元数据 101 | // 如果 files 中有 epub,则从 epub 中提取元数据 102 | // 如果 files 中没有 epub,则创建只有书名的空元数据 103 | let bookMetadata = { 104 | title: bookName, 105 | author: "", 106 | publisher: "", 107 | isbn: "", 108 | publicationYear: 0, 109 | }; 110 | 111 | const epubFile = books[i].files.find( 112 | (file) => file.fileExtension === ".epub" 113 | ); 114 | // 提取 epub 元数据 115 | if (epubFile) { 116 | logServiceRender.info("Extracting metadata from epub:", epubFile); 117 | const epubMetadata = await epubServiceRender.extractMetadata( 118 | epubFile.fullPath 119 | ); 120 | logServiceRender.info("Epub metadata:", epubMetadata); 121 | const metadataPayload: IMetadata = epubMetadata.payload; 122 | if (epubMetadata.success) { 123 | bookMetadata = { 124 | title: metadataPayload.title || bookName, 125 | author: metadataPayload.creator || "", 126 | publisher: metadataPayload.publisher || "", 127 | isbn: metadataPayload.ISBN || "", 128 | publicationYear: metadataPayload.date 129 | ? new Date(metadataPayload.date).getFullYear() 130 | : 0, 131 | }; 132 | } 133 | } 134 | logServiceRender.info("Book metadata:", bookMetadata); 135 | const bookModelSaveResult = await bookServiceRender.addBookByModel( 136 | bookMetadata 137 | ); 138 | 139 | if (!bookModelSaveResult.payload) { 140 | logServiceRender.error( 141 | "Failed to save book model:", 142 | bookModelSaveResult.message 143 | ); 144 | setBookModelSaveStatus((prevStatus) => ({ 145 | ...prevStatus, 146 | [bookName]: "error", 147 | })); 148 | continue; 149 | } 150 | 151 | // 提取 Epub 封面 152 | if (epubFile) { 153 | const coverImageResult = 154 | await bookCoverServiceRender.extractLocalBookCover( 155 | bookModelSaveResult.payload._id, 156 | epubFile.fullPath 157 | ); 158 | if (!coverImageResult.success) { 159 | logServiceRender.error( 160 | "Failed to extract cover image:", 161 | coverImageResult.message 162 | ); 163 | } 164 | } 165 | 166 | const bookId = bookModelSaveResult.payload._id; 167 | setBookModelSaveStatus((prevStatus) => ({ 168 | ...prevStatus, 169 | [bookName]: "success", 170 | })); 171 | 172 | setBookFileUploadStatus((prevStatus) => ({ 173 | ...prevStatus, 174 | [bookName]: {}, 175 | })); 176 | 177 | for (let j = 0; j < bookWithFileList[i].files.length; j++) { 178 | const fileName = bookWithFileList[i].files[j].filename; 179 | setBookFileUploadStatus((prevStatus) => ({ 180 | ...prevStatus, 181 | [bookName]: { ...prevStatus[bookName], [fileName]: "pending" }, 182 | })); 183 | 184 | const fileUploadResult = 185 | await bookFileServiceRender.uploadBookFileByPath( 186 | bookId, 187 | bookWithFileList[i].files[j].fullPath 188 | ); 189 | if (!fileUploadResult.success) { 190 | logServiceRender.error( 191 | `Failed to upload file ${fileName} for book ${bookName}:`, 192 | fileUploadResult.message 193 | ); 194 | setBookFileUploadStatus((prevStatus) => ({ 195 | ...prevStatus, 196 | [bookName]: { ...prevStatus[bookName], [fileName]: "error" }, 197 | })); 198 | } else { 199 | setBookFileUploadStatus((prevStatus) => ({ 200 | ...prevStatus, 201 | [bookName]: { ...prevStatus[bookName], [fileName]: "success" }, 202 | })); 203 | } 204 | } 205 | 206 | setProgress((prevProgress) => 207 | Math.round(((i + 1) / books.length) * 100) 208 | ); 209 | } 210 | message.success("批量上传书籍成功"); 211 | } catch (error) { 212 | logServiceRender.error("Failed to batch upload books:", error); 213 | message.error("批量上传书籍失败"); 214 | } 215 | }; 216 | 217 | const handleStartUpload = async () => { 218 | setIsUploading(true); 219 | try { 220 | await startBatchUpload(); 221 | message.success("批量上传完成"); 222 | setIsUploadComplete(true); 223 | } catch (error) { 224 | logServiceRender.error("批量上传失败:", error); 225 | message.error("批量上传失败"); 226 | } finally { 227 | setIsUploading(false); 228 | } 229 | }; 230 | 231 | const handleReturnHome = () => { 232 | navigate("/"); 233 | }; 234 | 235 | const columns = [ 236 | { 237 | title: "书名", 238 | dataIndex: "name", 239 | key: "name", 240 | }, 241 | { 242 | title: "文件", 243 | dataIndex: "files", 244 | key: "files", 245 | render: (files: { filename: string; fileExtension: string }[]) => { 246 | return files.map((file) => file.filename).join(", "); 247 | }, 248 | }, 249 | { 250 | title: "BookModel状态", 251 | key: "status", 252 | render: (record: BookWithFiles) => { 253 | const bookName = record.name; 254 | if (bookModelSaveStatus[bookName] === "success") { 255 | return "已添加"; 256 | } else if (bookModelSaveStatus[bookName] === "error") { 257 | return "添加失败"; 258 | } else { 259 | return "待添加"; 260 | } 261 | }, 262 | }, 263 | { 264 | title: "文件上传状态", 265 | key: "fileStatus", 266 | render: (record: BookWithFiles) => { 267 | const bookName = record.name; 268 | const fileStatus = bookFileUploadStatus[bookName]; 269 | if (!fileStatus) { 270 | return "待上传"; 271 | } 272 | 273 | const pendingFiles = Object.values(fileStatus).filter( 274 | (status) => status === "pending" 275 | ).length; 276 | const successFiles = Object.values(fileStatus).filter( 277 | (status) => status === "success" 278 | ).length; 279 | const errorFiles = Object.values(fileStatus).filter( 280 | (status) => status === "error" 281 | ).length; 282 | 283 | return `${successFiles} 成功, ${errorFiles} 失败, ${pendingFiles} 待上传`; 284 | }, 285 | }, 286 | { 287 | title: "重复检测", 288 | key: "fileStatus", 289 | render: (record: BookWithFiles) => { 290 | const hasDuplicate = record.files.some((file) => 291 | duplicateFiles.includes(file.fullPath) 292 | ); 293 | return hasDuplicate ? "存在重复文件" : "无重复文件"; 294 | }, 295 | }, 296 | ]; 297 | 298 | return ( 299 |
300 | 批量添加书籍 301 | 302 | 303 | 整体情况 304 | 扫描到的文件总数:{totalFiles} 305 |
306 | 合并后的书籍总数:{totalBooks} 307 |
308 | 重复文件数:{duplicateFiles.length} 309 |
310 | 316 | {isUploadAllowed && !isUploadComplete && ( 317 | 327 | )} 328 | 336 | {duplicateFiles.length > 0 && ( 337 | 338 | 重复文件列表 339 |
    340 | {duplicateFiles.map((file, index) => ( 341 |
  • {file}
  • 342 | ))} 343 |
344 |
345 | )} 346 | 347 | ); 348 | }; 349 | 350 | export default BatchUploadPage; 351 | -------------------------------------------------------------------------------- /src/pages/home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from "react"; 2 | import { 3 | Layout, 4 | Typography, 5 | Row, 6 | Col, 7 | Button, 8 | Pagination, 9 | message, 10 | Tabs, 11 | } from "antd"; 12 | import { 13 | BookOutlined, 14 | UploadOutlined, 15 | FolderAddOutlined, 16 | SettingOutlined, 17 | } from "@ant-design/icons"; 18 | import BookCard from "./components/BookCard"; 19 | import { IBook } from "../../models/Book"; 20 | import UploadBookModal from "./modal/UploadBookModal"; 21 | import { 22 | bookServiceRender, 23 | fileServiceRender, 24 | logServiceRender, 25 | } from "../../app"; 26 | import { useNavigate } from "react-router-dom"; 27 | import TabPane from "antd/es/tabs/TabPane"; 28 | import BookList from "./components/BookList"; 29 | 30 | const { Header, Content } = Layout; 31 | const { Title, Text } = Typography; 32 | 33 | const HomePage: React.FC = () => { 34 | const navigate = useNavigate(); 35 | const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); 36 | const [recentlyAddedBooks, setRecentlyAddedBooks] = useState([]); 37 | const [recentlyReadBooks, setRecentlyReadBooks] = useState([]); 38 | const [recentlyAddedTotal, setRecentlyAddedTotal] = useState(0); 39 | const [recentlyReadTotal, setRecentlyReadTotal] = useState(0); 40 | const [currentBookId, setCurrentBookId] = useState(null); 41 | const [recentlyAddedPage, setRecentlyAddedPage] = useState(1); 42 | const [recentlyReadPage, setRecentlyReadPage] = useState(1); 43 | const [activeTab, setActiveTab] = useState("1"); 44 | 45 | const pageSize = 10; 46 | 47 | const fetchRecentlyAddedBooks = async (page: number) => { 48 | const result = await bookServiceRender.getLatestBooks(page, pageSize); 49 | if (result.success) { 50 | setRecentlyAddedBooks(result.payload.books); 51 | setRecentlyAddedTotal(result.payload.total); 52 | } else { 53 | logServiceRender.error( 54 | "Failed to fetch recently added books:", 55 | result.message 56 | ); 57 | message.error("Failed to fetch recently added books"); 58 | } 59 | }; 60 | 61 | const fetchRecentlyReadBooks = async (page: number) => { 62 | const result = await bookServiceRender.getRecentlyReadBooks(page, pageSize); 63 | if (result.success) { 64 | setRecentlyReadBooks(result.payload.books); 65 | setRecentlyReadTotal(result.payload.total); 66 | } else { 67 | logServiceRender.error( 68 | "Failed to fetch recently read books:", 69 | result.message 70 | ); 71 | message.error("Failed to fetch recently read books"); 72 | } 73 | }; 74 | 75 | useEffect(() => { 76 | fetchRecentlyAddedBooks(recentlyAddedPage); 77 | }, [recentlyAddedPage]); 78 | 79 | useEffect(() => { 80 | fetchRecentlyReadBooks(recentlyReadPage); 81 | }, [recentlyReadPage]); 82 | 83 | const handleTabChange = (key: string) => { 84 | setActiveTab(key); 85 | }; 86 | 87 | const handleRecentlyAddedPageChange = (page: number) => { 88 | setRecentlyAddedPage(page); 89 | }; 90 | 91 | const handleRecentlyReadPageChange = (page: number) => { 92 | setRecentlyReadPage(page); 93 | }; 94 | 95 | const handleUploadClickNew = async () => { 96 | try { 97 | const bookAddedResult = await bookServiceRender.addBook(); 98 | if (!bookAddedResult.payload) { 99 | logServiceRender.error("Failed to add book:", bookAddedResult.message); 100 | message.error("添加图书失败"); 101 | return; 102 | } 103 | const bookId: Id = bookAddedResult.payload._id; 104 | setCurrentBookId(bookId); 105 | setIsUploadModalOpen(true); 106 | } catch (error) { 107 | logServiceRender.error("处理添加图书时出错:", error); 108 | message.error("添加图书失败"); 109 | } 110 | }; 111 | 112 | // 添加处理函数 113 | const handleBatchUpload = async () => { 114 | try { 115 | const result = await fileServiceRender.selectDirectory(); 116 | if (result.success && result.payload) { 117 | const selectedDir = result.payload; 118 | navigate(`/batch-upload?dir=${encodeURIComponent(selectedDir)}`); 119 | } else { 120 | message.info("未选择目录"); 121 | } 122 | } catch (error) { 123 | logServiceRender.error("选择目录时出错:", error); 124 | message.error("选择目录失败"); 125 | } 126 | }; 127 | 128 | const handleEditBook = (id: Id) => { 129 | logServiceRender.info("handleUploadClickEdit id: ", id); 130 | setCurrentBookId(id); 131 | setIsUploadModalOpen(true); 132 | }; 133 | 134 | const handleCloseModal = () => { 135 | setIsUploadModalOpen(false); 136 | setCurrentBookId(null); 137 | fetchRecentlyAddedBooks(recentlyAddedPage); 138 | fetchRecentlyReadBooks(recentlyReadPage); 139 | message.success("书籍信息已更新"); 140 | }; 141 | 142 | const handleBookUpdated = () => { 143 | logServiceRender.info("Book updated"); 144 | 145 | // 刷新最近添加的书籍列表 146 | fetchRecentlyAddedBooks(recentlyAddedPage); 147 | fetchRecentlyReadBooks(recentlyReadPage); 148 | 149 | // 关闭模态框 150 | setIsUploadModalOpen(false); 151 | setCurrentBookId(null); 152 | 153 | message.success("书籍信息已更新"); 154 | }; 155 | 156 | const handleSettingsClick = () => { 157 | navigate("/settings"); 158 | }; 159 | 160 | const handleWeixinReadClick = () => { 161 | navigate("/weixin-read"); 162 | }; 163 | 164 | return ( 165 | 166 |
167 | 168 |
169 | 170 | <BookOutlined /> RayBook 171 | 172 | 173 | 174 | 181 | 182 | 183 | 191 | 192 | 193 | 205 | 206 | 207 | 215 | 216 | 217 | 218 | 224 | 225 | 226 | 227 | 234 | 235 | 236 | 243 | 244 | 245 | 246 | 247 | ); 248 | }; 249 | 250 | export default HomePage; 251 | -------------------------------------------------------------------------------- /src/pages/home/components/BookCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { format } from "date-fns"; 3 | import { Card, Menu, Dropdown, MenuProps, Button } from "antd"; 4 | import { 5 | MoreOutlined, 6 | EditOutlined, 7 | ReadOutlined, 8 | WechatOutlined, 9 | } from "@ant-design/icons"; 10 | import { IBook } from "../../../models/Book"; 11 | const { ipcRenderer } = window.require("electron"); 12 | import { useNavigate } from "react-router-dom"; 13 | import { serializeId, toObjectId } from "../../../utils/DtoUtils"; 14 | import { 15 | bookCoverServiceRender, 16 | bookFileServiceRender, 17 | logServiceRender, 18 | } from "../../../app"; 19 | import { IBookFile } from "../../../models/BookFile"; 20 | 21 | interface BookCardProps { 22 | book: IBook; 23 | onEdit: (id: Id) => void; 24 | } 25 | 26 | const BookCard: React.FC = ({ book, onEdit }) => { 27 | const navigate = useNavigate(); 28 | const [coverUrl, setCoverUrl] = useState(null); 29 | const [bookFiles, setBookFiles] = useState([]); 30 | 31 | // 获取图书封面 32 | const getCoverImage = async (coverIamgePath: string) => { 33 | const result = await bookCoverServiceRender.getBookCover(coverIamgePath); 34 | if (result.success && result.payload) { 35 | setCoverUrl(result.payload); 36 | } 37 | }; 38 | 39 | useEffect(() => { 40 | if (book.coverImagePath) getCoverImage(book.coverImagePath as string); 41 | fetchBookFiles(); 42 | }, [book.coverImagePath]); 43 | 44 | const fetchBookFiles = async () => { 45 | const result = await bookFileServiceRender.getBookFiles(book._id); 46 | if (result.success) { 47 | setBookFiles(result.payload); 48 | } 49 | }; 50 | 51 | const handleRead = (bookFileId: Id) => { 52 | const serializedBookId = serializeId(book._id); 53 | const serializedFileId = serializeId(bookFileId); 54 | const file = bookFiles.find((f) => f._id === bookFileId); 55 | if (file && file.format.toLowerCase() === "pdf") { 56 | navigate(`/pdf-read/${serializedBookId}/${serializedFileId}`); 57 | } else { 58 | navigate(`/read/${serializedBookId}/${serializedFileId}`); 59 | } 60 | }; 61 | 62 | const handleWeixinRead = () => { 63 | navigate(`/weixin-read?bookKey=${book.weixinBookKey}`); 64 | }; 65 | 66 | const items: MenuProps["items"] = [ 67 | { 68 | key: "edit", 69 | label: ( 70 | onEdit(book._id)}> 71 | 72 | 编辑 73 | 74 | ), 75 | }, 76 | ...bookFiles.map((file) => ({ 77 | key: `read-${toObjectId(file._id).toHexString()}`, 78 | label: ( 79 | 82 | ), 83 | })), 84 | ...(book.weixinBookKey 85 | ? [ 86 | { 87 | key: "weixin-read", 88 | label: ( 89 | 92 | ), 93 | }, 94 | ] 95 | : []), 96 | ]; 97 | 98 | return ( 99 | 109 | ) : ( 110 |
119 | No Cover 120 |
121 | ) 122 | } 123 | actions={[ 124 | 125 | 126 | , 127 | ]} 128 | style={{ width: 200 }} 129 | > 130 | 134 | {book.author &&
{book.author}
} 135 | {book.lastReadTime && ( 136 |
137 | 上次阅读:{" "} 138 | {format(new Date(book.lastReadTime), "yyyy-MM-dd HH:mm")} 139 |
140 | )} 141 | 142 | } 143 | /> 144 |
145 | ); 146 | }; 147 | 148 | export default BookCard; 149 | -------------------------------------------------------------------------------- /src/pages/home/components/BookList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Row, Col, Pagination } from "antd"; 3 | import BookCard from "./BookCard"; 4 | import { IBook } from "../../../models/Book"; 5 | 6 | interface BookListProps { 7 | books: IBook[]; 8 | total: number; 9 | currentPage: number; 10 | onPageChange: (page: number) => void; 11 | onEditBook: (id: Id) => void; 12 | } 13 | 14 | const BookList: React.FC = ({ 15 | books, 16 | total, 17 | currentPage, 18 | onPageChange, 19 | onEditBook, 20 | }) => { 21 | return ( 22 | <> 23 | 24 | {books.map((book) => ( 25 |
33 | onEditBook(book._id)} /> 34 | 35 | ))} 36 | 37 | 44 | 45 | ); 46 | }; 47 | 48 | export default BookList; 49 | -------------------------------------------------------------------------------- /src/pages/home/modal/UploadBookModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { Modal, Form, message, Tabs } from "antd"; 3 | import { ipcRenderer } from "electron"; 4 | import { IBookFile } from "../../../models/BookFile"; 5 | import { IBook } from "../../../models/Book"; 6 | import BookMetadataForm from "./components/BookMetadataForm"; 7 | import BookFilesManager from "./components/BookFilesManager"; 8 | import { 9 | bookCoverServiceRender, 10 | bookFileServiceRender, 11 | bookServiceRender, 12 | logServiceRender, 13 | } from "../../../app"; 14 | 15 | const { TabPane } = Tabs; 16 | 17 | const UploadBookModal: React.FC<{ 18 | open: boolean; 19 | onClose: () => void; 20 | bookId: Id | null; 21 | onBookUpdated: () => void; 22 | }> = ({ open, onClose, bookId, onBookUpdated }) => { 23 | const [form] = Form.useForm(); 24 | const [book, setBook] = useState(null); 25 | const [coverUrl, setCoverUrl] = useState(null); 26 | const [bookFiles, setBookFiles] = useState([]); 27 | const [activeTab, setActiveTab] = useState("1"); 28 | 29 | useEffect(() => { 30 | if (bookId) { 31 | fetchBookDetails(bookId); 32 | } else { 33 | resetForm(); 34 | } 35 | }, [bookId]); 36 | 37 | const fetchBookDetails = async (id: Id) => { 38 | try { 39 | const _book = await bookServiceRender.findBookById(id); 40 | if (!_book.success || !_book.payload) { 41 | message.error("获取书籍详情失败"); 42 | return; 43 | } 44 | logServiceRender.debug("Fetched book details:", _book.payload); 45 | setBook(_book.payload); 46 | form.setFieldsValue({ 47 | ..._book.payload, 48 | weixinBookKey: _book.payload.weixinBookKey || "", 49 | weixinBookId: _book.payload.weixinBookId || "", 50 | }); 51 | logServiceRender.debug("Form values set:", form.getFieldsValue()); 52 | 53 | if (_book.payload.coverImagePath) { 54 | const coverResult = await bookCoverServiceRender.getBookCover( 55 | _book.payload.coverImagePath 56 | ); 57 | if (coverResult.success && coverResult.payload) { 58 | setCoverUrl(coverResult.payload); 59 | } 60 | } 61 | 62 | const _bookFilesResult = await bookFileServiceRender.getBookFiles(id); 63 | if (_bookFilesResult.success) setBookFiles(_bookFilesResult.payload); 64 | } catch (error) { 65 | logServiceRender.error("Error fetching book details:", error); 66 | message.error("获取书籍详情时出错"); 67 | } 68 | }; 69 | 70 | const resetForm = () => { 71 | form.resetFields(); 72 | setBookFiles([]); 73 | }; 74 | 75 | const handleMetadataSubmit = async () => { 76 | if (!book || !book._id) { 77 | message.error("书籍信息尚未加载完成,请稍后再试"); 78 | return; 79 | } 80 | try { 81 | const values = await form.validateFields(); 82 | const bookData: Partial = { 83 | ...values, 84 | weixinBookKey: values.weixinBookKey || null, 85 | weixinBookId: values.weixinBookId || null, 86 | }; 87 | const updatedResult = await bookServiceRender.updateBook( 88 | book._id, 89 | bookData 90 | ); 91 | form.setFieldsValue(updatedResult.payload); 92 | if (updatedResult.success) { 93 | message.success(updatedResult.message); 94 | onBookUpdated(); // 更新父组件的数据 95 | onClose(); 96 | } else { 97 | message.error(updatedResult.message); 98 | } 99 | } catch (error) { 100 | logServiceRender.error("提交表单时出错:", error); 101 | message.error("提交表单失败"); 102 | } 103 | }; 104 | 105 | const handleFileUpload = async () => { 106 | try { 107 | const result = await bookFileServiceRender.uploadBookFile(bookId); 108 | if (result.success && result.payload) { 109 | const fileExists = bookFiles.some( 110 | (file) => file.path === result.payload.path 111 | ); 112 | if (!fileExists) { 113 | setBookFiles([...bookFiles, result.payload]); 114 | message.success("成功添加电子书文件"); 115 | } else { 116 | message.warning("该文件已存在,请勿重复添加"); 117 | } 118 | } else { 119 | message.error("添加电子书文件失败: " + result.message); 120 | } 121 | } catch (error) { 122 | logServiceRender.error("上传文件时出错:", error); 123 | message.error("上传文件失败: " + error.message); 124 | } 125 | }; 126 | 127 | const handleFileDelete = async (fileId: Id) => { 128 | try { 129 | const result = await ipcRenderer.invoke("delete-book-file", fileId); 130 | if (result.success) { 131 | setBookFiles( 132 | bookFiles.filter((file) => file._id.buffer !== fileId.buffer) 133 | ); 134 | message.success("成功删除文件"); 135 | } else { 136 | message.error("删除文件失败"); 137 | } 138 | } catch (error) { 139 | logServiceRender.error("删除文件时出错:", error); 140 | message.error("删除文件失败"); 141 | } 142 | }; 143 | 144 | const handleCoverUpload = (file: File) => { 145 | // const reader = new FileReader(); 146 | // reader.onload = (e) => { 147 | // if (e.target && e.target.result) { 148 | // setCoverBase64(e.target.result.toString().split(',')[1]); 149 | // setCoverMimeType(file.type); 150 | // } 151 | // }; 152 | // reader.readAsDataURL(file); 153 | }; 154 | 155 | const handleExtractCover = async (fileId: Id) => { 156 | try { 157 | if (!bookId) { 158 | message.error("请先保存图书信息"); 159 | return; 160 | } 161 | const result = await bookCoverServiceRender.extractBookCover( 162 | bookId, 163 | fileId 164 | ); 165 | if (result.success) { 166 | message.success("成功提取封面"); 167 | setCoverUrl(result.payload); 168 | setActiveTab("1"); // Switch to the metadata tab 169 | // 更新 form 中的 coverImagePath 170 | form.setFieldsValue({ coverImagePath: result.payload }); 171 | } else { 172 | message.error("提取封面失败"); 173 | } 174 | } catch (error) { 175 | logServiceRender.error("提取封面时出错:", error); 176 | message.error("提取封面失败"); 177 | } 178 | }; 179 | 180 | const handleTabChange = (activeKey: string) => { 181 | setActiveTab(activeKey); 182 | }; 183 | 184 | return ( 185 | (activeTab === "1" ? handleMetadataSubmit() : onClose())} 190 | okText={activeTab === "1" ? "更新" : "完成"} 191 | cancelText="取消" 192 | width={800} 193 | > 194 | 195 | 196 | 201 | 202 | 203 | fetchBookDetails(bookId)} 210 | /> 211 | 212 | 213 | 214 | ); 215 | }; 216 | 217 | export default UploadBookModal; 218 | -------------------------------------------------------------------------------- /src/pages/home/modal/components/BookFilesManager.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, Table, message } from "antd"; 3 | import { 4 | UploadOutlined, 5 | DeleteOutlined, 6 | FileImageOutlined, 7 | CalculatorOutlined, 8 | } from "@ant-design/icons"; 9 | import { IBookFile } from "../../../../models/BookFile"; 10 | import { bookFileServiceRender, logServiceRender } from "../../../../app"; 11 | 12 | interface BookFilesManagerProps { 13 | bookId: Id; 14 | bookFiles: IBookFile[]; 15 | onFileUpload: () => void; 16 | onFileDelete: (fileId: Id) => void; 17 | onExtractCover: (fileId: Id) => Promise; 18 | onFilesUpdate: () => void; 19 | } 20 | 21 | const BookFilesManager: React.FC = ({ 22 | bookId, 23 | bookFiles, 24 | onFileUpload, 25 | onFileDelete, 26 | onExtractCover, 27 | onFilesUpdate, 28 | }) => { 29 | const handleCalculateSha256 = async (fileId: Id) => { 30 | const result = await bookFileServiceRender.calculateAndUpdateSha256( 31 | bookId, 32 | fileId 33 | ); 34 | if (result.success) { 35 | message.success(result.message); 36 | onFilesUpdate(); // 刷新文件列表 37 | } else { 38 | message.error(result.message); 39 | } 40 | }; 41 | 42 | const columns = [ 43 | { title: "文件名", dataIndex: "filename", key: "filename" }, 44 | { title: "格式", dataIndex: "format", key: "format" }, 45 | { title: "大小", dataIndex: "size", key: "size" }, 46 | { title: "sha256", dataIndex: "sha256", key: "sha256" }, 47 | { 48 | title: "操作", 49 | key: "action", 50 | render: (text: any, record: IBookFile) => ( 51 | <> 52 | 59 | 66 | 72 | 73 | ), 74 | }, 75 | ]; 76 | 77 | logServiceRender.info("bookFiles: ", bookFiles); 78 | 79 | return ( 80 | <> 81 | 88 |
89 | 90 | ); 91 | }; 92 | 93 | export default BookFilesManager; 94 | -------------------------------------------------------------------------------- /src/pages/home/modal/components/BookMetadataForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Form, 4 | Input, 5 | InputNumber, 6 | Row, 7 | Col, 8 | Upload, 9 | Button, 10 | Image, 11 | } from "antd"; 12 | import { UploadOutlined } from "@ant-design/icons"; 13 | import { IBook } from "../../../../models/Book"; 14 | 15 | interface BookMetadataFormProps { 16 | form: any; 17 | coverUrl: string | null; 18 | onCoverUpload: (file: File) => void; 19 | } 20 | 21 | const BookMetadataForm: React.FC = ({ 22 | form, 23 | coverUrl, 24 | onCoverUpload, 25 | }) => { 26 | return ( 27 |
28 | 29 |
30 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | {coverUrl ? ( 103 | 108 | ) : ( 109 | { 111 | onCoverUpload(file); 112 | return false; 113 | }} 114 | > 115 | 116 | 117 | )} 118 | 119 | 120 | 121 | 122 | ); 123 | }; 124 | 125 | export default BookMetadataForm; 126 | -------------------------------------------------------------------------------- /src/pages/reader/epub/ReaderPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, Component } from "react"; 2 | import { useParams, useNavigate } from "react-router-dom"; 3 | import { Button, message } from "antd"; 4 | import { ArrowLeftOutlined } from "@ant-design/icons"; 5 | import { deserializeId } from "../../../utils/DtoUtils"; 6 | import { ReactReader, EpubView } from "react-reader"; 7 | import { 8 | bookFileServiceRender, 9 | bookServiceRender, 10 | logServiceRender, 11 | } from "../../../app"; 12 | import { useBookLocation } from "./hooks/useBookLocation"; 13 | 14 | const ReaderPage: React.FC = () => { 15 | const [epubData, setEpubData] = useState(null); 16 | const { bookId, fileId } = useParams<{ bookId: string; fileId: string }>(); 17 | const navigate = useNavigate(); 18 | const { location, setLocation, saveLocation, isLoading } = 19 | useBookLocation(fileId); 20 | 21 | useEffect(() => { 22 | const fetchBookFile = async () => { 23 | logServiceRender.info("Fetching book file"); 24 | try { 25 | const result = await bookFileServiceRender.getBookFileContent( 26 | deserializeId(bookId), 27 | deserializeId(fileId) 28 | ); 29 | if (result.success) { 30 | setEpubData(result.payload.buffer); 31 | } else { 32 | message.error("Failed to load the book"); 33 | } 34 | } catch (error) { 35 | logServiceRender.error("Error fetching book file:", error); 36 | message.error("An error occurred while loading the book"); 37 | } 38 | }; 39 | 40 | if (bookId) { 41 | fetchBookFile(); 42 | // Update last read time 43 | bookServiceRender.updateLastReadTime(deserializeId(bookId)); 44 | } 45 | }, [bookId, fileId]); 46 | 47 | const handleLocationChanged = (newLocation: string) => { 48 | if (!isLoading) { 49 | logServiceRender.debug("Location changed:", newLocation); 50 | setLocation(newLocation); 51 | } 52 | }; 53 | 54 | const handleBackClick = async () => { 55 | await saveLocation(); 56 | navigate("/"); 57 | }; 58 | 59 | return ( 60 |
61 | 68 | {!isLoading && epubData ? ( 69 |
70 | 75 |
76 | ) : ( 77 |
Loading...
78 | )} 79 |
80 | ); 81 | }; 82 | 83 | export default ReaderPage; 84 | -------------------------------------------------------------------------------- /src/pages/reader/epub/hooks/useBookLocation.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from "react"; 2 | import { bookFileServiceRender, logServiceRender } from "../../../../app"; 3 | import { deserializeId } from "../../../../utils/DtoUtils"; 4 | 5 | export const useBookLocation = (bookFileId: string) => { 6 | const [location, setLocationState] = useState(); 7 | const [isLoading, setIsLoading] = useState(true); 8 | 9 | useEffect(() => { 10 | const fetchLocation = async () => { 11 | setIsLoading(true); 12 | if (bookFileId) { 13 | const result = await bookFileServiceRender.findBookFileByBookFileId( 14 | deserializeId(bookFileId) 15 | ); 16 | if (result.success && result.payload.location) { 17 | logServiceRender.debug("Location found:", result.payload.location); 18 | setLocationState(result.payload.location); 19 | } 20 | } 21 | setIsLoading(false); 22 | }; 23 | fetchLocation(); 24 | }, [bookFileId]); 25 | 26 | const setLocation = useCallback((newLocation: string) => { 27 | setLocationState(newLocation); 28 | }, []); 29 | 30 | const saveLocation = useCallback(async () => { 31 | if (bookFileId && location) { 32 | const result = await bookFileServiceRender.updateBookFileLocation( 33 | deserializeId(bookFileId), 34 | location 35 | ); 36 | if (result.success) { 37 | logServiceRender.debug("Location saved:", result.payload.location); 38 | } else { 39 | logServiceRender.error("Failed to save location:", result.message); 40 | } 41 | } 42 | }, [bookFileId, location]); 43 | 44 | return { location, setLocation, saveLocation, isLoading }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/pages/reader/pdf/PDFReaderPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useMemo } from "react"; 2 | import { useParams, useNavigate } from "react-router-dom"; 3 | import { Button, message } from "antd"; 4 | import { ArrowLeftOutlined } from "@ant-design/icons"; 5 | import { pdfjs, Document, Page } from "react-pdf"; 6 | import { deserializeId } from "../../../utils/DtoUtils"; 7 | import { 8 | bookFileServiceRender, 9 | bookServiceRender, 10 | logServiceRender, 11 | } from "../../../app"; 12 | import { useBookLocation } from "../epub/hooks/useBookLocation"; 13 | const { ipcRenderer } = window.require("electron"); 14 | import "react-pdf/dist/Page/AnnotationLayer.css"; 15 | import "react-pdf/dist/Page/TextLayer.css"; 16 | 17 | const PDFReaderPage: React.FC = () => { 18 | const [numPages, setNumPages] = useState(null); 19 | const [pageNumber, setPageNumber] = useState(1); 20 | const [pdfData, setPdfData] = useState(null); 21 | const { bookId, fileId } = useParams<{ bookId: string; fileId: string }>(); 22 | const navigate = useNavigate(); 23 | const { location, setLocation, saveLocation, isLoading } = 24 | useBookLocation(fileId); 25 | 26 | const pdfFile = useMemo(() => { 27 | if (pdfData) { 28 | return { data: pdfData }; 29 | } 30 | return null; 31 | }, [pdfData]); 32 | 33 | useEffect(() => { 34 | const fetchBookFile = async () => { 35 | logServiceRender.info("Fetching PDF file"); 36 | try { 37 | const result = await bookFileServiceRender.getBookFileContent( 38 | deserializeId(bookId), 39 | deserializeId(fileId) 40 | ); 41 | if (result.success) { 42 | logServiceRender.debug("PDF file fetched successfully"); 43 | setPdfData(result.payload); 44 | if (location) { 45 | setPageNumber(parseInt(location)); 46 | } 47 | } else { 48 | message.error("Failed to load the PDF"); 49 | } 50 | } catch (error) { 51 | logServiceRender.error("Error fetching PDF file:", error); 52 | message.error("An error occurred while loading the PDF"); 53 | } 54 | }; 55 | 56 | const setupPdfWorker = async () => { 57 | const workerPath = await ipcRenderer.invoke("get-pdf-worker-path"); 58 | pdfjs.GlobalWorkerOptions.workerSrc = workerPath; 59 | }; 60 | 61 | const effect = async () => { 62 | await setupPdfWorker(); 63 | 64 | if (bookId) { 65 | if (!pdfData) { 66 | await fetchBookFile(); 67 | } 68 | // Update last read time 69 | await bookServiceRender.updateLastReadTime(deserializeId(bookId)); 70 | } 71 | }; 72 | 73 | effect(); 74 | }, [bookId, fileId, location, pdfData]); 75 | 76 | const handleDocumentLoadSuccess = ({ numPages }: { numPages: number }) => { 77 | setNumPages(numPages); 78 | }; 79 | 80 | const handlePageChange = (newPageNumber: number) => { 81 | setPageNumber(newPageNumber); 82 | setLocation(newPageNumber.toString()); 83 | }; 84 | 85 | const handleBackClick = async () => { 86 | await saveLocation(); 87 | navigate("/"); 88 | }; 89 | 90 | return ( 91 |
92 | 99 | {!isLoading && pdfFile ? ( 100 |
101 | 102 | 103 | 104 |
105 |

106 | Page {pageNumber} of {numPages} 107 |

108 | 114 | 120 |
121 |
122 | ) : ( 123 |
Loading...
124 | )} 125 |
126 | ); 127 | }; 128 | 129 | export default PDFReaderPage; 130 | -------------------------------------------------------------------------------- /src/pages/reader/weixin/WeixinReadPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { Avatar, Button, Layout, Typography, message } from "antd"; 3 | import { ArrowLeftOutlined, PlusOutlined } from "@ant-design/icons"; 4 | import { useLocation, useNavigate } from "react-router-dom"; 5 | import { debounce } from "ts-debounce"; 6 | import { bookServiceRender, logServiceRender } from "../../../app"; 7 | const { ipcRenderer } = window.require("electron"); 8 | 9 | const { Header, Content } = Layout; 10 | const { Text } = Typography; 11 | 12 | interface BookmarkListData { 13 | synckey: number; 14 | updated: any[]; 15 | removed: any[]; 16 | chapters: any[]; 17 | book: { 18 | bookId: string; 19 | title: string; 20 | author: string; 21 | cover: string; 22 | }; 23 | } 24 | 25 | interface BookData { 26 | bookmarklist: BookmarkListData; 27 | } 28 | 29 | const WeixinReadPage: React.FC = () => { 30 | const location = useLocation(); 31 | const navigate = useNavigate(); 32 | const contentRef = useRef(null); 33 | const [contentBounds, setContentBounds] = useState({ 34 | x: 0, 35 | y: 0, 36 | width: 0, 37 | height: 0, 38 | }); 39 | const [bookData, setBookData] = useState(null); 40 | const [isBookInRayBook, setIsBookInRayBook] = useState(false); 41 | const [bookKey, setBookKey] = useState(null); 42 | 43 | useEffect(() => { 44 | const updateBounds = () => { 45 | if (contentRef.current) { 46 | const rect = contentRef.current.getBoundingClientRect(); 47 | setContentBounds({ 48 | x: rect.x, 49 | y: rect.y, 50 | width: rect.width, 51 | height: rect.height, 52 | }); 53 | } 54 | }; 55 | 56 | const resizeBounds = () => { 57 | updateBounds(); 58 | ipcRenderer.send("weixin-read:resize", contentBounds); 59 | }; 60 | 61 | updateBounds(); 62 | window.addEventListener("resize", debounce(resizeBounds, 2000)); 63 | 64 | // 初始化 BrowserView 65 | ipcRenderer.send("weixin-read:init", contentBounds); 66 | 67 | const searchParams = new URLSearchParams(location.search); 68 | const bookKey = searchParams.get("bookKey"); 69 | if (bookKey) { 70 | // 如果有 bookKey,直接跳转到对应的阅读页面 71 | ipcRenderer.send( 72 | "weixin-read:navigate", 73 | `https://weread.qq.com/web/reader/${bookKey}` 74 | ); 75 | } 76 | 77 | const checkBookInRayBook = async (bookKey: string) => { 78 | const result = await bookServiceRender.findBookByWeixinBookKey(bookKey); 79 | setIsBookInRayBook(result.success && result.payload !== null); 80 | }; 81 | 82 | // 监听新书打开的消息 83 | const handleBookOpened = async (event: any, bookKey: string) => { 84 | logServiceRender.info(`New book opened with key: ${bookKey}`); 85 | await checkBookInRayBook(bookKey); 86 | setBookKey(bookKey); 87 | 88 | // 更新上次阅读时间 89 | try { 90 | const result = await bookServiceRender.findBookByWeixinBookKey(bookKey); 91 | if (result.success && result.payload) { 92 | await bookServiceRender.updateLastReadTime(result.payload._id); 93 | logServiceRender.info( 94 | `Updated last read time for book: ${result.payload.title}` 95 | ); 96 | } else { 97 | logServiceRender.warn( 98 | `Book not found in RayBook for key: ${bookKey}` 99 | ); 100 | } 101 | } catch (error) { 102 | logServiceRender.error("Error updating last read time:", error); 103 | } 104 | 105 | // 设置3秒定时器 106 | setTimeout(async () => { 107 | try { 108 | const data = await ipcRenderer.invoke("get-book-data", bookKey); 109 | logServiceRender.debug("Received book data:", data); 110 | setBookData(data); 111 | } catch (error) { 112 | logServiceRender.error("Error fetching book data:", error); 113 | } 114 | }, 3000); 115 | }; 116 | 117 | ipcRenderer.on("weixin-read-book-opened", handleBookOpened); 118 | 119 | return () => { 120 | window.removeEventListener("resize", resizeBounds); 121 | ipcRenderer.send("weixin-read:cleanup"); 122 | ipcRenderer.removeListener("weixin-read-book-opened", handleBookOpened); 123 | }; 124 | }, [location]); 125 | 126 | // 首次渲染时,初始化 BrowserView 的大小 127 | useEffect(() => { 128 | // 当 contentBounds 变化时,更新 BrowserView 的大小 129 | ipcRenderer.send("weixin-read:resize", contentBounds); 130 | }, [contentBounds]); 131 | 132 | const handleBack = () => { 133 | navigate("/"); 134 | }; 135 | 136 | const handleCreateBook = async () => { 137 | if (bookData && bookData.bookmarklist && bookData.bookmarklist.book) { 138 | const result = await bookServiceRender.createBookFromWeixin( 139 | bookKey, 140 | bookData.bookmarklist.book 141 | ); 142 | if (result.success) { 143 | message.success("成功创建图书"); 144 | setIsBookInRayBook(true); 145 | } else { 146 | message.error("创建图书失败"); 147 | } 148 | } 149 | }; 150 | 151 | return ( 152 | 153 |
162 | 165 | {bookData && bookData.bookmarklist && bookData.bookmarklist.book && ( 166 |
167 | 172 |
180 | 189 | {bookData.bookmarklist.book.title} 190 | 191 | 199 | {bookData.bookmarklist.book.author} 200 | 201 |
202 |
203 | )} 204 | {!isBookInRayBook ? ( 205 | 208 | ) : ( 209 |
210 | )} 211 |
212 | 213 |
214 | ); 215 | }; 216 | 217 | export default WeixinReadPage; 218 | -------------------------------------------------------------------------------- /src/pages/settings/SettingsPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { Form, Input, Button, message, Row, Col, Card, Space } from "antd"; 3 | import { 4 | ArrowLeftOutlined, 5 | ToolOutlined, 6 | FolderOutlined, 7 | } from "@ant-design/icons"; 8 | import { configStore } from "../../config/ConfigStore"; 9 | import { useLocation, useNavigate } from "react-router-dom"; 10 | import { fileServiceRender } from "../../app"; 11 | const { ipcRenderer } = window.require("electron"); 12 | 13 | const SettingsPage: React.FC = () => { 14 | const [form] = Form.useForm(); 15 | const navigate = useNavigate(); 16 | const location = useLocation(); 17 | const [isBlockingPage, setIsBlockingPage] = useState(false); 18 | 19 | useEffect(() => { 20 | const config = configStore.getConfig(); 21 | form.setFieldsValue(config); 22 | setIsBlockingPage(!configStore.hasRequiredConfig()); 23 | }, []); 24 | 25 | const onFinish = (values: any) => { 26 | configStore.setConfig(values); 27 | message.success("设置保存成功"); 28 | ipcRenderer.send("config-updated"); 29 | }; 30 | 31 | const handleBack = () => { 32 | navigate("/"); 33 | }; 34 | 35 | const handleSelectStoragePath = async () => { 36 | const result = await fileServiceRender.selectDirectory(); 37 | if (result.success && result.payload) { 38 | form.setFieldsValue({ defaultStoragePath: result.payload }); 39 | } 40 | }; 41 | 42 | const handleSha256Complementation = () => { 43 | navigate("/sha256-complementation"); 44 | }; 45 | 46 | return ( 47 |
48 | 49 |
50 |

RayBook 设置

51 | 52 | {!isBlockingPage && ( 53 | 54 | 57 | 58 | )} 59 | {" "} 60 | 61 | 66 | 67 | 68 | 73 | 74 | 75 | 80 | 81 | 82 | 87 | 88 | 89 | 94 | } 99 | onClick={handleSelectStoragePath} 100 | > 101 | 选择 102 | 103 | } 104 | /> 105 | 106 | 107 | 110 | 111 | 112 | {!isBlockingPage && ( 113 | 116 | 工具箱 117 | 118 | } 119 | style={{ marginTop: 20 }} 120 | > 121 | 122 | 123 | {/* 这里可以添加更多工具箱按钮 */} 124 | 125 | 126 | )} 127 | 128 | ); 129 | }; 130 | 131 | export default SettingsPage; 132 | -------------------------------------------------------------------------------- /src/pages/settings/toolbox/Sha256CompletionPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { Button, message, Progress, Table, Typography } from "antd"; 3 | import { ArrowLeftOutlined } from "@ant-design/icons"; 4 | import { useNavigate } from "react-router-dom"; 5 | import { bookFileServiceRender, logServiceRender } from "../../../app"; 6 | import { IBookFile } from "../../../models/BookFile"; 7 | import { toObjectId } from "../../../utils/DtoUtils"; 8 | 9 | const { Title } = Typography; 10 | 11 | const Sha256CompletionPage: React.FC = () => { 12 | const navigate = useNavigate(); 13 | const [isProcessing, setIsProcessing] = useState(false); 14 | const [progress, setProgress] = useState(0); 15 | const [results, setResults] = useState([]); 16 | const [filesToProcess, setFilesToProcess] = useState([]); 17 | 18 | useEffect(() => { 19 | fetchFilesWithoutSha256(); 20 | return () => { 21 | setFilesToProcess([]); 22 | setResults([]); 23 | setProgress(0); 24 | }; 25 | }, []); 26 | 27 | const fetchFilesWithoutSha256 = async () => { 28 | try { 29 | const response = await bookFileServiceRender.getBookFilesWithoutSha256(); 30 | if (response.success) { 31 | setFilesToProcess(response.payload); 32 | } else { 33 | message.error("获取需要处理的文件失败"); 34 | } 35 | } catch (error) { 36 | logServiceRender.error("获取需要处理的文件时出错:", error); 37 | message.error("获取需要处理的文件时出错"); 38 | } 39 | }; 40 | 41 | const handleBack = () => { 42 | navigate(-1); 43 | }; 44 | 45 | const handleSha256Completion = async () => { 46 | logServiceRender.info("开始补齐 SHA256"); 47 | setIsProcessing(true); 48 | setProgress(0); 49 | setResults([]); 50 | 51 | try { 52 | const totalFiles = filesToProcess.length; 53 | 54 | for (let i = 0; i < totalFiles; i++) { 55 | const file = filesToProcess[i]; 56 | logServiceRender.info(`处理文件 ${file.filename}`); 57 | const result = await bookFileServiceRender.calculateAndUpdateSha256( 58 | file.book, 59 | file._id 60 | ); 61 | 62 | if (result.success) { 63 | // 更新 filesToProcess 状态 64 | setFilesToProcess((prevFiles) => 65 | prevFiles.map((prevFile) => 66 | prevFile._id === file._id ? result.payload : prevFile 67 | ) 68 | ); 69 | } else { 70 | logServiceRender.error( 71 | `处理文件 ${file.filename} 时出错:`, 72 | result.message 73 | ); 74 | } 75 | 76 | setResults((prevResults) => [...prevResults, result.payload]); 77 | setProgress(Math.round(((i + 1) / totalFiles) * 100)); 78 | } 79 | 80 | message.success("SHA256 补齐完成"); 81 | fetchFilesWithoutSha256(); // 重新获取需要处理的文件列表 82 | } catch (error) { 83 | logServiceRender.error("SHA256 补齐过程中发生错误:", error); 84 | message.error("处理过程中发生错误"); 85 | } finally { 86 | setIsProcessing(false); 87 | } 88 | }; 89 | 90 | const columns = [ 91 | { 92 | title: "书名", 93 | dataIndex: "bookTitle", 94 | key: "bookTitle", 95 | }, 96 | { 97 | title: "文件名", 98 | dataIndex: "filename", 99 | key: "filename", 100 | }, 101 | { 102 | title: "sha256", 103 | dataIndex: "sha256", 104 | key: "sha256", 105 | render: (sha256: string) => sha256 || "未计算", 106 | }, 107 | { 108 | title: "状态", 109 | dataIndex: "sha256", 110 | key: "status", 111 | render: (sha256: string) => ( 112 | 113 | {sha256 ? "已补齐" : "未补齐"} 114 | 115 | ), 116 | }, 117 | ]; 118 | 119 | return ( 120 |
121 | 128 | SHA256 补齐 129 | 139 | {isProcessing && ( 140 | 141 | )} 142 |
toObjectId(record._id).toHexString()} 146 | /> 147 | 148 | ); 149 | }; 150 | 151 | export default Sha256CompletionPage; 152 | -------------------------------------------------------------------------------- /src/preload.ts: -------------------------------------------------------------------------------- 1 | // See the Electron documentation for details on how to use preload scripts: 2 | // https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts 3 | -------------------------------------------------------------------------------- /src/renderer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file will automatically be loaded by webpack and run in the "renderer" context. 3 | * To learn more about the differences between the "main" and the "renderer" context in 4 | * Electron, visit: 5 | * 6 | * https://electronjs.org/docs/latest/tutorial/process-model 7 | * 8 | * By default, Node.js integration in this file is disabled. When enabling Node.js integration 9 | * in a renderer process, please be aware of potential security implications. You can read 10 | * more about security risks here: 11 | * 12 | * https://electronjs.org/docs/tutorial/security 13 | * 14 | * To enable Node.js integration in this file, open up `main.js` and enable the `nodeIntegration` 15 | * flag: 16 | * 17 | * ``` 18 | * // Create the browser window. 19 | * mainWindow = new BrowserWindow({ 20 | * width: 800, 21 | * height: 600, 22 | * webPreferences: { 23 | * nodeIntegration: true 24 | * } 25 | * }); 26 | * ``` 27 | */ 28 | 29 | import './index.css'; 30 | import './app'; 31 | console.log('👋 This message is being logged by "renderer.js", included via webpack'); 32 | -------------------------------------------------------------------------------- /src/repository/BookRepository.ts: -------------------------------------------------------------------------------- 1 | import { logService } from "../services/log/LogServiceImpl"; 2 | import Book, { IBook } from "../models/Book"; 3 | import { toObjectId } from "../utils/DtoUtils"; 4 | 5 | class BookRepository { 6 | async findBookById(id: Id): Promise { 7 | logService.info( 8 | "BookRepository findBookById id: ", 9 | toObjectId(id).toHexString() 10 | ); 11 | return await Book.findById(toObjectId(id)).lean(); 12 | } 13 | 14 | async findAll( 15 | page: number, 16 | pageSize: number 17 | ): Promise<{ books: IBook[]; total: number }> { 18 | const skip = (page - 1) * pageSize; 19 | const [books, total] = await Promise.all([ 20 | Book.find().sort({ _id: -1 }).skip(skip).limit(pageSize).lean(), 21 | Book.countDocuments(), 22 | ]); 23 | return { books, total }; 24 | } 25 | 26 | async createNewBook(book: Partial): Promise { 27 | return (await Book.create(book)).toObject(); 28 | } 29 | 30 | async updateBook(bookId: Id, book: Partial): Promise { 31 | logService.info("BookRepository updateBook book: ", book); 32 | return await Book.findByIdAndUpdate(toObjectId(bookId), book, { 33 | new: true, 34 | }).lean(); 35 | } 36 | 37 | async delete(id: string): Promise { 38 | const result = await Book.findByIdAndDelete(id); 39 | return !!result; 40 | } 41 | 42 | async findByWeixinBookKey(bookKey: string): Promise { 43 | return await Book.findOne({ weixinBookKey: bookKey }).lean(); 44 | } 45 | } 46 | 47 | export const bookRepository = new BookRepository(); 48 | -------------------------------------------------------------------------------- /src/repository/BookfileRepository.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { BookFile, IBookFile } from "../models/BookFile"; 4 | import s3Client from "../data/minio/MinioClient"; 5 | import { toObjectId } from "../utils/DtoUtils"; 6 | import { BUCKET_NAME } from "../constants"; 7 | import crypto from "crypto"; 8 | import { logService } from "../services/log/LogServiceImpl"; 9 | 10 | class BookFileRepostory { 11 | /** 12 | * Uploads a book file to the server and saves its metadata in the database. 13 | * @param bookId - The ID of the book associated with the file. 14 | * @param filePath - The path to the file on the local filesystem. 15 | * @param filename - The name of the file. 16 | * @param objectName - The name of the object to be stored in the server. 17 | * @returns A Promise that resolves to the newly created book file object, or null if the file already exists. 18 | */ 19 | async uploadBookFile( 20 | bookId: Id, 21 | filePath: string, 22 | filename: string, 23 | objectName: string 24 | ): Promise { 25 | const fileStats = fs.statSync(filePath); 26 | const fileExtension = path.extname(filePath).slice(1); 27 | 28 | // 计算文件的 sha256 29 | const sha256 = await this.calculateSha256(filePath); 30 | // 检查是否存在具有相同 sha256 的文件 31 | const existingFile = await this.findBookFileBySha256(sha256); 32 | if (existingFile) { 33 | throw new Error("已存在具有相同 sha256 的文件"); 34 | } 35 | 36 | const existingBookFile = await this.findBookFileByObjectName(objectName); 37 | if (existingBookFile) { 38 | logService.info("File already exists"); 39 | throw new Error("MinIO 中已存在该文件"); 40 | } 41 | 42 | const newBookFile = new BookFile({ 43 | filename: filename, 44 | format: fileExtension, 45 | path: objectName, 46 | size: fileStats.size, 47 | book: toObjectId(bookId), 48 | sha256, 49 | }); 50 | await newBookFile.save(); 51 | 52 | await s3Client 53 | .upload({ 54 | Bucket: BUCKET_NAME, 55 | Key: objectName, 56 | Body: fs.createReadStream(filePath), 57 | }) 58 | .promise(); 59 | 60 | return newBookFile.toObject(); 61 | } 62 | 63 | async findBookFileById(bookFileId: Id): Promise { 64 | return await BookFile.findById(toObjectId(bookFileId)).lean(); 65 | } 66 | 67 | async findBookFileByObjectName( 68 | objectName: string 69 | ): Promise { 70 | return await BookFile.findOne({ objectName }).lean(); 71 | } 72 | 73 | async findBookFilesByBookId(bookId: Id): Promise { 74 | return await BookFile.find({ book: toObjectId(bookId) }).lean(); 75 | } 76 | 77 | async updateFileSha256( 78 | fileId: Id, 79 | filePath: string 80 | ): Promise { 81 | try { 82 | const sha256 = await this.calculateSha256(filePath); 83 | return await BookFile.findByIdAndUpdate( 84 | toObjectId(fileId), 85 | { sha256 }, 86 | { new: true } 87 | ).lean(); 88 | } catch (error) { 89 | logService.error(`Failed to update SHA256 for file ${fileId}:`, error); 90 | return null; 91 | } 92 | } 93 | 94 | async calculateSha256(filePath: string): Promise { 95 | return new Promise((resolve, reject) => { 96 | const hash = crypto.createHash("sha256"); 97 | const stream = fs.createReadStream(filePath); 98 | stream.on("data", (data) => hash.update(data)); 99 | stream.on("end", () => resolve(hash.digest("hex"))); 100 | stream.on("error", reject); 101 | }); 102 | } 103 | 104 | async findBookFileBySha256(sha256: string): Promise { 105 | return await BookFile.findOne({ sha256 }).lean(); 106 | } 107 | 108 | // async removeBookFile(bookId: string, fileId: string): Promise { 109 | // const result = await BookFile.findByIdAndDelete(fileId); 110 | // if (result) { 111 | // await Book.findByIdAndUpdate(bookId, { 112 | // $pull: { files: new SchemaTypes.ObjectId(fileId) }, 113 | // }); 114 | // return true; 115 | // } 116 | // return false; 117 | // } 118 | } 119 | 120 | export const bookFileRepository = new BookFileRepostory(); 121 | -------------------------------------------------------------------------------- /src/repository/CoverImageRepostory.ts: -------------------------------------------------------------------------------- 1 | import EPub from "epub2"; 2 | import s3Client from "../data/minio/MinioClient"; 3 | import { toObjectId } from "../utils/DtoUtils"; 4 | import Book from "../models/Book"; 5 | import { BUCKET_NAME } from "../constants"; 6 | import { logService } from "../services/log/LogServiceImpl"; 7 | 8 | export class CoverIamgeRepostory { 9 | static collectionPath: string = "book-cover-images"; 10 | 11 | /** 12 | * Constructs the cover image filename for a book. 13 | * @param bookId - The ID of the book. 14 | * @param mimeType - The MIME type of the cover image. 15 | * @returns The constructed cover image filename. 16 | */ 17 | static constructCoverImageFilename( 18 | bookIdString: string, 19 | mimeType: string 20 | ): string { 21 | const originalExtension = mimeType.split("/")[1]; 22 | return `${CoverIamgeRepostory.collectionPath}/${bookIdString}.${originalExtension}`; 23 | } 24 | 25 | async findCoverImageByPath(path: string): Promise { 26 | try { 27 | // 获取 MinIO 中文件的 URL 28 | const coverUrl = await s3Client.getSignedUrlPromise("getObject", { 29 | Bucket: "raybook", 30 | Key: path, 31 | Expires: 60 * 60 * 24 * 7, // URL 有效期为 7 天 32 | }); 33 | return coverUrl; 34 | } catch (error) { 35 | logService.error("Error fetching cover image:", error); 36 | return null; 37 | } 38 | } 39 | 40 | /** 41 | * Uploads a book cover image to the specified book ID. 42 | * @param {string} bookId - The ID of the book. 43 | * @param {string} filepath - The file path of the cover image. 44 | * @returns {Promise} - 上传到 MinIO 的封面的 URL。 45 | */ 46 | async uploadBookCoverImage( 47 | bookId: Id, 48 | filepath: string 49 | ): Promise { 50 | const epub: EPub = await EPub.createAsync(filepath); 51 | const coverPath = epub.metadata.cover; 52 | logService.info("coverPath:", coverPath); 53 | 54 | if (!coverPath) return null; 55 | 56 | const coverData: { data: Buffer; mimeType: string } = await new Promise( 57 | (resolve, reject) => { 58 | epub.getImage( 59 | coverPath, 60 | (err: Error, data: Buffer, mimeType: string) => { 61 | if (err) reject(err); 62 | else resolve({ data, mimeType }); 63 | } 64 | ); 65 | } 66 | ); 67 | 68 | const coverObjectName = CoverIamgeRepostory.constructCoverImageFilename( 69 | toObjectId(bookId).toHexString(), 70 | coverData.mimeType 71 | ); 72 | logService.info("coverObjectName:", coverObjectName); 73 | 74 | await s3Client 75 | .putObject({ 76 | Bucket: BUCKET_NAME, 77 | Key: coverObjectName, 78 | Body: coverData.data, 79 | ContentType: coverData.mimeType, 80 | }) 81 | .promise(); 82 | 83 | // 更新书籍的封面路径 84 | // TODO 未来 BookRepository 变成单例后,通过单例方法更新封面路径 85 | await Book.findByIdAndUpdate(toObjectId(bookId), { 86 | coverImagePath: coverObjectName, 87 | }); 88 | 89 | // 获取 MinIO 中文件的 URL 90 | const coverUrl = await s3Client.getSignedUrlPromise("getObject", { 91 | Bucket: "raybook", 92 | Key: coverObjectName, 93 | Expires: 60 * 60 * 24 * 7, // URL 有效期为 7 天 94 | }); 95 | 96 | return coverUrl; 97 | } 98 | 99 | async uploadBookCoverImageFromBuffer( 100 | bookId: Id, 101 | buffer: Buffer, 102 | mimeType: string 103 | ): Promise { 104 | const coverObjectName = CoverIamgeRepostory.constructCoverImageFilename( 105 | toObjectId(bookId).toHexString(), 106 | mimeType 107 | ); 108 | 109 | await s3Client 110 | .putObject({ 111 | Bucket: BUCKET_NAME, 112 | Key: coverObjectName, 113 | Body: buffer, 114 | ContentType: mimeType, 115 | }) 116 | .promise(); 117 | 118 | // 更新书籍的封面路径 119 | // TODO 未来 BookRepository 变成单例后,通过单例方法更新封面路径 120 | await Book.findByIdAndUpdate(toObjectId(bookId), { 121 | coverImagePath: coverObjectName, 122 | }); 123 | 124 | return coverObjectName; 125 | } 126 | } 127 | 128 | export const coverImageRepository = new CoverIamgeRepostory(); 129 | -------------------------------------------------------------------------------- /src/services/LocalBookCacheService.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path from "path"; 3 | import s3Client from "../data/minio/MinioClient"; 4 | import { BUCKET_NAME } from "../constants"; 5 | import { toObjectId } from "../utils/DtoUtils"; 6 | import { logService } from "./log/LogServiceImpl"; 7 | import { configStore } from "../config/ConfigStore"; 8 | 9 | class LocalBookCacheServcies { 10 | private cacheDir: string; 11 | 12 | constructor() { 13 | this.cacheDir = path.join(configStore.getDefaultStoragePath(), "books"); 14 | fs.ensureDirSync(this.cacheDir); 15 | } 16 | 17 | async getBookFile(bookId: Id, filePath: string): Promise { 18 | const idString = toObjectId(bookId).toHexString(); 19 | const localPath = this.getLocalPath(idString, filePath); 20 | 21 | if (await this.isFileCached(localPath)) { 22 | logService.info("Book file found in cache"); 23 | return localPath; 24 | } 25 | 26 | logService.info("Book file not in cache, downloading from MinIO"); 27 | return await this.downloadAndCacheFile(idString, filePath, localPath); 28 | } 29 | 30 | private getLocalPath(bookId: string, filePath: string): string { 31 | return path.join(this.cacheDir, bookId, path.basename(filePath)); 32 | } 33 | 34 | private async isFileCached(localPath: string): Promise { 35 | return fs.pathExists(localPath); 36 | } 37 | 38 | /** 39 | * Downloads a file from an S3 bucket and caches it locally. 40 | * 41 | * @param bookId - The ID of the book. 42 | * @param filePath - The path of the file in the S3 bucket. 43 | * @param localPath - The local path where the file will be cached. 44 | * @returns The local path of the cached file. 45 | */ 46 | private async downloadAndCacheFile( 47 | bookId: string, 48 | filePath: string, 49 | localPath: string 50 | ): Promise { 51 | const data = await s3Client 52 | .getObject({ 53 | Bucket: BUCKET_NAME, 54 | Key: filePath, 55 | }) 56 | .promise(); 57 | 58 | await fs.ensureDir(path.dirname(localPath)); 59 | await fs.writeFile(localPath, data.Body as Buffer); 60 | 61 | return localPath; 62 | } 63 | 64 | /** 65 | * Clears the cache for a specific book or all books. 66 | * If a `bookId` is provided, the cache for that specific book will be cleared. 67 | * If no `bookId` is provided, the cache for all books will be cleared. 68 | * @param bookId - The ID of the book to clear the cache for. If not provided, the cache for all books will be cleared. 69 | * @returns A Promise that resolves when the cache is cleared. 70 | */ 71 | async clearCache(bookId?: string): Promise { 72 | if (bookId) { 73 | await fs.remove(path.join(this.cacheDir, bookId)); 74 | } else { 75 | await fs.emptyDir(this.cacheDir); 76 | } 77 | } 78 | } 79 | 80 | export const localBookCache = new LocalBookCacheServcies(); 81 | -------------------------------------------------------------------------------- /src/services/book/BookServiceImpl.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | import { fileTypeFromBuffer } from "file-type"; 4 | import { bookRepository } from "../../repository/BookRepository"; 5 | import Book, { IBook } from "../../models/Book"; 6 | import { BookWithFiles, IBookService } from "./BookServiceInterface"; 7 | import { ApiResponse } from "../../core/ipc/ApiResponse"; 8 | import { dialog } from "electron"; 9 | import { bookFileRepository } from "../../repository/BookfileRepository"; 10 | import { coverImageRepository } from "../../repository/CoverImageRepostory"; 11 | import { logService } from "../log/LogServiceImpl"; 12 | import axios from "axios"; 13 | import { BookProcessorFactory } from "../../data/processors/BookProcessorFactory"; 14 | 15 | class BookService implements IBookService { 16 | private bookProcessorFactory = new BookProcessorFactory(); 17 | 18 | async addBookByModel(book: Partial): Promise> { 19 | logService.info(`Adding new book: ${book.title}`); 20 | try { 21 | const newBook = await bookRepository.createNewBook(book); 22 | logService.info(`Successfully added book: ${newBook._id}`); 23 | return { 24 | success: true, 25 | message: "Successfully added book", 26 | payload: newBook, 27 | }; 28 | } catch (error) { 29 | logService.error(`Failed to add book: ${book.title}`, error); 30 | return { 31 | success: false, 32 | message: "Failed to add book", 33 | payload: null, 34 | }; 35 | } 36 | } 37 | 38 | async addBook(): Promise> { 39 | logService.info("Adding new book"); 40 | try { 41 | const result = await dialog.showOpenDialog({ 42 | properties: ["openFile"], 43 | filters: [{ name: "Ebooks", extensions: ["epub"] }], 44 | }); 45 | 46 | if (result.canceled || result.filePaths.length === 0) { 47 | logService.info("No file selected"); 48 | return { success: false, message: "No file selected", payload: null }; 49 | } 50 | 51 | const filePath = result.filePaths[0]; 52 | const fileName = path.basename(filePath); 53 | const objectName = `books/${Date.now()}_${fileName}`; 54 | 55 | logService.info("add-new-book filePath:", filePath); 56 | logService.info("add-new-book objectName:", objectName); 57 | logService.info("add-new-book fileName:", fileName); 58 | 59 | const processor = this.bookProcessorFactory.getProcessor(filePath); 60 | if (!processor) { 61 | return { 62 | success: false, 63 | message: "Unsupported file type", 64 | payload: null, 65 | }; 66 | } 67 | 68 | const metadata = await processor.extractMetadata(filePath); 69 | const newBook = await bookRepository.createNewBook(metadata); 70 | logService.info("add-new-book newBook:", newBook); 71 | 72 | const newUploadBookFile = await bookFileRepository.uploadBookFile( 73 | newBook._id, 74 | filePath, 75 | fileName, 76 | objectName 77 | ); 78 | logService.info("add-new-book newUploadBookFile:", newUploadBookFile); 79 | 80 | const coverBuffer = await processor.extractCover(filePath); 81 | if (coverBuffer) { 82 | await coverImageRepository.uploadBookCoverImageFromBuffer( 83 | newBook._id, 84 | coverBuffer, 85 | "image/jpeg" // 假设封面总是 JPEG 格式,实际使用时应该动态确定 86 | ); 87 | } 88 | return { 89 | success: true, 90 | message: "Successfully added book", 91 | payload: newBook, 92 | }; 93 | } catch (error) { 94 | logService.error("Failed to add book", error); 95 | return { 96 | success: false, 97 | message: "Failed to add book", 98 | payload: null, 99 | }; 100 | } 101 | } 102 | 103 | async updateBook( 104 | bookId: Id, 105 | book: Partial 106 | ): Promise> { 107 | try { 108 | const bookUpdated = await bookRepository.updateBook(bookId, book); 109 | return { 110 | success: true, 111 | message: "Successfully updated book", 112 | payload: bookUpdated, 113 | }; 114 | } catch (error) { 115 | logService.error("Failed to update book", error); 116 | return { 117 | success: false, 118 | message: "Failed to update book", 119 | payload: null, 120 | }; 121 | } 122 | } 123 | /** 124 | * Retrieves the latest books based on the specified page and page size. 125 | * @param page - The page number. 126 | * @param pageSize - The number of books per page. 127 | * @returns A promise that resolves to an ApiResponse object containing the fetched books and total count. 128 | */ 129 | async getLatestBooks( 130 | page: number, 131 | pageSize: number 132 | ): Promise> { 133 | // 查询书籍 134 | try { 135 | const { books, total } = await bookRepository.findAll(page, pageSize); 136 | return { 137 | success: true, 138 | message: "Successfully fetched latest books", 139 | payload: { books, total }, 140 | }; 141 | } catch (error) { 142 | logService.error("Failed to fetch latest books", error); 143 | return { 144 | success: false, 145 | message: "Failed to fetch latest books", 146 | payload: { books: [], total: 0 }, 147 | }; 148 | } 149 | } 150 | 151 | async getRecentlyReadBooks( 152 | page: number, 153 | pageSize: number 154 | ): Promise> { 155 | try { 156 | const [books, total] = await Promise.all([ 157 | Book.find({ lastReadTime: { $exists: true } }) 158 | .sort({ lastReadTime: -1 }) 159 | .skip((page - 1) * pageSize) 160 | .limit(pageSize) 161 | .lean(), 162 | Book.countDocuments({ lastReadTime: { $exists: true } }), 163 | ]); 164 | return { 165 | success: true, 166 | message: "Successfully fetched recently read books", 167 | payload: { books, total }, 168 | }; 169 | } catch (error) { 170 | logService.error("Failed to fetch recently read books", error); 171 | return { 172 | success: false, 173 | message: "Failed to fetch recently read books", 174 | payload: { books: [], total: 0 }, 175 | }; 176 | } 177 | } 178 | 179 | /** 180 | * Finds a book by its ID. 181 | * @param id - The ID of the book to find. 182 | * @returns A promise that resolves to the found book, or null if not found. 183 | */ 184 | async findBookById(id: Id): Promise> { 185 | try { 186 | return { 187 | success: true, 188 | message: "Successfully fetched book details", 189 | payload: await bookRepository.findBookById(id), 190 | }; 191 | } catch (error) { 192 | logService.error("Failed to fetch book details", error); 193 | return { 194 | success: false, 195 | message: "Failed to fetch book details", 196 | payload: null, 197 | }; 198 | } 199 | } 200 | 201 | /** 202 | * Parses the books in the specified directory and returns the result as an ApiResponse. 203 | * @param directory - The directory path where the books are located. 204 | * @returns A Promise that resolves to an ApiResponse containing the parsed books with their files. 205 | */ 206 | async batchParseBooksInDirectory( 207 | directory: string 208 | ): Promise> { 209 | try { 210 | const bookMap = new Map(); 211 | const files = await fs.promises.readdir(directory); 212 | 213 | // 第一步:收集文件 214 | for (const file of files) { 215 | const filePath = path.join(directory, file); 216 | const stats = await fs.promises.stat(filePath); 217 | 218 | if (stats.isFile()) { 219 | const fileNameWithoutExt = path.parse(file).name.toLowerCase(); 220 | const fileExt = path.extname(file).toLowerCase(); 221 | 222 | if (this.supportedEbookFormats.has(fileExt)) { 223 | if (!bookMap.has(fileNameWithoutExt)) { 224 | bookMap.set(fileNameWithoutExt, []); 225 | } 226 | bookMap.get(fileNameWithoutExt).push(filePath); 227 | } 228 | } 229 | } 230 | 231 | // 第二步:处理每本书 232 | const booksWithFiles: BookWithFiles[] = []; 233 | for (const [bookName, files] of bookMap) { 234 | const bookWithFiles: BookWithFiles = { 235 | name: bookName, 236 | files: files.map((filePath) => ({ 237 | filename: path.basename(filePath), 238 | fullPath: filePath, 239 | fileExtension: path.extname(filePath), 240 | })), 241 | }; 242 | 243 | booksWithFiles.push(bookWithFiles); 244 | } 245 | 246 | return { 247 | success: true, 248 | message: "Successfully parsed books in directory", 249 | payload: booksWithFiles, 250 | }; 251 | } catch (error) { 252 | logService.error("Error parsing books in directory:", error); 253 | return { 254 | success: false, 255 | message: "Failed to parse books in directory", 256 | payload: [], 257 | }; 258 | } 259 | } 260 | 261 | async findBookByWeixinBookKey(bookKey: string): Promise> { 262 | try { 263 | const book = await bookRepository.findByWeixinBookKey(bookKey); 264 | return { 265 | success: true, 266 | message: "Successfully found book", 267 | payload: book, 268 | }; 269 | } catch (error) { 270 | logService.error("Failed to find book by Weixin book key", error); 271 | return { 272 | success: false, 273 | message: "Failed to find book by Weixin book key", 274 | payload: null, 275 | }; 276 | } 277 | } 278 | 279 | async createBookFromWeixin( 280 | bookKey: string, 281 | weixinBook: { 282 | bookId: string; 283 | title: string; 284 | author: string; 285 | cover: string; 286 | } 287 | ): Promise> { 288 | try { 289 | // 下载封面图片 290 | const coverResponse = await axios.get(weixinBook.cover, { 291 | responseType: "arraybuffer", 292 | }); 293 | const coverBuffer = Buffer.from(coverResponse.data, "binary"); 294 | // 检测图片的 MIME 类型 295 | const fileTypeResult = await fileTypeFromBuffer(coverBuffer); 296 | const mimeType = fileTypeResult 297 | ? fileTypeResult.mime 298 | : "application/octet-stream"; 299 | 300 | // 创建新书 301 | const newBook = await bookRepository.createNewBook({ 302 | title: weixinBook.title, 303 | author: weixinBook.author, 304 | weixinBookKey: bookKey, 305 | weixinBookId: weixinBook.bookId, 306 | weixinBookTitle: weixinBook.title, 307 | weixinBookAuthor: weixinBook.author, 308 | }); 309 | 310 | // 上传封面到 MinIO 311 | const coverUrl = 312 | await coverImageRepository.uploadBookCoverImageFromBuffer( 313 | newBook._id, 314 | coverBuffer, 315 | mimeType 316 | ); 317 | 318 | // 更新书籍信息,包含封面 URL 319 | const updatedBook = await bookRepository.updateBook(newBook._id, { 320 | ...newBook, 321 | coverImagePath: coverUrl, 322 | }); 323 | 324 | return { 325 | success: true, 326 | message: "Successfully created book from Weixin", 327 | payload: updatedBook, 328 | }; 329 | } catch (error) { 330 | logService.error("Failed to create book from Weixin", error); 331 | return { 332 | success: false, 333 | message: "Failed to create book from Weixin", 334 | payload: null, 335 | }; 336 | } 337 | } 338 | 339 | async updateLastReadTime(bookId: Id): Promise> { 340 | try { 341 | const updatedBook = await bookRepository.updateBook(bookId, { 342 | lastReadTime: new Date(), 343 | }); 344 | return { 345 | success: true, 346 | message: "Successfully updated last read time", 347 | payload: updatedBook, 348 | }; 349 | } catch (error) { 350 | logService.error("Failed to update last read time", error); 351 | return { 352 | success: false, 353 | message: "Failed to update last read time", 354 | payload: null, 355 | }; 356 | } 357 | } 358 | 359 | private readonly supportedEbookFormats = new Set([ 360 | ".epub", // Electronic Publication - open e-book standard by International Digital Publishing Forum (IDPF) 361 | ".pdf", // Portable Document Format - developed by Adobe 362 | ".mobi", // Mobipocket e-book format 363 | ".azw", // Amazon Kindle e-book format 364 | ".azw3", // Enhanced version of AZW with better support for complex layouts 365 | ".fb2", // FictionBook - open XML-based e-book format 366 | ".djvu", // DjVu - format specialized for storing scanned documents 367 | ".txt", // Plain text format 368 | ".rtf", // Rich Text Format - a formatted text format 369 | ".chm", // Microsoft Compiled HTML Help - compressed HTML format 370 | ".cbr", // Comic Book Archive file (RAR compressed) 371 | ".cbz", // Comic Book Archive file (ZIP compressed) 372 | ".docx", // Microsoft Word Open XML Format 373 | ".lit", // Microsoft Reader format (deprecated) 374 | ".prc", // Palm Resource Compiler format, often used for Mobipocket books 375 | ".pdb", // Palm Database format, used for various e-book formats 376 | ".htm", // HyperText Markup Language file 377 | ".html", // HyperText Markup Language file 378 | ".lrf", // Sony Portable Reader format 379 | ".lrx", // Sony Reader Digital Book file 380 | ".pmlz", // Palm Markup Language Compressed format 381 | ".oxps", // Open XML Paper Specification 382 | ".xps", // XML Paper Specification 383 | ]); 384 | } 385 | 386 | export const bookService = new BookService(); 387 | -------------------------------------------------------------------------------- /src/services/book/BookServiceInterface.ts: -------------------------------------------------------------------------------- 1 | import { ApiResponse } from "../../core/ipc/ApiResponse"; 2 | import { IBook } from "../../models/Book"; 3 | 4 | export interface IBookService { 5 | addBookByModel(book: Partial): Promise>; 6 | 7 | addBook(): Promise>; 8 | 9 | updateBook(bookId: Id, book: Partial): Promise>; 10 | 11 | findBookById(id: Id): Promise>; 12 | 13 | getLatestBooks( 14 | page: number, 15 | pageSize: number 16 | ): Promise>; 17 | 18 | getRecentlyReadBooks( 19 | page: number, 20 | pageSize: number 21 | ): Promise>; 22 | 23 | batchParseBooksInDirectory( 24 | directory: string 25 | ): Promise>; 26 | 27 | findBookByWeixinBookKey(bookKey: string): Promise>; 28 | 29 | createBookFromWeixin( 30 | bookKey: string, 31 | weixinBook: { 32 | bookId: string; 33 | title: string; 34 | author: string; 35 | cover: string; 36 | } 37 | ): Promise>; 38 | 39 | updateLastReadTime(bookId: Id): Promise>; 40 | } 41 | 42 | export interface BookWithFiles { 43 | name: string; // 书名(去除后缀的文件名) 44 | files: { 45 | filename: string; // 带有后缀的文件名 46 | fullPath: string; // 文件的完整路径 47 | fileExtension: string; // 文件扩展名(.epub, .pdf, .mobi, etc.) 48 | }[]; 49 | } 50 | -------------------------------------------------------------------------------- /src/services/bookcover/BookCoverServiceImpl.ts: -------------------------------------------------------------------------------- 1 | import { coverImageRepository } from "../../repository/CoverImageRepostory"; 2 | import { ApiResponse } from "../../core/ipc/ApiResponse"; 3 | import { IBookCoverService } from "./BookCoverServiceInterface"; 4 | import { bookFileRepository } from "../../repository/BookfileRepository"; 5 | import { localBookCache } from "../LocalBookCacheService"; 6 | import { logService } from "../log/LogServiceImpl"; 7 | 8 | class BookCoverService implements IBookCoverService { 9 | async extractBookCover(bookId: Id, fileId: Id): Promise> { 10 | try { 11 | const bookFile = await bookFileRepository.findBookFileById(fileId); 12 | if (!bookFile) { 13 | return { success: false, message: "文件不存在" }; 14 | } 15 | // 使用本地缓存模块获取文件 16 | const filePath = await localBookCache.getBookFile(bookId, bookFile.path); 17 | const coverImageUrl = await coverImageRepository.uploadBookCoverImage( 18 | bookId, 19 | filePath 20 | ); 21 | 22 | return { 23 | success: true, 24 | message: "Successfully extracted cover image", 25 | payload: coverImageUrl, 26 | }; 27 | } catch (error) { 28 | logService.error("Error extracting cover image:", error); 29 | return { 30 | success: false, 31 | message: "Failed to extract cover image", 32 | payload: null, 33 | }; 34 | } 35 | } 36 | 37 | async extractLocalBookCover( 38 | bookId: Id, 39 | filePath: string 40 | ): Promise> { 41 | try { 42 | const coverImageUrl = await coverImageRepository.uploadBookCoverImage( 43 | bookId, 44 | filePath 45 | ); 46 | 47 | return { 48 | success: true, 49 | message: "Successfully extracted cover image", 50 | payload: coverImageUrl, 51 | }; 52 | } catch (error) { 53 | logService.error("Error extracting cover image:", error); 54 | return { 55 | success: false, 56 | message: "Failed to extract cover image", 57 | payload: null, 58 | }; 59 | } 60 | } 61 | 62 | async getBookCover( 63 | coverImagePath: string 64 | ): Promise> { 65 | try { 66 | const coverImage = await coverImageRepository.findCoverImageByPath( 67 | coverImagePath 68 | ); 69 | return { 70 | success: true, 71 | message: "Successfully fetched cover image", 72 | payload: coverImage, 73 | }; 74 | } catch (error) { 75 | logService.error("Error fetching cover image:", error); 76 | return { 77 | success: false, 78 | message: "Failed to fetch cover image", 79 | payload: null, 80 | }; 81 | } 82 | } 83 | } 84 | 85 | export const bookCoverService = new BookCoverService(); 86 | -------------------------------------------------------------------------------- /src/services/bookcover/BookCoverServiceInterface.ts: -------------------------------------------------------------------------------- 1 | import { ApiResponse } from "../../core/ipc/ApiResponse"; 2 | 3 | export interface IBookCoverService { 4 | getBookCover(coverImagePath: string): Promise>; 5 | 6 | extractBookCover(bookId: Id, fileId: Id): Promise>; 7 | 8 | extractLocalBookCover( 9 | bookId: Id, 10 | filePath: string 11 | ): Promise>; 12 | } 13 | -------------------------------------------------------------------------------- /src/services/bookfile/BookFileServiceImpl.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import crypto from "crypto"; 4 | import { BookFile, IBookFile } from "../../models/BookFile"; 5 | import { IBookFileService } from "./BookFileServiceInterface"; 6 | import { bookFileRepository } from "../../repository/BookfileRepository"; 7 | import { dialog } from "electron"; 8 | import { ApiResponse } from "../../core/ipc/ApiResponse"; 9 | import { localBookCache } from "../LocalBookCacheService"; 10 | import { logService } from "../log/LogServiceImpl"; 11 | import { toObjectId } from "../../utils/DtoUtils"; 12 | 13 | class BookFileService implements IBookFileService { 14 | async getBookFileContent(bookId: Id, bookFileId: Id): Promise> { 15 | try { 16 | const bookFile = await bookFileRepository.findBookFileById(bookFileId); 17 | if (!bookFile) { 18 | return { success: false, message: "No book file found", payload: null }; 19 | } 20 | const filePath = await localBookCache.getBookFile( 21 | bookId, 22 | bookFile.path 23 | ); 24 | const content = fs.readFileSync(filePath); 25 | return { 26 | success: true, 27 | message: "Successfully fetched book content", 28 | payload: content, 29 | }; 30 | } catch (error) { 31 | logService.error("Error getting local book path:", error); 32 | return { 33 | success: false, 34 | message: "Failed to get local book content", 35 | payload: null, 36 | }; 37 | } 38 | } 39 | 40 | async getBookFiles(bookId: Id): Promise> { 41 | try { 42 | const bookFiles = await bookFileRepository.findBookFilesByBookId(bookId); 43 | return { 44 | success: true, 45 | message: "Successfully fetched book files", 46 | payload: bookFiles, 47 | }; 48 | } catch (error) { 49 | logService.error("Failed to fetch book files", error); 50 | return { 51 | success: false, 52 | message: "Failed to fetch book files", 53 | payload: [], 54 | }; 55 | } 56 | } 57 | 58 | async findBookFileByBookFileId(bookFileId: Id): Promise> { 59 | try { 60 | const bookFile = await bookFileRepository.findBookFileById(bookFileId); 61 | if (!bookFile) { 62 | return { 63 | success: false, 64 | message: "Book file not found", 65 | payload: null, 66 | }; 67 | } 68 | 69 | return { 70 | success: true, 71 | message: "Successfully found book file", 72 | payload: bookFile, 73 | }; 74 | } catch (error) { 75 | logService.error("Error finding book file:", error); 76 | return { 77 | success: false, 78 | message: "Failed to find book file", 79 | payload: null, 80 | }; 81 | } 82 | } 83 | 84 | async uploadBookFileByPath( 85 | bookId: Id, 86 | filePath: string 87 | ): Promise> { 88 | try { 89 | const fileName = path.basename(filePath); 90 | const objectName = `books/${Date.now()}_${fileName}`; 91 | const newUploadBookFile = await bookFileRepository.uploadBookFile( 92 | bookId, 93 | filePath, 94 | fileName, 95 | objectName 96 | ); 97 | 98 | if (!newUploadBookFile) { 99 | return null; 100 | } 101 | 102 | return { 103 | success: true, 104 | message: "Book file uploaded successfully", 105 | payload: newUploadBookFile, 106 | }; 107 | } catch (error) { 108 | logService.error("Error uploading file:", error); 109 | return { 110 | success: false, 111 | message: "Failed to upload book file", 112 | payload: null, 113 | }; 114 | } 115 | } 116 | 117 | async uploadBookFile(bookId: Id): Promise> { 118 | try { 119 | const result = await dialog.showOpenDialog({ 120 | properties: ["openFile"], 121 | filters: [{ name: "Book Files", extensions: ["epub", "pdf", "mobi"] }], 122 | }); 123 | 124 | if (result.canceled || result.filePaths.length === 0) { 125 | return { 126 | success: false, 127 | message: "未选择文件", 128 | payload: null, 129 | }; 130 | } 131 | 132 | const filePath = result.filePaths[0]; 133 | const fileName = path.basename(filePath); 134 | const objectName = `books/${Date.now()}_${fileName}`; 135 | const newUploadBookFile = await bookFileRepository.uploadBookFile( 136 | bookId, 137 | filePath, 138 | fileName, 139 | objectName 140 | ); 141 | 142 | if (!newUploadBookFile) { 143 | return { 144 | success: false, 145 | message: "Failed to upload book file", 146 | payload: null, 147 | }; 148 | } 149 | 150 | return { 151 | success: true, 152 | message: "Book file uploaded successfully", 153 | payload: newUploadBookFile, 154 | }; 155 | } catch (error) { 156 | logService.error("Error uploading file:", error); 157 | return { 158 | success: false, 159 | message: "Failed to upload book file: " + error.message, 160 | payload: null, 161 | }; 162 | } 163 | } 164 | 165 | async batchCheckSHA256( 166 | filePaths: string[] 167 | ): Promise> { 168 | try { 169 | const result: { [filePath: string]: string | null } = {}; 170 | for (const filePath of filePaths) { 171 | const sha256 = await bookFileRepository.calculateSha256(filePath); 172 | const existingFile = await bookFileRepository.findBookFileBySha256( 173 | sha256 174 | ); 175 | result[filePath] = existingFile ? sha256 : null; 176 | } 177 | return { 178 | success: true, 179 | message: "SHA256 检查完成", 180 | payload: result, 181 | }; 182 | } catch (error) { 183 | logService.error("批量 SHA256 检查出错:", error); 184 | return { 185 | success: false, 186 | message: "批量 SHA256 检查失败", 187 | payload: null, 188 | }; 189 | } 190 | } 191 | 192 | async getBookFilesWithoutSha256(): Promise> { 193 | try { 194 | const bookFiles = await BookFile.find({ 195 | sha256: { $exists: false }, 196 | }).lean(); 197 | return { 198 | success: true, 199 | message: "Successfully retrieved book files without SHA256", 200 | payload: bookFiles, 201 | }; 202 | } catch (error) { 203 | logService.error("Error in getBookFilesWithoutSha256:", error); 204 | return { 205 | success: false, 206 | message: "Failed to retrieve book files without SHA256", 207 | payload: [], 208 | }; 209 | } 210 | } 211 | 212 | async calculateAndUpdateSha256( 213 | bookId: Id, 214 | fileId: Id 215 | ): Promise> { 216 | try { 217 | const bookFile = await bookFileRepository.findBookFileById(fileId); 218 | if (!bookFile) { 219 | return { success: false, message: "文件不存在", payload: null }; 220 | } 221 | 222 | const filePath = await localBookCache.getBookFile(bookId, bookFile.path); 223 | logService.debug("Calculating SHA256 for file:", filePath); 224 | const updatedBookFile = await bookFileRepository.updateFileSha256( 225 | fileId, 226 | filePath 227 | ); 228 | logService.debug("Updated book file:", updatedBookFile.filename); 229 | 230 | if (!updatedBookFile) { 231 | logService.debug("Failed to update SHA256"); 232 | return { success: false, message: "更新SHA256失败", payload: null }; 233 | } 234 | 235 | logService.debug("Successfully updated SHA256"); 236 | return { 237 | success: true, 238 | message: "成功计算并更新SHA256", 239 | payload: updatedBookFile, 240 | }; 241 | } catch (error) { 242 | logService.error("计算或更新SHA256时出错:", error); 243 | return { success: false, message: "计算或更新SHA256失败", payload: null }; 244 | } 245 | } 246 | 247 | async updateBookFileLocation(fileId: Id, location: string): Promise> { 248 | logService.debug("Updating book file location:", toObjectId(fileId).toHexString(), location); 249 | try { 250 | const updatedBookFile = await BookFile.findByIdAndUpdate( 251 | toObjectId(fileId), 252 | { location }, 253 | { new: true } 254 | ).lean(); 255 | 256 | if (!updatedBookFile) { 257 | logService.error("Failed to update book file location"); 258 | return { 259 | success: false, 260 | message: "Failed to update book file location", 261 | payload: null, 262 | }; 263 | } 264 | 265 | logService.debug("Successfully updated book file location:", updatedBookFile.location); 266 | 267 | return { 268 | success: true, 269 | message: "Successfully updated book file location", 270 | payload: updatedBookFile, 271 | }; 272 | } catch (error) { 273 | logService.error("Error updating book file location:", error); 274 | return { 275 | success: false, 276 | message: "Failed to update book file location", 277 | payload: null, 278 | }; 279 | } 280 | } 281 | } 282 | 283 | export const bookFileService = new BookFileService(); 284 | -------------------------------------------------------------------------------- /src/services/bookfile/BookFileServiceInterface.ts: -------------------------------------------------------------------------------- 1 | import { ApiResponse } from "../../core/ipc/ApiResponse"; 2 | import { IBookFile } from "../../models/BookFile"; 3 | 4 | /** 5 | * Represents a service for managing book files. 6 | */ 7 | export interface IBookFileService { 8 | /** 9 | * Uploads a book file by its file path. 10 | * @param bookId - The ID of the book. 11 | * @param filePath - The file path of the book file. 12 | * @returns A promise that resolves to the API response containing the uploaded book file. 13 | */ 14 | uploadBookFileByPath( 15 | bookId: Id, 16 | filePath: string 17 | ): Promise>; 18 | 19 | /** 20 | * Uploads a book file. 21 | * @param bookId - The ID of the book. 22 | * @returns A promise that resolves to the API response containing the uploaded book file. 23 | */ 24 | uploadBookFile(bookId: Id): Promise>; 25 | 26 | /** 27 | * Retrieves all book files associated with a book. 28 | * @param bookId - The ID of the book. 29 | * @returns A promise that resolves to the API response containing an array of book files. 30 | */ 31 | getBookFiles(bookId: Id): Promise>; 32 | 33 | findBookFileByBookFileId(bookFileId: Id): Promise>; 34 | 35 | /** 36 | * Retrieves the content of a book file. 37 | * @param bookFileId - The ID of the book. 38 | * @returns A promise that resolves to the API response containing the content of the book file as a Buffer, or null if the file is not found. 39 | */ 40 | getBookFileContent(bookId: Id, bookFileId: Id): Promise>; 41 | 42 | batchCheckSHA256( 43 | filePaths: string[] 44 | ): Promise>; 45 | 46 | getBookFilesWithoutSha256(): Promise>; 47 | 48 | calculateAndUpdateSha256( 49 | bookId: Id, 50 | fileId: Id 51 | ): Promise>; 52 | 53 | updateBookFileLocation(fileId: Id, location: string): Promise>; 54 | } 55 | -------------------------------------------------------------------------------- /src/services/epub/EpubServiceImpl.ts: -------------------------------------------------------------------------------- 1 | import EPub from "epub2"; 2 | import { IMetadata } from "epub2/lib/epub/const"; 3 | import { ApiResponse } from "../../core/ipc/ApiResponse"; 4 | 5 | class EpubService { 6 | async extractMetadata(filePath: string): Promise> { 7 | try { 8 | const epub: EPub = await EPub.createAsync(filePath); 9 | const metadata = epub.metadata; 10 | return { 11 | success: true, 12 | message: "Successfully extracted metadata", 13 | payload: metadata, 14 | }; 15 | } catch (error) { 16 | return { 17 | success: false, 18 | message: "Failed to extract metadata", 19 | payload: null, 20 | }; 21 | } 22 | } 23 | } 24 | 25 | export const epubService = new EpubService(); 26 | -------------------------------------------------------------------------------- /src/services/epub/EpubServiceInterface.ts: -------------------------------------------------------------------------------- 1 | import { IMetadata } from "epub2/lib/epub/const"; 2 | import { ApiResponse } from "../../core/ipc/ApiResponse"; 3 | 4 | export interface IEpubService { 5 | extractMetadata(filePath: string): Promise>; 6 | } 7 | -------------------------------------------------------------------------------- /src/services/file/FileServiceImpl.ts: -------------------------------------------------------------------------------- 1 | import { dialog } from "electron"; 2 | import { IFileService } from "./FileServiceInterface"; 3 | import { ApiResponse } from "../../core/ipc/ApiResponse"; 4 | import { logService } from "../log/LogServiceImpl"; 5 | 6 | class FileService implements IFileService { 7 | async selectDirectory(): Promise> { 8 | try { 9 | const result = await dialog.showOpenDialog({ 10 | properties: ["openDirectory"], 11 | }); 12 | 13 | if (result.canceled || result.filePaths.length === 0) { 14 | return { 15 | success: false, 16 | message: "Directory selection was cancelled", 17 | payload: null, 18 | }; 19 | } 20 | 21 | return { 22 | success: true, 23 | message: "Directory selected successfully", 24 | payload: result.filePaths[0], 25 | }; 26 | } catch (error) { 27 | logService.error("Error selecting directory:", error); 28 | return { 29 | success: false, 30 | message: "Failed to select directory", 31 | payload: null, 32 | }; 33 | } 34 | } 35 | } 36 | 37 | export const fileService = new FileService(); 38 | -------------------------------------------------------------------------------- /src/services/file/FileServiceInterface.ts: -------------------------------------------------------------------------------- 1 | import { ApiResponse } from "../../core/ipc/ApiResponse"; 2 | 3 | export interface IFileService { 4 | selectDirectory(): Promise>; 5 | } 6 | -------------------------------------------------------------------------------- /src/services/log/LogServiceImpl.ts: -------------------------------------------------------------------------------- 1 | import log from "electron-log"; 2 | import path from "path"; 3 | import { ILogService } from "./LogServiceInterface"; 4 | import { configStore } from "../../config/ConfigStore"; 5 | 6 | class LogService implements ILogService { 7 | constructor() { 8 | const logPath = path.join(configStore.getDefaultStoragePath(), "logs"); 9 | log.transports.file.resolvePathFn = () => 10 | path.join(logPath, `${new Date().toISOString().split("T")[0]}.log`); 11 | log.transports.file.level = "info"; 12 | log.transports.console.level = "debug"; 13 | } 14 | 15 | info(message: string, ...args: any[]): void { 16 | log.info(message, ...args); 17 | } 18 | warn(message: string, ...args: any[]): void { 19 | log.warn(message, ...args); 20 | } 21 | error(message: string, ...args: any[]): void { 22 | log.error(message, ...args); 23 | } 24 | debug(message: string, ...args: any[]): void { 25 | log.debug(message, ...args); 26 | } 27 | } 28 | 29 | export const logService = new LogService(); 30 | -------------------------------------------------------------------------------- /src/services/log/LogServiceInterface.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a logging service that provides methods for logging different types of messages. 3 | */ 4 | export interface ILogService { 5 | /** 6 | * Logs an informational message. 7 | * @param message - The message to be logged. 8 | * @param args - Additional arguments to be included in the log message. 9 | */ 10 | info(message: string, ...args: any[]): void; 11 | 12 | /** 13 | * Logs a warning message. 14 | * @param message - The message to be logged. 15 | * @param args - Additional arguments to be included in the log message. 16 | */ 17 | warn(message: string, ...args: any[]): void; 18 | 19 | /** 20 | * Logs an error message. 21 | * @param message - The message to be logged. 22 | * @param args - Additional arguments to be included in the log message. 23 | */ 24 | error(message: string, ...args: any[]): void; 25 | 26 | /** 27 | * Logs a debug message. 28 | * @param message - The message to be logged. 29 | * @param args - Additional arguments to be included in the log message. 30 | */ 31 | debug(message: string, ...args: any[]): void; 32 | } 33 | -------------------------------------------------------------------------------- /src/types/mongoose.ts: -------------------------------------------------------------------------------- 1 | type Id = { 2 | buffer: Uint8Array; 3 | }; -------------------------------------------------------------------------------- /src/utils/DtoUtils.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | 3 | export const toObjectId = (binaryId: Id): Types.ObjectId => { 4 | // 将 Uint8Array 转换为十六进制字符串 5 | return new Types.ObjectId(binaryId.buffer); 6 | // return new Types.ObjectId( 7 | // Array.from(binaryId.buffer) 8 | // .map(b => b.toString(16).padStart(2, '0')).join('')); 9 | } 10 | 11 | export function serializeId(id: Id): string { 12 | return Buffer.from(id.buffer) 13 | .toString('base64') 14 | .replace(/\+/g, '-') 15 | .replace(/\//g, '_') 16 | .replace(/=/g, ''); 17 | } 18 | 19 | export function deserializeId(serialized: string): Id { 20 | const base64 = serialized 21 | .replace(/-/g, '+') 22 | .replace(/_/g, '/') 23 | .padEnd(serialized.length + (4 - serialized.length % 4) % 4, '='); 24 | 25 | const buffer = Buffer.from(base64, 'base64'); 26 | return { buffer: new Uint8Array(buffer) }; 27 | } -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxiee/RayBook/b8a463e6517ab4a603682e7dbe6444206ad72d09/todo.md -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "allowJs": true, 5 | "jsx": "react-jsx", 6 | "module": "commonjs", 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "noImplicitAny": true, 11 | "sourceMap": true, 12 | "baseUrl": ".", 13 | "outDir": "dist", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "paths": { 17 | "*": ["node_modules/*"] 18 | } 19 | }, 20 | "include": ["src/**/*"] 21 | } 22 | -------------------------------------------------------------------------------- /webpack.main.config.ts: -------------------------------------------------------------------------------- 1 | import type { Configuration } from "webpack"; 2 | 3 | import { rules } from "./webpack.rules"; 4 | import { plugins } from "./webpack.plugins"; 5 | 6 | export const mainConfig: Configuration = { 7 | /** 8 | * This is the main entry point for your application, it's the first file 9 | * that runs in the main process. 10 | */ 11 | entry: "./src/index.ts", 12 | // Put your normal webpack config below here 13 | module: { 14 | rules, 15 | }, 16 | plugins, 17 | resolve: { 18 | extensions: [".js", ".ts", ".jsx", ".tsx", ".css", ".json"], 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /webpack.plugins.ts: -------------------------------------------------------------------------------- 1 | import type IForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin"; 2 | import CopyPlugin from "copy-webpack-plugin"; 3 | import path from "path"; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-var-requires 6 | const ForkTsCheckerWebpackPlugin: typeof IForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin"); 7 | 8 | export const plugins = [ 9 | new ForkTsCheckerWebpackPlugin({ 10 | logger: "webpack-infrastructure", 11 | }), 12 | new CopyPlugin({ 13 | patterns: [ 14 | { 15 | from: path.resolve( 16 | __dirname, 17 | "node_modules/react-pdf/node_modules/pdfjs-dist/build/pdf.worker.mjs" 18 | ), 19 | to: path.resolve(__dirname, ".webpack/pdf.worker.mjs"), 20 | }, 21 | ], 22 | }), 23 | ]; 24 | -------------------------------------------------------------------------------- /webpack.renderer.config.ts: -------------------------------------------------------------------------------- 1 | import type { Configuration } from "webpack"; 2 | 3 | import { rules } from "./webpack.rules"; 4 | import { plugins } from "./webpack.plugins"; 5 | import path from "path"; 6 | 7 | rules.push({ 8 | test: /\.css$/, 9 | use: [{ loader: "style-loader" }, { loader: "css-loader" }], 10 | }); 11 | 12 | export const rendererConfig: Configuration = { 13 | target: "electron-renderer", 14 | module: { 15 | rules, 16 | }, 17 | plugins, 18 | resolve: { 19 | extensions: [".js", ".ts", ".jsx", ".tsx", ".css"], 20 | alias: { 21 | react: path.resolve("./node_modules/react"), 22 | "react-dom": path.resolve("./node_modules/react-dom"), 23 | "react-reader": path.resolve( 24 | __dirname, 25 | "node_modules/react-reader/dist/react-reader.es.js" 26 | ), 27 | }, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /webpack.rules.ts: -------------------------------------------------------------------------------- 1 | import type { ModuleOptions } from 'webpack'; 2 | 3 | export const rules: Required['rules'] = [ 4 | // Add support for native node modules 5 | { 6 | // We're specifying native_modules in the test because the asset relocator loader generates a 7 | // "fake" .node file which is really a cjs file. 8 | test: /native_modules[/\\].+\.node$/, 9 | use: 'node-loader', 10 | }, 11 | { 12 | test: /[/\\]node_modules[/\\].+\.(m?js|node)$/, 13 | parser: { amd: false }, 14 | use: { 15 | loader: '@vercel/webpack-asset-relocator-loader', 16 | options: { 17 | outputAssetBase: 'native_modules', 18 | }, 19 | }, 20 | }, 21 | { 22 | test: /\.tsx?$/, 23 | exclude: /(node_modules|\.webpack)/, 24 | use: { 25 | loader: 'ts-loader', 26 | options: { 27 | transpileOnly: true, 28 | }, 29 | }, 30 | }, 31 | ]; 32 | --------------------------------------------------------------------------------