Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,4 @@
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env node

/**
* 使用 fonteditor-core 将 TTF 字体转换为 WOFF2。
* 作为 Tauri sidecar,在 Linux 平台上替代 woff2_compress 原生二进制。
*/

import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);

const { Font, woff2 } = require("fonteditor-core");

async function main() {
const [, , inputPath] = process.argv;

if (!inputPath) {
console.error("用法: woff2_compress <字体路径>");
process.exit(1);
}

if (!fs.existsSync(inputPath)) {
console.error(`文件不存在: ${inputPath}`);
process.exit(1);
}

const ext = path.extname(inputPath).toLowerCase();
if (ext !== ".ttf") {
console.error("仅支持 .ttf 文件转换");
process.exit(1);
}

const outputPath = inputPath.replace(/\.ttf$/i, ".woff2");

try {
await woff2.init();
const fontBuffer = fs.readFileSync(inputPath);
const font = Font.create(fontBuffer, { type: "ttf", combinePath: true });
const woff2ArrayBuffer = font.write({ type: "woff2", hinting: true });
const woff2Buffer = Buffer.from(new Uint8Array(woff2ArrayBuffer));
fs.writeFileSync(outputPath, woff2Buffer);

process.exit(0);
} catch (error) {
console.error(`转换失败: ${error.message || error}`);
process.exit(1);
}
}

main();
35 changes: 33 additions & 2 deletions packages/app/src-tauri/plugins/tauri-plugin-epub/src/commands.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use serde::Serialize;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Emitter, Manager, Runtime, State};

use crate::database::VectorDatabase;
Expand All @@ -13,6 +14,36 @@ use crate::models::{
};
use epub2mdbook::convert_epub_to_mdbook;

fn find_epub_in_dir(dir: &Path) -> Option<PathBuf> {
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("epub"))
.unwrap_or(false)
{
return Some(path);
}
}
None
}

fn resolve_epub_path(book_dir: &Path) -> Result<PathBuf, String> {
let default_path = book_dir.join("book.epub");
if default_path.exists() {
return Ok(default_path);
}
if let Some(candidate) = find_epub_in_dir(book_dir) {
return Ok(candidate);
}
Err(format!(
"EPUB not found under directory: {}",
book_dir.to_string_lossy()
))
}

/// Parse an EPUB under $AppData/books/{book_id} and return basic metadata.
#[tauri::command]
pub async fn parse_epub<R: Runtime>(
Expand All @@ -25,7 +56,7 @@ pub async fn parse_epub<R: Runtime>(
}
let app_data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let book_dir = app_data_dir.join("books").join(&book_id);
let epub_path = book_dir.join("book.epub");
let epub_path = resolve_epub_path(&book_dir)?;
let reader = EpubReader::new().map_err(|e| e.to_string())?;
let content = reader.read_epub(&epub_path).map_err(|e| e.to_string())?;
Ok(ParsedBook {
Expand Down Expand Up @@ -114,7 +145,7 @@ pub async fn convert_to_mdbook<R: Runtime>(

let app_data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let book_dir = app_data_dir.join("books").join(&book_id);
let epub_path = book_dir.join("book.epub");
let epub_path = resolve_epub_path(&book_dir)?;
let mdbook_dir = book_dir.join("mdbook");

if !epub_path.exists() {
Expand Down
44 changes: 34 additions & 10 deletions packages/app/src-tauri/plugins/tauri-plugin-epub/src/pipeline.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};

use crate::database::VectorDatabase;
use crate::epub::EpubReader;
Expand All @@ -12,6 +12,22 @@ use crate::models::{
};
use epub2mdbook::convert_epub_to_mdbook;

fn find_epub_in_dir(dir: &Path) -> Option<PathBuf> {
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("epub"))
.unwrap_or(false)
{
return Some(path);
}
}
None
}

/// Core pipeline: book_dir -> locate book.epub -> parse -> write chapters -> vectorize -> persist to SQLite
pub async fn process_epub_to_db<P: AsRef<Path>, F>(
book_dir: P,
Expand All @@ -22,17 +38,25 @@ where
F: FnMut(ProgressUpdate) + Send,
{
let book_dir = book_dir.as_ref();
let epub_path = book_dir.join("book.epub");
let mut epub_path = book_dir.join("book.epub");
let db_path = book_dir.join("vectors.sqlite");

if !epub_path.exists() {
log::error!("EPUB file not found at path: {:?}", epub_path);
log::error!("Book directory contents: {:?}",
std::fs::read_dir(book_dir)
.map(|entries| entries.collect::<Result<Vec<_>, _>>())
.unwrap_or_else(|e| Err(e))
);
anyhow::bail!("EPUB not found: {:?}", epub_path);
if let Some(candidate) = find_epub_in_dir(book_dir) {
log::warn!(
"默认 EPUB 文件不存在,使用目录中的候选文件: {:?}",
candidate
);
epub_path = candidate;
} else {
log::error!("EPUB file not found at path: {:?}", epub_path);
log::error!("Book directory contents: {:?}",
std::fs::read_dir(book_dir)
.map(|entries| entries.collect::<Result<Vec<_>, _>>())
.unwrap_or_else(|e| Err(e))
);
anyhow::bail!("EPUB not found: {:?}", epub_path);
}
}

log::info!("Starting EPUB processing pipeline for book directory: {:?}", book_dir);
Expand Down Expand Up @@ -577,4 +601,4 @@ fn write_metadata_markdown(book_dir: &Path, epub_content: &EpubContent, flat_toc
log::info!("已更新 metadata.json,添加了 base_dir: {:?}", toc_base_dir);

Ok(())
}
}
66 changes: 62 additions & 4 deletions packages/app/src-tauri/src/core/books/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,76 @@ pub async fn save_book(app_handle: AppHandle, data: BookUploadData) -> Result<Si

let epub_filename = format!("book.{}", data.format.to_lowercase());
let epub_path = book_dir.join(&epub_filename);
std::fs::rename(&data.temp_file_path, &epub_path)
.map_err(|e| format!("移动书籍文件失败: {}", e))?;

if !data.temp_file_path.is_empty() {
match std::fs::rename(&data.temp_file_path, &epub_path) {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
log::warn!(
"rename failed (missing source): {:?} -> {:?}, fallback to copy",
data.temp_file_path,
epub_path
);
if let Some(parent) = epub_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("创建书籍目录失败: {}", e))?;
}
std::fs::copy(&data.temp_file_path, &epub_path)
.map_err(|e| format!("复制书籍文件失败: {}", e))?;
}
Err(err) => {
return Err(format!("移动书籍文件失败: {}", err));
}
}
}

let cover_path = if let Some(cover_temp_path) = &data.cover_temp_file_path {
let cover_file = book_dir.join("cover.jpg");
std::fs::rename(cover_temp_path, &cover_file)
.map_err(|e| format!("移动封面文件失败: {}", e))?;
match std::fs::rename(cover_temp_path, &cover_file) {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
log::warn!(
"rename cover failed (missing source): {:?} -> {:?}, fallback to copy",
cover_temp_path,
cover_file
);
std::fs::copy(cover_temp_path, &cover_file)
.map_err(|e| format!("复制封面文件失败: {}", e))?;
}
Err(err) => {
return Err(format!("移动封面文件失败: {}", err));
}
}
Some(format!("books/{}/cover.jpg", data.id))
} else {
None
};

if let Some(derived_files) = &data.derived_files {
for derived in derived_files {
let target_path = book_dir.join(&derived.filename);
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建衍生文件目录失败: {}", e))?;
}

match std::fs::rename(&derived.temp_file_path, &target_path) {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
log::warn!(
"rename derived failed (missing source): {:?} -> {:?}, fallback to copy",
derived.temp_file_path,
target_path
);
std::fs::copy(&derived.temp_file_path, &target_path)
.map_err(|e| format!("复制衍生文件失败: {}", e))?;
}
Err(err) => {
return Err(format!("移动衍生文件失败: {}", err));
}
}
}
}

let metadata_path = book_dir.join("metadata.json");
let metadata_json = serde_json::to_string_pretty(&data.metadata)
.map_err(|e| format!("序列化元数据失败: {}", e))?;
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src-tauri/src/core/books/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ pub struct SimpleBook {
pub updated_at: i64,
}

#[derive(Deserialize, Debug)]
pub struct DerivedFileUpload {
#[serde(rename = "tempFilePath")]
pub temp_file_path: String,
pub filename: String,
}

#[derive(Deserialize, Debug)]
pub struct BookUploadData {
pub id: String,
Expand All @@ -35,6 +42,8 @@ pub struct BookUploadData {
#[serde(rename = "coverTempFilePath")]
pub cover_temp_file_path: Option<String>,
pub metadata: serde_json::Value,
#[serde(rename = "derivedFiles")]
pub derived_files: Option<Vec<DerivedFileUpload>>,
}

#[derive(Deserialize, Debug)]
Expand Down
9 changes: 7 additions & 2 deletions packages/app/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,12 @@
"assetProtocol": {
"enable": true,
"scope": [
"**"
"**",
"$RESOURCE/**",
"$APPDATA/**",
"$APPDATA",
"$CACHE/**",
"$TEMP/**"
]
}
}
Expand Down Expand Up @@ -155,4 +160,4 @@
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEZFNTAwRUM3NDVDQTVGMjkKUldRcFg4cEZ4dzVRL28wSWZ0S3FZdEVkNlFCTUlaYlRaR2ZyOWJoQTNRNm9EdXF5cnhXc1IwU3QK"
}
}
}
}
11 changes: 10 additions & 1 deletion packages/app/src/lib/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export const EXTS: Record<BookFormat, string> = {
EPUB: "epub",
PDF: "pdf",
MOBI: "mobi",
AZW: "azw",
AZW3: "azw3",
CBZ: "cbz",
FB2: "fb2",
FBZ: "fbz",
Expand Down Expand Up @@ -212,7 +214,14 @@ export class DocumentLoader {
const fflate = await import("foliate-js/vendor/fflate.js");
const { MOBI } = await import("foliate-js/mobi.js");
book = await new MOBI({ unzlib: fflate.unzlibSync }).open(this.file);
format = "MOBI";
const ext = this.file.name.split(".").pop()?.toLowerCase();
if (ext === "azw3") {
format = "AZW3";
} else if (ext === "azw") {
format = "AZW";
} else {
format = "MOBI";
}
} else if (this.isFB2()) {
const { makeFB2 } = await import("foliate-js/fb2.js");
book = await makeFB2(this.file);
Expand Down
8 changes: 4 additions & 4 deletions packages/app/src/pages/library/components/upload.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Upload as UploadIcon } from "lucide-react";

import { Button } from "@/components/ui/button";
import { useBookUpload } from "@/hooks/use-book-upload";
import { useThemeStore } from "@/store/theme-store";
import { FILE_ACCEPT_FORMATS } from "@/services/constants";
import { Upload as UploadIcon } from "lucide-react";

export default function Upload() {
const { isDarkMode } = useThemeStore();
Expand All @@ -27,7 +27,7 @@ export default function Upload() {
<input
type="file"
multiple
accept=".epub"
accept={FILE_ACCEPT_FORMATS}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
onChange={handleFileSelect}
disabled={isUploading}
Expand All @@ -47,7 +47,7 @@ export default function Upload() {
<h2 className="font-medium text-neutral-900 text-xl dark:text-neutral-100">
{isUploading ? "上传中..." : "拖拽书籍到此处上传"}
</h2>
<p className="text-neutral-600 text-sm dark:text-neutral-400">支持的格式:.epub</p>
<p className="text-neutral-600 text-sm dark:text-neutral-400">支持的格式:{FILE_ACCEPT_FORMATS}</p>
</div>

<div className="relative">
Expand Down
Loading