forked from akai/readest
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| acb7dd4c93 | |||
| da756eed47 | |||
| 48383b3db6 | |||
| 47130f4fe3 | |||
| 2c4085815e | |||
| 4993053d03 | |||
| 75293bd1a4 | |||
| 3b6d49c1ee | |||
| 783dd6c606 | |||
| ebae9103d2 | |||
| 0d8b2f9167 | |||
| 987067414c | |||
| afd48837f9 | |||
| f7f10f5959 | |||
| 14d1b35d87 | |||
| fd41bcf420 | |||
| 21cad7ec09 | |||
| dc7137d331 | |||
| c4621a273e | |||
| c410b405b9 | |||
| 94c35f999b | |||
| e3b4a00ed1 |
@@ -26,6 +26,7 @@ docker/.env
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
readest-dev.log
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
@@ -72,6 +72,9 @@ docs/superpowers
|
||||
/public/swe-worker-*.js
|
||||
|
||||
/dist/
|
||||
/epub_review_sessions/
|
||||
/tools/epub-review-editor/.venv/
|
||||
/tools/epub-review-editor/__pycache__/
|
||||
|
||||
.context/
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -15,3 +15,5 @@ export const SAMPLE_EPUB = path.join(
|
||||
fixturesDir,
|
||||
'../../src/__tests__/fixtures/data/sample-alice.epub',
|
||||
);
|
||||
|
||||
export const REVIEW_EPUB = path.join(fixturesDir, 'books/readest-review-e2e.epub');
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { zipSync, strToU8 } from 'fflate';
|
||||
|
||||
const target = process.argv[2];
|
||||
if (!target) throw new Error('output path is required');
|
||||
|
||||
const files = {
|
||||
mimetype: strToU8('application/epub+zip'),
|
||||
'META-INF/container.xml': strToU8(`<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||
</container>`),
|
||||
'OEBPS/content.opf': strToU8(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="id">urn:readest:review-e2e</dc:identifier>
|
||||
<dc:title>Readest Review E2E</dc:title><dc:language>zh-CN</dc:language>
|
||||
</metadata>
|
||||
<manifest><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/></manifest>
|
||||
<spine><itemref idref="chapter"/></spine>
|
||||
</package>`),
|
||||
'OEBPS/chapter.xhtml': strToU8(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter</title></head><body>
|
||||
<p class="sourceText">これは校正テスト用の原文です。</p>
|
||||
<p>这是网页审校测试译文。</p>
|
||||
</body></html>`),
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, zipSync(files, { level: 0 }));
|
||||
@@ -0,0 +1,60 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { expect, test } from '../fixtures/base';
|
||||
import { REVIEW_EPUB } from '../fixtures/books';
|
||||
|
||||
const fixtureDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
test.beforeAll(() => {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(fixtureDir, '../fixtures/create-review-epub.mjs'), REVIEW_EPUB],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
fs.rmSync(REVIEW_EPUB, { force: true });
|
||||
});
|
||||
|
||||
test.describe('Inline review mode', () => {
|
||||
test('opens the local sidecar, saves an edit, and resumes the same session', async ({
|
||||
openBook,
|
||||
page,
|
||||
}) => {
|
||||
test.skip(process.env['READEST_REVIEW_E2E'] !== '1', 'requires the local Python sidecar');
|
||||
const reader = await openBook(REVIEW_EPUB);
|
||||
await reader.revealHeader();
|
||||
|
||||
const toggle = page.getByRole('button', { name: 'Review Mode' });
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
|
||||
const panel = page.getByRole('group', { name: '审校' });
|
||||
await expect(panel).toBeVisible({ timeout: 120_000 });
|
||||
const editor = panel.locator('textarea').first();
|
||||
await expect(editor).toHaveValue('这是网页审校测试译文。');
|
||||
await editor.fill('这是已保存的网页审校测试译文。');
|
||||
await panel.getByRole('button', { name: '保存', exact: true }).click();
|
||||
await expect(panel.getByRole('status')).toContainText('已保存');
|
||||
|
||||
await panel.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(panel).toBeHidden();
|
||||
await reader.revealHeader();
|
||||
await page.getByRole('button', { name: 'Disable Review Mode' }).click();
|
||||
await reader.revealHeader();
|
||||
await page.getByRole('button', { name: 'Review Mode' }).click();
|
||||
|
||||
await expect(panel).toBeVisible({ timeout: 120_000 });
|
||||
await expect(panel.locator('textarea').first()).toHaveValue(
|
||||
'这是已保存的网页审校测试译文。',
|
||||
);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await panel.getByRole('button', { name: '导出 EPUB' }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toMatch(/\.epub$/i);
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ const nextConfig = {
|
||||
assetPrefix: '',
|
||||
reactStrictMode: true,
|
||||
serverExternalPackages: ['isows'],
|
||||
allowedDevOrigins: ['192.168.2.120'],
|
||||
allowedDevOrigins: ['192.168.2.120', '127.0.0.1', 'localhost'],
|
||||
webpack: (config) => {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
|
||||
@@ -69,6 +69,11 @@
|
||||
"Imported {{count}} annotations_one": "Imported {{count}} annotation",
|
||||
"Imported {{count}} annotations_other": "Imported {{count}} annotations",
|
||||
"Recently read": "Recently read",
|
||||
"All Books": "All Books",
|
||||
"Categories": "Categories",
|
||||
"Category": "Category",
|
||||
"Publication Year": "Publication Year",
|
||||
"Bilingual": "Bilingual",
|
||||
"Show recently read": "Show recently read",
|
||||
"Your books will appear here": "Your books will appear here",
|
||||
"{{count}} results_one": "{{count}} result",
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
"Apply": "应用",
|
||||
"Auto Mode": "自动主题",
|
||||
"Behavior": "行为",
|
||||
"Bilingual": "双语拆分",
|
||||
"Book": "书籍",
|
||||
"All Books": "全部书籍",
|
||||
"Categories": "分类",
|
||||
"Category": "类别",
|
||||
"Publication Year": "出版年份",
|
||||
"Bookmark": "书签",
|
||||
"Cancel": "取消",
|
||||
"Chapter": "章节",
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
"Apply": "應用",
|
||||
"Auto Mode": "自動主題",
|
||||
"Behavior": "行為",
|
||||
"Bilingual": "雙語拆分",
|
||||
"Book": "書籍",
|
||||
"All Books": "全部書籍",
|
||||
"Categories": "分類",
|
||||
"Category": "類別",
|
||||
"Publication Year": "出版年份",
|
||||
"Bookmark": "書籤",
|
||||
"Cancel": "取消",
|
||||
"Chapter": "章節",
|
||||
|
||||
@@ -112,9 +112,9 @@ sentry = { version = "0.42", default-features = false, features = [
|
||||
"rustls",
|
||||
] }
|
||||
tauri-plugin-sentry = "0.5"
|
||||
rand = "0.8"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
rand = "0.8"
|
||||
cocoa = "0.25"
|
||||
objc = "0.2.7"
|
||||
objc-foundation = "0.1.1"
|
||||
|
||||
@@ -33,6 +33,8 @@ mod mobi_parser;
|
||||
mod nightly_update;
|
||||
mod parser_common;
|
||||
mod range_file;
|
||||
#[cfg(desktop)]
|
||||
mod review_editor;
|
||||
mod sentry_config;
|
||||
#[cfg(desktop)]
|
||||
mod spawn_fresh_browser;
|
||||
@@ -397,6 +399,8 @@ pub fn run() {
|
||||
set_webview_info,
|
||||
#[cfg(desktop)]
|
||||
is_updater_disabled,
|
||||
#[cfg(desktop)]
|
||||
review_editor::launch_epub_review_editor,
|
||||
allow_paths_in_scopes,
|
||||
dir_scanner::read_dir,
|
||||
epub_parser::parse_epub_metadata,
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
use rand::RngCore;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Mutex;
|
||||
use std::{
|
||||
fs,
|
||||
fs::OpenOptions,
|
||||
io::{Read, Write},
|
||||
net::{SocketAddr, TcpStream},
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
time::Duration,
|
||||
};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
const DEFAULT_PORT: u16 = 5177;
|
||||
const PORT_SCAN_LIMIT: u16 = 100;
|
||||
static LAUNCH_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReviewEditorLaunchResponse {
|
||||
ok: bool,
|
||||
url: String,
|
||||
reused: bool,
|
||||
review_root: String,
|
||||
version: String,
|
||||
session_id: Option<String>,
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
struct CommandOutput {
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
struct PythonCommand {
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn launch_epub_review_editor(
|
||||
app: AppHandle,
|
||||
epub_path: Option<String>,
|
||||
) -> Result<ReviewEditorLaunchResponse, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || launch_epub_review_editor_sync(&app, epub_path))
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
|
||||
fn launch_epub_review_editor_sync(
|
||||
app: &AppHandle,
|
||||
epub_path: Option<String>,
|
||||
) -> Result<ReviewEditorLaunchResponse, String> {
|
||||
let _launch_guard = LAUNCH_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "审校器启动锁已损坏。请重启 Readest 后重试。".to_string())?;
|
||||
let tool_root = resolve_tool_root(app)?;
|
||||
let server_path = tool_root.join("server.py");
|
||||
if !server_path.exists() {
|
||||
return Err(format!(
|
||||
"没有找到审校器后端:{}",
|
||||
server_path.to_string_lossy()
|
||||
));
|
||||
}
|
||||
|
||||
let review_root = std::env::var("READEST_REVIEW_ROOT")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
|
||||
.join("epub_review_sessions")
|
||||
});
|
||||
fs::create_dir_all(&review_root).map_err(|err| {
|
||||
format!(
|
||||
"创建审校数据目录失败:{} ({err})",
|
||||
review_root.to_string_lossy()
|
||||
)
|
||||
})?;
|
||||
let access_token = ensure_access_token(&review_root)?;
|
||||
|
||||
let expected_version = read_expected_version(&tool_root);
|
||||
if let Some(url) = find_running_editor(expected_version.as_deref(), &review_root, &access_token)
|
||||
{
|
||||
let session_id = ensure_session_from_path(&url, epub_path.as_deref(), &access_token)?;
|
||||
return Ok(ReviewEditorLaunchResponse {
|
||||
ok: true,
|
||||
url,
|
||||
reused: true,
|
||||
review_root: review_root.to_string_lossy().to_string(),
|
||||
version: expected_version.unwrap_or_default(),
|
||||
session_id,
|
||||
access_token,
|
||||
});
|
||||
}
|
||||
|
||||
let runtime_root = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.unwrap_or_else(|_| review_root.clone())
|
||||
.join("epub-review-editor-runtime");
|
||||
let venv_root = runtime_root.join(".venv");
|
||||
let venv_python = venv_python_path(&venv_root);
|
||||
|
||||
ensure_venv(&venv_python, &venv_root)?;
|
||||
ensure_dependencies(&venv_python, &tool_root)?;
|
||||
|
||||
let output = run_command(
|
||||
&venv_python.to_string_lossy(),
|
||||
&[
|
||||
server_path.to_string_lossy().to_string(),
|
||||
"--review-root".to_string(),
|
||||
review_root.to_string_lossy().to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
DEFAULT_PORT.to_string(),
|
||||
"--access-token".to_string(),
|
||||
access_token.clone(),
|
||||
"--daemon".to_string(),
|
||||
],
|
||||
Some(&tool_root),
|
||||
)?;
|
||||
|
||||
let launched_url = extract_url(&output.stdout)
|
||||
.or_else(|| find_running_editor(expected_version.as_deref(), &review_root, &access_token))
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"审校器已启动但没有返回访问地址。\nstdout:\n{}\nstderr:\n{}",
|
||||
output.stdout, output.stderr
|
||||
)
|
||||
})?;
|
||||
|
||||
let launched_url = launched_url.replace("http://localhost:", "http://127.0.0.1:");
|
||||
let session_id = ensure_session_from_path(&launched_url, epub_path.as_deref(), &access_token)?;
|
||||
|
||||
Ok(ReviewEditorLaunchResponse {
|
||||
ok: true,
|
||||
url: launched_url,
|
||||
reused: false,
|
||||
review_root: review_root.to_string_lossy().to_string(),
|
||||
version: expected_version.unwrap_or_default(),
|
||||
session_id,
|
||||
access_token,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_access_token(review_root: &Path) -> Result<String, String> {
|
||||
let token_path = review_root.join(".access-token");
|
||||
if let Ok(existing) = fs::read_to_string(&token_path) {
|
||||
let token = existing.trim();
|
||||
if !token.is_empty() {
|
||||
return Ok(token.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut bytes = [0_u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
let token = bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let create_result = options
|
||||
.open(&token_path)
|
||||
.and_then(|mut file| file.write_all(token.as_bytes()));
|
||||
match create_result {
|
||||
Ok(()) => Ok(token),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
fs::read_to_string(&token_path)
|
||||
.map(|value| value.trim().to_string())
|
||||
.map_err(|read_err| {
|
||||
format!("Failed to read review sidecar access token: {read_err}")
|
||||
})
|
||||
.and_then(|value| {
|
||||
if value.is_empty() {
|
||||
Err("Review sidecar access token file is empty.".to_string())
|
||||
} else {
|
||||
Ok(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
Err(err) => Err(format!(
|
||||
"Failed to create review sidecar access token: {err}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_tool_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
if let Ok(raw) = std::env::var("READEST_REVIEW_EDITOR_TOOL_ROOT") {
|
||||
let path = PathBuf::from(raw);
|
||||
if path.join("server.py").exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(resource_dir) = app.path().resource_dir() {
|
||||
for candidate in [
|
||||
resource_dir.join("tools").join("epub-review-editor"),
|
||||
resource_dir
|
||||
.join("apps")
|
||||
.join("readest-app")
|
||||
.join("tools")
|
||||
.join("epub-review-editor"),
|
||||
] {
|
||||
if candidate.join("server.py").exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if let Ok(current) = std::env::current_dir() {
|
||||
for ancestor in current.ancestors() {
|
||||
for candidate in [
|
||||
ancestor.join("tools").join("epub-review-editor"),
|
||||
ancestor
|
||||
.join("apps")
|
||||
.join("readest-app")
|
||||
.join("tools")
|
||||
.join("epub-review-editor"),
|
||||
] {
|
||||
if candidate.join("server.py").exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("没有找到已迁移的 EPUB 审校器资源目录。".to_string())
|
||||
}
|
||||
|
||||
fn ensure_venv(venv_python: &Path, venv_root: &Path) -> Result<(), String> {
|
||||
if venv_python.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = venv_root.parent() {
|
||||
fs::create_dir_all(parent).map_err(|err| {
|
||||
format!(
|
||||
"创建审校器运行目录失败:{} ({err})",
|
||||
parent.to_string_lossy()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let python = resolve_python_command()?;
|
||||
let mut args = python.args;
|
||||
args.extend([
|
||||
"-m".to_string(),
|
||||
"venv".to_string(),
|
||||
venv_root.to_string_lossy().to_string(),
|
||||
]);
|
||||
run_command(&python.program, &args, None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_dependencies(venv_python: &Path, tool_root: &Path) -> Result<(), String> {
|
||||
let check = run_command_allow_failure(
|
||||
&venv_python.to_string_lossy(),
|
||||
&[
|
||||
"-c".to_string(),
|
||||
"import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('flask') else 1)"
|
||||
.to_string(),
|
||||
],
|
||||
Some(tool_root),
|
||||
)?;
|
||||
if check.0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let requirements = tool_root.join("requirements.txt");
|
||||
run_command(
|
||||
&venv_python.to_string_lossy(),
|
||||
&[
|
||||
"-m".to_string(),
|
||||
"pip".to_string(),
|
||||
"install".to_string(),
|
||||
"-r".to_string(),
|
||||
requirements.to_string_lossy().to_string(),
|
||||
],
|
||||
Some(tool_root),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn venv_python_path(venv_root: &Path) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
venv_root.join("Scripts").join("python.exe")
|
||||
} else {
|
||||
venv_root.join("bin").join("python")
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_python_command() -> Result<PythonCommand, String> {
|
||||
let candidates: Vec<PythonCommand> = if cfg!(windows) {
|
||||
vec![
|
||||
PythonCommand {
|
||||
program: "py".to_string(),
|
||||
args: vec!["-3".to_string()],
|
||||
},
|
||||
PythonCommand {
|
||||
program: "python".to_string(),
|
||||
args: vec![],
|
||||
},
|
||||
PythonCommand {
|
||||
program: "python3".to_string(),
|
||||
args: vec![],
|
||||
},
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
PythonCommand {
|
||||
program: "python3".to_string(),
|
||||
args: vec![],
|
||||
},
|
||||
PythonCommand {
|
||||
program: "python".to_string(),
|
||||
args: vec![],
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
for candidate in candidates {
|
||||
let mut args = candidate.args.clone();
|
||||
args.push("--version".to_string());
|
||||
if run_command_allow_failure(&candidate.program, &args, None)
|
||||
.map(|(ok, _)| ok)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err("没有找到 Python 3。请先安装 Python 3,然后重新打开 EPUB 审校器。".to_string())
|
||||
}
|
||||
|
||||
fn read_expected_version(tool_root: &Path) -> Option<String> {
|
||||
let text = fs::read_to_string(tool_root.join("version.py")).ok()?;
|
||||
let marker = "version = \"";
|
||||
let start = text.find(marker)? + marker.len();
|
||||
let rest = &text[start..];
|
||||
Some(rest[..rest.find('"')?].to_string())
|
||||
}
|
||||
|
||||
fn find_running_editor(
|
||||
expected_version: Option<&str>,
|
||||
review_root: &Path,
|
||||
access_token: &str,
|
||||
) -> Option<String> {
|
||||
let expected_root = review_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| review_root.to_path_buf());
|
||||
for port in DEFAULT_PORT..(DEFAULT_PORT + PORT_SCAN_LIMIT) {
|
||||
let Some(payload) = request_json(port, "/api/version", access_token) else {
|
||||
continue;
|
||||
};
|
||||
let Some(version) = payload.get("version").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if expected_version.map_or(true, |expected| expected == version) {
|
||||
let running_root = payload
|
||||
.get("review_root")
|
||||
.and_then(Value::as_str)
|
||||
.map(PathBuf::from);
|
||||
let running_root_matches = running_root
|
||||
.as_ref()
|
||||
.and_then(|path| path.canonicalize().ok())
|
||||
.map(|path| path == expected_root)
|
||||
.unwrap_or(false);
|
||||
if !running_root_matches {
|
||||
continue;
|
||||
}
|
||||
return Some(format!("http://127.0.0.1:{port}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn request_json(port: u16, path: &str, access_token: &str) -> Option<Value> {
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(250)).ok()?;
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(800)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_millis(800)));
|
||||
let request = format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {access_token}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
stream.write_all(request.as_bytes()).ok()?;
|
||||
read_http_json_response(stream)
|
||||
}
|
||||
|
||||
fn post_json(url: &str, path: &str, body: &str, access_token: &str) -> Result<Value, String> {
|
||||
let port = parse_loopback_port(url)?;
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(3))
|
||||
.map_err(|err| format!("连接审校器失败:{err}"))?;
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
|
||||
let request = format!(
|
||||
"POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {access_token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.map_err(|err| format!("写入审校器请求失败:{err}"))?;
|
||||
read_http_json_response_result(stream)
|
||||
}
|
||||
|
||||
fn read_http_json_response(mut stream: TcpStream) -> Option<Value> {
|
||||
let mut response = String::new();
|
||||
stream.read_to_string(&mut response).ok()?;
|
||||
if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
|
||||
return None;
|
||||
}
|
||||
let body = response.split("\r\n\r\n").nth(1)?;
|
||||
serde_json::from_str(body).ok()
|
||||
}
|
||||
|
||||
fn read_http_json_response_result(mut stream: TcpStream) -> Result<Value, String> {
|
||||
let mut response = String::new();
|
||||
stream
|
||||
.read_to_string(&mut response)
|
||||
.map_err(|err| format!("读取审校器响应失败:{err}"))?;
|
||||
let status_line = response.lines().next().unwrap_or_default().to_string();
|
||||
let body = response.split("\r\n\r\n").nth(1).unwrap_or("");
|
||||
let payload = serde_json::from_str::<Value>(body).unwrap_or(Value::Null);
|
||||
if status_line.starts_with("HTTP/1.1 200") || status_line.starts_with("HTTP/1.0 200") {
|
||||
if payload.is_null() {
|
||||
return Err("审校器返回了空响应。".to_string());
|
||||
}
|
||||
return Ok(payload);
|
||||
}
|
||||
let message = payload
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(status_line.as_str());
|
||||
Err(message.to_string())
|
||||
}
|
||||
|
||||
fn parse_loopback_port(url: &str) -> Result<u16, String> {
|
||||
let rest = url
|
||||
.strip_prefix("http://127.0.0.1:")
|
||||
.or_else(|| url.strip_prefix("http://localhost:"))
|
||||
.ok_or_else(|| format!("审校器地址不是本机 HTTP 地址:{url}"))?;
|
||||
let port_text = rest.split('/').next().unwrap_or(rest);
|
||||
port_text
|
||||
.parse::<u16>()
|
||||
.map_err(|err| format!("无法解析审校器端口:{port_text} ({err})"))
|
||||
}
|
||||
|
||||
fn ensure_session_from_path(
|
||||
url: &str,
|
||||
epub_path: Option<&str>,
|
||||
access_token: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(raw_path) = epub_path.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let body = serde_json::json!({ "epub_path": raw_path, "activate": false }).to_string();
|
||||
let payload = post_json(url, "/api/session/from-path", &body, access_token)?;
|
||||
Ok(payload
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned))
|
||||
}
|
||||
|
||||
fn run_command(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<CommandOutput, String> {
|
||||
let (ok, output) = run_command_allow_failure(program, args, cwd)?;
|
||||
if ok {
|
||||
Ok(output)
|
||||
} else {
|
||||
Err(format!(
|
||||
"命令执行失败:{} {}\nstdout:\n{}\nstderr:\n{}",
|
||||
program,
|
||||
redact_command_args(args).join(" "),
|
||||
output.stdout,
|
||||
output.stderr
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_command_args(args: &[String]) -> Vec<String> {
|
||||
let mut redact_next = false;
|
||||
args.iter()
|
||||
.map(|arg| {
|
||||
if redact_next {
|
||||
redact_next = false;
|
||||
return "[REDACTED]".to_string();
|
||||
}
|
||||
if arg == "--access-token" {
|
||||
redact_next = true;
|
||||
}
|
||||
arg.clone()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_command_allow_failure(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(bool, CommandOutput), String> {
|
||||
let mut command = Command::new(program);
|
||||
command.args(args);
|
||||
if let Some(cwd) = cwd {
|
||||
command.current_dir(cwd);
|
||||
}
|
||||
command.stdin(std::process::Stdio::null());
|
||||
hide_command_window(&mut command);
|
||||
let output = command
|
||||
.output()
|
||||
.map_err(|err| format!("无法执行命令 {program}: {err}"))?;
|
||||
Ok((
|
||||
output.status.success(),
|
||||
CommandOutput {
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn hide_command_window(command: &mut Command) {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn hide_command_window(_command: &mut Command) {}
|
||||
|
||||
fn extract_url(text: &str) -> Option<String> {
|
||||
text.split_whitespace()
|
||||
.find(|part| part.starts_with("http://") || part.starts_with("https://"))
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
@@ -49,7 +49,11 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": [],
|
||||
"resources": {
|
||||
"../tools/epub-review-editor/server.py": "tools/epub-review-editor/server.py",
|
||||
"../tools/epub-review-editor/version.py": "tools/epub-review-editor/version.py",
|
||||
"../tools/epub-review-editor/requirements.txt": "tools/epub-review-editor/requirements.txt"
|
||||
},
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "embedBootstrapper"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"bundle": {
|
||||
"resources": {
|
||||
"../extensions/windows-thumbnail/target/windows_thumbnail.dll": "readest_thumbnail.dll"
|
||||
"../extensions/windows-thumbnail/target/windows_thumbnail.dll": "readest_thumbnail.dll",
|
||||
"../tools/epub-review-editor/server.py": "tools/epub-review-editor/server.py",
|
||||
"../tools/epub-review-editor/version.py": "tools/epub-review-editor/version.py",
|
||||
"../tools/epub-review-editor/requirements.txt": "tools/epub-review-editor/requirements.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('getBookContextMenuItemIds', () => {
|
||||
'markFinished',
|
||||
'markAbandoned',
|
||||
'showDetails',
|
||||
'bilingual',
|
||||
'showInFinder',
|
||||
'searchGoodreads',
|
||||
'upload',
|
||||
@@ -39,6 +40,7 @@ describe('getBookContextMenuItemIds', () => {
|
||||
'markAbandoned',
|
||||
'clearStatus',
|
||||
'showDetails',
|
||||
'bilingual',
|
||||
'showInFinder',
|
||||
'searchGoodreads',
|
||||
'upload',
|
||||
@@ -56,6 +58,7 @@ describe('getBookContextMenuItemIds', () => {
|
||||
'markAbandoned',
|
||||
'clearStatus',
|
||||
'showDetails',
|
||||
'bilingual',
|
||||
'showInFinder',
|
||||
'searchGoodreads',
|
||||
'upload',
|
||||
@@ -72,6 +75,7 @@ describe('getBookContextMenuItemIds', () => {
|
||||
'markFinished',
|
||||
'clearStatus',
|
||||
'showDetails',
|
||||
'bilingual',
|
||||
'showInFinder',
|
||||
'searchGoodreads',
|
||||
'upload',
|
||||
@@ -88,6 +92,7 @@ describe('getBookContextMenuItemIds', () => {
|
||||
'markFinished',
|
||||
'markAbandoned',
|
||||
'showDetails',
|
||||
'bilingual',
|
||||
'showInFinder',
|
||||
'searchGoodreads',
|
||||
'download',
|
||||
@@ -104,6 +109,7 @@ describe('getBookContextMenuItemIds', () => {
|
||||
'markFinished',
|
||||
'markAbandoned',
|
||||
'showDetails',
|
||||
'bilingual',
|
||||
'showInFinder',
|
||||
'searchGoodreads',
|
||||
'delete',
|
||||
|
||||
@@ -29,6 +29,18 @@ const renderDropdown = () =>
|
||||
</DropdownProvider>,
|
||||
);
|
||||
|
||||
const renderTwoDropdowns = () =>
|
||||
render(
|
||||
<DropdownProvider>
|
||||
<Dropdown label='First Menu' toggleButton={<span>First</span>} showTooltip={false}>
|
||||
<div>First content</div>
|
||||
</Dropdown>
|
||||
<Dropdown label='Second Menu' toggleButton={<span>Second</span>} showTooltip={false}>
|
||||
<div>Second content</div>
|
||||
</Dropdown>
|
||||
</DropdownProvider>,
|
||||
);
|
||||
|
||||
describe('Dropdown keyboard activation', () => {
|
||||
it('opens when Enter is pressed on the toggle button', () => {
|
||||
renderDropdown();
|
||||
@@ -91,4 +103,29 @@ describe('Dropdown keyboard activation', () => {
|
||||
window.removeEventListener('keydown', onWindowKeyDown);
|
||||
}
|
||||
});
|
||||
|
||||
it('closes when the pointer is pressed outside', () => {
|
||||
renderDropdown();
|
||||
const toggle = screen.getByRole('button', { name: 'Test Menu' });
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('switches directly to another dropdown on the first click', () => {
|
||||
renderTwoDropdowns();
|
||||
const first = screen.getByRole('button', { name: 'First Menu' });
|
||||
const second = screen.getByRole('button', { name: 'Second Menu' });
|
||||
|
||||
fireEvent.click(first);
|
||||
expect(first.getAttribute('aria-expanded')).toBe('true');
|
||||
|
||||
fireEvent.pointerDown(second);
|
||||
fireEvent.click(second);
|
||||
expect(first.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(second.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/context/EnvContext', () => ({
|
||||
useEnv: () => ({ appService: null }),
|
||||
}));
|
||||
|
||||
import { __testing } from '@/app/reader/components/ReviewModeController';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
|
||||
describe('ReviewModeController marks', () => {
|
||||
test('marks bilingual source and target paragraphs by file and index', () => {
|
||||
const doc = document.implementation.createHTMLDocument('chapter');
|
||||
doc.body.innerHTML = [
|
||||
'<p class="sourceText">原文一</p>',
|
||||
'<p>译文一</p>',
|
||||
'<p class="sourceText">原文二</p>',
|
||||
'<p>译文二</p>',
|
||||
].join('');
|
||||
|
||||
__testing.applyReviewMarks(
|
||||
doc,
|
||||
'OEBPS/Text/chapter.xhtml',
|
||||
[
|
||||
{
|
||||
id: 'R00002',
|
||||
file: 'Text/chapter.xhtml',
|
||||
ja_p_index: 2,
|
||||
cn_p_index: 3,
|
||||
jp_html: '原文二',
|
||||
jp_text: '原文二',
|
||||
cn_html: '译文二',
|
||||
current_html: '<span>新译文二</span><script>alert(1)</script>',
|
||||
},
|
||||
],
|
||||
'R00002',
|
||||
);
|
||||
|
||||
const paragraphs = Array.from(doc.querySelectorAll('p'));
|
||||
expect(paragraphs[2]?.getAttribute('data-readest-review-row-id')).toBe('R00002');
|
||||
expect(paragraphs[2]?.classList.contains('readest-review-source')).toBe(true);
|
||||
expect(paragraphs[3]?.getAttribute('data-readest-review-row-id')).toBe('R00002');
|
||||
expect(paragraphs[3]?.classList.contains('readest-review-target')).toBe(true);
|
||||
expect(paragraphs[3]?.innerHTML).toBe('<span>新译文二</span>');
|
||||
});
|
||||
|
||||
test('uses section id when foliate sections do not expose href', () => {
|
||||
const bookDoc = {
|
||||
sections: [{ id: 'OEBPS/Text/chapter.xhtml' }],
|
||||
} as unknown as BookDoc;
|
||||
|
||||
expect(__testing.sectionHrefAt(bookDoc, 0)).toBe('OEBPS/Text/chapter.xhtml');
|
||||
});
|
||||
|
||||
test('clearReviewMarks restores original target HTML', () => {
|
||||
const doc = document.implementation.createHTMLDocument('chapter');
|
||||
doc.body.innerHTML = '<p class="sourceText">原文</p><p><ruby>旧<rt>old</rt></ruby>译文</p>';
|
||||
|
||||
__testing.applyReviewMarks(
|
||||
doc,
|
||||
'chapter.xhtml',
|
||||
[
|
||||
{
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
ja_p_index: 0,
|
||||
cn_p_index: 1,
|
||||
jp_html: '原文',
|
||||
jp_text: '原文',
|
||||
cn_html: '<ruby>旧<rt>old</rt></ruby>译文',
|
||||
current_html: '<span>新译文</span>',
|
||||
},
|
||||
],
|
||||
'',
|
||||
);
|
||||
|
||||
__testing.clearReviewMarks(doc);
|
||||
|
||||
const target = doc.querySelectorAll('p')[1];
|
||||
expect(target?.innerHTML).toBe('<ruby>旧<rt>old</rt></ruby>译文');
|
||||
expect(target?.hasAttribute('data-readest-review-row-id')).toBe(false);
|
||||
});
|
||||
|
||||
test('strips event handlers and dangerous links from target HTML', () => {
|
||||
const doc = document.implementation.createHTMLDocument('chapter');
|
||||
doc.body.innerHTML = '<p>原文</p><p>译文</p>';
|
||||
|
||||
__testing.applyReviewMarks(
|
||||
doc,
|
||||
'chapter.xhtml',
|
||||
[
|
||||
{
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
ja_p_index: 0,
|
||||
cn_p_index: 1,
|
||||
jp_html: '原文',
|
||||
jp_text: '原文',
|
||||
cn_html: '译文',
|
||||
current_html: '<span onclick="alert(1)"><a href="javascript:alert(1)">新译文</a></span>',
|
||||
},
|
||||
],
|
||||
'R00001',
|
||||
);
|
||||
|
||||
const target = doc.querySelectorAll('p')[1];
|
||||
expect(target?.innerHTML).toBe('<span>新译文</span>');
|
||||
});
|
||||
|
||||
test('updates active classes without rewriting translated paragraph HTML', () => {
|
||||
const doc = document.implementation.createHTMLDocument('chapter');
|
||||
doc.body.innerHTML = '<p>原文一</p><p>译文一</p><p>原文二</p><p>译文二</p>';
|
||||
|
||||
__testing.applyReviewMarks(
|
||||
doc,
|
||||
'chapter.xhtml',
|
||||
[
|
||||
{
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
ja_p_index: 0,
|
||||
cn_p_index: 1,
|
||||
jp_html: '原文一',
|
||||
jp_text: '原文一',
|
||||
cn_html: '译文一',
|
||||
current_html: '<span>新译文一</span>',
|
||||
},
|
||||
{
|
||||
id: 'R00002',
|
||||
file: 'chapter.xhtml',
|
||||
ja_p_index: 2,
|
||||
cn_p_index: 3,
|
||||
jp_html: '原文二',
|
||||
jp_text: '原文二',
|
||||
cn_html: '译文二',
|
||||
current_html: '<span>新译文二</span>',
|
||||
},
|
||||
],
|
||||
'R00001',
|
||||
);
|
||||
|
||||
const target = doc.querySelectorAll('p')[1]!;
|
||||
target.dataset['localState'] = 'kept';
|
||||
|
||||
__testing.applyActiveReviewMark(doc, 'R00002');
|
||||
|
||||
expect(target.innerHTML).toBe('<span>新译文一</span>');
|
||||
expect(target.dataset['localState']).toBe('kept');
|
||||
expect(target.classList.contains('readest-review-active')).toBe(false);
|
||||
expect(doc.querySelectorAll('p')[3]?.classList.contains('readest-review-active')).toBe(true);
|
||||
});
|
||||
|
||||
test('clears the target paragraph when a saved translation is empty', () => {
|
||||
const doc = document.implementation.createHTMLDocument('chapter');
|
||||
doc.body.innerHTML = '<p>原文</p><p>旧译文</p>';
|
||||
|
||||
__testing.applyReviewMarks(
|
||||
doc,
|
||||
'chapter.xhtml',
|
||||
[
|
||||
{
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
ja_p_index: 0,
|
||||
cn_p_index: 1,
|
||||
jp_html: '原文',
|
||||
jp_text: '原文',
|
||||
cn_html: '旧译文',
|
||||
current_html: '',
|
||||
},
|
||||
],
|
||||
'R00001',
|
||||
);
|
||||
|
||||
expect(doc.querySelectorAll('p')[1]?.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
ask: vi.fn(),
|
||||
setBookEnabled: vi.fn(),
|
||||
reviewStore: {
|
||||
books: {
|
||||
'book-a': { enabled: true, loading: false, hasUnsavedEdit: true },
|
||||
},
|
||||
setActiveBookKey: vi.fn(),
|
||||
setPanelVisible: vi.fn(),
|
||||
setBookLoading: vi.fn(),
|
||||
setBookError: vi.fn(),
|
||||
setBookEnabled: vi.fn(),
|
||||
setBookData: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/context/EnvContext', () => ({
|
||||
useEnv: () => ({ appService: { isDesktopApp: true, isMobile: false, ask: h.ask } }),
|
||||
}));
|
||||
vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (value: string) => value }));
|
||||
vi.mock('@/store/bookDataStore', () => ({
|
||||
useBookDataStore: (selector: (state: { getBookData: () => unknown }) => unknown) =>
|
||||
selector({ getBookData: () => ({ book: { format: 'EPUB' } }) }),
|
||||
}));
|
||||
vi.mock('@/store/readerStore', () => ({
|
||||
useReaderStore: () => ({ setHoveredBookKey: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/store/reviewModeStore', () => {
|
||||
const useReviewModeStore = (selector?: (state: typeof h.reviewStore) => unknown) =>
|
||||
selector ? selector(h.reviewStore) : h.reviewStore;
|
||||
Object.assign(useReviewModeStore, { getState: () => h.reviewStore });
|
||||
return { useReviewModeStore };
|
||||
});
|
||||
vi.mock('@/services/reviewEditorService', () => ({
|
||||
canLaunchInlineReviewEditor: () => true,
|
||||
launchInlineReviewEditor: vi.fn(),
|
||||
loadInlineReviewData: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/utils/event', () => ({ eventDispatcher: { dispatch: vi.fn() } }));
|
||||
|
||||
import ReviewModeToggler from '@/app/reader/components/ReviewModeToggler';
|
||||
|
||||
describe('ReviewModeToggler unsaved edit guard', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
h.reviewStore.setBookEnabled = h.setBookEnabled;
|
||||
});
|
||||
|
||||
test('keeps review mode enabled when discarding an unsaved edit is cancelled', async () => {
|
||||
h.ask.mockResolvedValue(false);
|
||||
render(<ReviewModeToggler bookKey='book-a' />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disable Review Mode' }));
|
||||
|
||||
await waitFor(() => expect(h.ask).toHaveBeenCalledOnce());
|
||||
expect(h.setBookEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('disables review mode after confirming the unsaved edit warning', async () => {
|
||||
h.ask.mockResolvedValue(true);
|
||||
render(<ReviewModeToggler bookKey='book-a' />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disable Review Mode' }));
|
||||
|
||||
await waitFor(() => expect(h.setBookEnabled).toHaveBeenCalledWith('book-a', false));
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ describe('middleware cross-origin isolation headers', () => {
|
||||
it('keeps the stricter require-corp on every other document route', () => {
|
||||
expect(coep('/')).toBe('require-corp');
|
||||
expect(coep('/library')).toBe('require-corp');
|
||||
expect(coep('/reader')).toBe('require-corp');
|
||||
// Must not be caught by a naive startsWith('/s').
|
||||
expect(coep('/settings')).toBe('require-corp');
|
||||
expect(coep('/search')).toBe('require-corp');
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import type { ReviewRow } from '@/services/reviewEditorService';
|
||||
|
||||
import {
|
||||
canLaunchInlineReviewEditor,
|
||||
createReviewSessionFromPath,
|
||||
downloadReviewedEpub,
|
||||
exportReviewedEpub,
|
||||
generateReviewFeedback,
|
||||
isReviewEditDirty,
|
||||
launchInlineReviewEditor,
|
||||
loadInlineReviewData,
|
||||
loadReviewGlossary,
|
||||
saveReviewGptConfig,
|
||||
retranslateReviewRow,
|
||||
saveReviewRow,
|
||||
saveReviewGlossary,
|
||||
sidecarApi,
|
||||
switchReviewGlossaryPath,
|
||||
} from '@/services/reviewEditorService';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const stubJsonFetch = () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
Response.json({
|
||||
status: 'ok',
|
||||
updated_at: '2026-07-09T00:00:00Z',
|
||||
current_html: '<span>净化译文</span>',
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
};
|
||||
|
||||
const firstFetchUrl = (fetchMock: ReturnType<typeof vi.fn>) => {
|
||||
const input = fetchMock.mock.calls[0]?.[0];
|
||||
if (input instanceof URL) return input.toString();
|
||||
if (typeof input === 'string') return input;
|
||||
return input?.toString() || '';
|
||||
};
|
||||
|
||||
describe('reviewEditorService', () => {
|
||||
test('allows the inline review entry on desktop and local dev-web, but not hosted web', () => {
|
||||
expect(canLaunchInlineReviewEditor({ isDesktopApp: true }, false)).toBe(true);
|
||||
expect(canLaunchInlineReviewEditor({ isDesktopApp: false }, true)).toBe(true);
|
||||
expect(canLaunchInlineReviewEditor({ isDesktopApp: false }, false)).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a launch response when the Next route returns an HTTP error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
Response.json(
|
||||
{ ok: false, error: '本地 Web 审校入口未启用' },
|
||||
{ status: 404 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
launchInlineReviewEditor(
|
||||
{
|
||||
resolveNativeBookFilePath: async () => 'C:/books/demo.epub',
|
||||
} as never,
|
||||
{ format: 'EPUB' } as never,
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow('本地 Web 审校入口未启用');
|
||||
});
|
||||
|
||||
test('uploads the current web book to the local sidecar before creating a session', async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/review-editor/launch') {
|
||||
return Response.json({
|
||||
ok: true,
|
||||
url: 'http://127.0.0.1:5177',
|
||||
accessToken: 'sidecar-token',
|
||||
});
|
||||
}
|
||||
return Response.json({ id: 'session-web' });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const launched = await launchInlineReviewEditor(
|
||||
{
|
||||
resolveNativeBookFilePath: async () => null,
|
||||
loadBookContent: async () => ({
|
||||
file: new File(['epub-bytes'], 'demo.epub', { type: 'application/epub+zip' }),
|
||||
}),
|
||||
} as never,
|
||||
{ format: 'EPUB', title: 'Demo book', hash: 'book-hash' } as never,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(launched.sessionId).toBe('session-web');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const uploadCall = fetchMock.mock.calls[1] as
|
||||
| [string | URL | Request, RequestInit | undefined]
|
||||
| undefined;
|
||||
expect(uploadCall?.[0]).toBe('http://127.0.0.1:5177/api/upload');
|
||||
expect(uploadCall?.[1]?.body).toBeInstanceOf(FormData);
|
||||
expect(uploadCall?.[1]?.headers).toMatchObject({
|
||||
Authorization: 'Bearer sidecar-token',
|
||||
});
|
||||
expect((uploadCall?.[1]?.body as FormData).get('activate')).toBe('false');
|
||||
expect((uploadCall?.[1]?.body as FormData).get('book_key')).toBe('book-hash');
|
||||
});
|
||||
|
||||
test('downloads an exported EPUB in web mode with its server filename', async () => {
|
||||
const click = vi.fn();
|
||||
const remove = vi.fn();
|
||||
const anchor = { click, remove, href: '', download: '' };
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(anchor as never);
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
|
||||
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(new Blob(['epub-bytes'], { type: 'application/epub+zip' })),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:reviewed-epub');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||
|
||||
await downloadReviewedEpub(
|
||||
'http://127.0.0.1:5177/downloads/export/reviewed.epub?session_id=session-a',
|
||||
'sidecar-token',
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/downloads/export/reviewed.epub'),
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer sidecar-token' },
|
||||
}),
|
||||
);
|
||||
expect(anchor.href).toBe('blob:reviewed-epub');
|
||||
expect(anchor.download).toBe('reviewed.epub');
|
||||
expect(click).toHaveBeenCalledOnce();
|
||||
expect(remove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('detects unsaved row fields against the selected row baseline', () => {
|
||||
const row = {
|
||||
current_html: '译文',
|
||||
cn_html: '译文',
|
||||
marked: false,
|
||||
issue_type: '',
|
||||
severity: '',
|
||||
tags: '',
|
||||
comment: '',
|
||||
learn_note: '',
|
||||
} as ReviewRow;
|
||||
|
||||
expect(
|
||||
isReviewEditDirty(row, {
|
||||
current_html: '译文',
|
||||
marked: false,
|
||||
issue_type: '',
|
||||
severity: '',
|
||||
tags: '',
|
||||
comment: '',
|
||||
learn_note: '',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReviewEditDirty(row, {
|
||||
current_html: '修改后译文',
|
||||
marked: false,
|
||||
issue_type: '',
|
||||
severity: '',
|
||||
tags: '',
|
||||
comment: '',
|
||||
learn_note: '',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReviewEditDirty(
|
||||
{ ...row, current_html: '' },
|
||||
{
|
||||
current_html: '',
|
||||
marked: false,
|
||||
issue_type: '',
|
||||
severity: '',
|
||||
tags: '',
|
||||
comment: '',
|
||||
learn_note: '',
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('adds session_id to sidecar API calls without mutating endpoint paths', async () => {
|
||||
const fetchMock = stubJsonFetch();
|
||||
|
||||
await sidecarApi(
|
||||
'http://127.0.0.1:5177',
|
||||
'/api/rows',
|
||||
{},
|
||||
'session-a',
|
||||
'sidecar-token',
|
||||
);
|
||||
|
||||
const url = new URL(firstFetchUrl(fetchMock));
|
||||
expect(url.pathname).toBe('/api/rows');
|
||||
expect(url.searchParams.get('session_id')).toBe('session-a');
|
||||
const firstCall = fetchMock.mock.calls[0] as
|
||||
| [string | URL | Request, RequestInit | undefined]
|
||||
| undefined;
|
||||
expect(firstCall?.[1]?.headers).toMatchObject({
|
||||
Authorization: 'Bearer sidecar-token',
|
||||
});
|
||||
});
|
||||
|
||||
test('row operations are scoped to the current review session', async () => {
|
||||
const fetchMock = stubJsonFetch();
|
||||
|
||||
await saveReviewRow('http://127.0.0.1:5177', 'session-a', 'R00001', {
|
||||
current_html: '<span>译文</span>',
|
||||
marked: true,
|
||||
issue_type: '误译',
|
||||
severity: '严重',
|
||||
tags: '术语',
|
||||
comment: '备注',
|
||||
learn_note: '长期规则',
|
||||
});
|
||||
await retranslateReviewRow('http://127.0.0.1:5177', 'session-a', 'R00001', '');
|
||||
await generateReviewFeedback('http://127.0.0.1:5177', 'session-a');
|
||||
await exportReviewedEpub('http://127.0.0.1:5177', 'session-a');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
for (const call of fetchMock.mock.calls as unknown as [
|
||||
string | URL,
|
||||
RequestInit | undefined,
|
||||
][]) {
|
||||
const input = call[0];
|
||||
const url = new URL(input instanceof URL ? input.toString() : input);
|
||||
expect(url.searchParams.get('session_id')).toBe('session-a');
|
||||
}
|
||||
});
|
||||
|
||||
test('inline launch creates sessions without activating sidecar global session', async () => {
|
||||
const fetchMock = stubJsonFetch();
|
||||
|
||||
await createReviewSessionFromPath('http://127.0.0.1:5177', 'C:/books/demo.epub');
|
||||
|
||||
const calls = fetchMock.mock.calls as unknown as [string | URL, RequestInit][];
|
||||
const body = JSON.parse(String(calls[0]?.[1]?.body || '{}'));
|
||||
expect(body).toMatchObject({
|
||||
epub_path: 'C:/books/demo.epub',
|
||||
activate: false,
|
||||
reset: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('inline bootstrap and config operations are scoped to the current review session', async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
Response.json({
|
||||
has_session: true,
|
||||
rows: [],
|
||||
entries: [],
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await loadInlineReviewData('http://127.0.0.1:5177', 'session-a');
|
||||
await saveReviewGptConfig('http://127.0.0.1:5177', 'session-a', {
|
||||
base_url: 'https://api.example.test/v1',
|
||||
model: 'model-a',
|
||||
});
|
||||
await loadReviewGlossary('http://127.0.0.1:5177', 'session-a');
|
||||
await switchReviewGlossaryPath('http://127.0.0.1:5177', 'session-a', 'mingcibiao.json');
|
||||
await saveReviewGlossary('http://127.0.0.1:5177', 'session-a', 'mingcibiao.json', []);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(7);
|
||||
for (const call of fetchMock.mock.calls as unknown as [
|
||||
string | URL,
|
||||
RequestInit | undefined,
|
||||
][]) {
|
||||
const input = call[0];
|
||||
const url = new URL(input instanceof URL ? input.toString() : input);
|
||||
expect(url.searchParams.get('session_id')).toBe('session-a');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { useReviewModeStore } from '@/store/reviewModeStore';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useReviewModeStore.setState({
|
||||
activeBookKey: null,
|
||||
isPanelVisible: false,
|
||||
isPanelPinned: false,
|
||||
panelWidth: '32%',
|
||||
panelHeight: '68vh',
|
||||
books: {},
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewModeStore', () => {
|
||||
test('enables review mode for a book and opens the panel', () => {
|
||||
useReviewModeStore.getState().setBookEnabled('book-a', true);
|
||||
|
||||
const state = useReviewModeStore.getState();
|
||||
expect(state.activeBookKey).toBe('book-a');
|
||||
expect(state.isPanelVisible).toBe(true);
|
||||
expect(state.books['book-a']?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('selects rows without replacing loaded review data', () => {
|
||||
useReviewModeStore.getState().setBookData('book-a', {
|
||||
baseUrl: 'http://127.0.0.1:5177',
|
||||
sessionId: 'session-a',
|
||||
rows: [
|
||||
{
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
jp_html: '原文',
|
||||
jp_text: '原文',
|
||||
cn_html: '译文',
|
||||
current_html: '译文',
|
||||
},
|
||||
],
|
||||
});
|
||||
useReviewModeStore.getState().selectRow('book-a', 'R00001');
|
||||
|
||||
const state = useReviewModeStore.getState();
|
||||
expect(state.activeBookKey).toBe('book-a');
|
||||
expect(state.isPanelVisible).toBe(true);
|
||||
expect(state.books['book-a']?.baseUrl).toBe('http://127.0.0.1:5177');
|
||||
expect(state.books['book-a']?.selectedRowId).toBe('R00001');
|
||||
});
|
||||
|
||||
test('reopens the panel for the selected row without clearing its unsaved flag', () => {
|
||||
useReviewModeStore.getState().setBookEnabled('book-a', true);
|
||||
useReviewModeStore.getState().selectRow('book-a', 'R00001');
|
||||
useReviewModeStore.getState().setBookUnsavedEdit('book-a', true);
|
||||
useReviewModeStore.getState().setPanelVisible(false);
|
||||
|
||||
useReviewModeStore.getState().selectRow('book-a', 'R00001');
|
||||
|
||||
const state = useReviewModeStore.getState();
|
||||
expect(state.isPanelVisible).toBe(true);
|
||||
expect(state.books['book-a']?.hasUnsavedEdit).toBe(true);
|
||||
});
|
||||
|
||||
test('updates one review row after save', () => {
|
||||
useReviewModeStore.getState().setBookData('book-a', {
|
||||
rows: [
|
||||
{
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
jp_html: '原文',
|
||||
jp_text: '原文',
|
||||
cn_html: '旧译文',
|
||||
current_html: '旧译文',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useReviewModeStore.getState().updateRow('book-a', {
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
jp_html: '原文',
|
||||
jp_text: '原文',
|
||||
cn_html: '旧译文',
|
||||
current_html: '新译文',
|
||||
edited: true,
|
||||
});
|
||||
|
||||
expect(useReviewModeStore.getState().books['book-a']?.rows[0]?.current_html).toBe('新译文');
|
||||
expect(useReviewModeStore.getState().books['book-a']?.rows[0]?.edited).toBe(true);
|
||||
});
|
||||
|
||||
test('disabling the active book closes the panel', () => {
|
||||
useReviewModeStore.getState().setBookEnabled('book-a', true);
|
||||
useReviewModeStore.getState().setBookEnabled('book-a', false);
|
||||
|
||||
const state = useReviewModeStore.getState();
|
||||
expect(state.activeBookKey).toBeNull();
|
||||
expect(state.isPanelVisible).toBe(false);
|
||||
expect(state.books['book-a']?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test('clears an active book and closes the panel', () => {
|
||||
useReviewModeStore.getState().setBookEnabled('book-a', true);
|
||||
useReviewModeStore.getState().setBookData('book-a', {
|
||||
baseUrl: 'http://127.0.0.1:5177',
|
||||
sessionId: 'session-a',
|
||||
});
|
||||
|
||||
useReviewModeStore.getState().clearBook('book-a');
|
||||
|
||||
const state = useReviewModeStore.getState();
|
||||
expect(state.books['book-a']).toBeUndefined();
|
||||
expect(state.activeBookKey).toBeNull();
|
||||
expect(state.isPanelVisible).toBe(false);
|
||||
});
|
||||
|
||||
test('persists only panel layout preferences', () => {
|
||||
useReviewModeStore.getState().setPanelWidth('41%');
|
||||
useReviewModeStore.getState().setPanelHeight('77vh');
|
||||
useReviewModeStore.getState().togglePanelPin();
|
||||
useReviewModeStore.getState().setBookData('book-a', {
|
||||
baseUrl: 'http://127.0.0.1:5177',
|
||||
sessionId: 'session-a',
|
||||
rows: [
|
||||
{
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
jp_html: '原文',
|
||||
jp_text: '原文',
|
||||
cn_html: '译文',
|
||||
current_html: '译文',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem('readest-review-panel-layout') || '{}');
|
||||
|
||||
expect(stored.state).toEqual({
|
||||
isPanelPinned: true,
|
||||
panelWidth: '41%',
|
||||
panelHeight: '77vh',
|
||||
});
|
||||
expect(stored.state.books).toBeUndefined();
|
||||
expect(stored.state.activeBookKey).toBeUndefined();
|
||||
});
|
||||
|
||||
test('ignores invalid persisted layout values during hydration', async () => {
|
||||
useReviewModeStore.setState({
|
||||
activeBookKey: null,
|
||||
isPanelVisible: false,
|
||||
isPanelPinned: false,
|
||||
panelWidth: '32%',
|
||||
panelHeight: '68vh',
|
||||
books: {},
|
||||
});
|
||||
|
||||
useReviewModeStore.persist.clearStorage();
|
||||
localStorage.setItem(
|
||||
'readest-review-panel-layout',
|
||||
JSON.stringify({
|
||||
state: {
|
||||
isPanelPinned: true,
|
||||
panelWidth: '999%',
|
||||
panelHeight: 'not-a-height',
|
||||
books: {
|
||||
stale: { enabled: true },
|
||||
},
|
||||
},
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
await useReviewModeStore.persist.rehydrate();
|
||||
|
||||
const state = useReviewModeStore.getState();
|
||||
expect(state.isPanelPinned).toBe(true);
|
||||
expect(state.panelWidth).toBe('48%');
|
||||
expect(state.panelHeight).toBe('68vh');
|
||||
expect(state.books).toEqual({});
|
||||
expect(state.activeBookKey).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const DEFAULT_PORT = 5177;
|
||||
const PORT_SCAN_LIMIT = 100;
|
||||
const launchState = {
|
||||
promise: null as Promise<LaunchResponse> | null,
|
||||
};
|
||||
|
||||
type CommandResult = {
|
||||
ok: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
status: number | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type LaunchResponse = {
|
||||
ok: boolean;
|
||||
url?: string;
|
||||
reused?: boolean;
|
||||
reviewRoot?: string;
|
||||
version?: string;
|
||||
accessToken?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type PythonCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
const appRoot = () => process.cwd();
|
||||
const toolRoot = () => path.join(appRoot(), 'tools', 'epub-review-editor');
|
||||
const reviewRoot = () =>
|
||||
path.resolve(process.env['READEST_REVIEW_ROOT'] || path.join(appRoot(), 'epub_review_sessions'));
|
||||
const accessTokenPath = () => path.join(reviewRoot(), '.access-token');
|
||||
|
||||
const ensureAccessToken = () => {
|
||||
fs.mkdirSync(reviewRoot(), { recursive: true });
|
||||
try {
|
||||
const existing = fs.readFileSync(accessTokenPath(), 'utf8').trim();
|
||||
if (existing) return existing;
|
||||
} catch {
|
||||
// Create the token below.
|
||||
}
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
try {
|
||||
fs.writeFileSync(accessTokenPath(), token, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
||||
return token;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
const existing = fs.readFileSync(accessTokenPath(), 'utf8').trim();
|
||||
if (existing) return existing;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const venvRoot = () => path.join(toolRoot(), '.venv');
|
||||
const venvPython = () =>
|
||||
process.platform === 'win32'
|
||||
? path.join(venvRoot(), 'Scripts', 'python.exe')
|
||||
: path.join(venvRoot(), 'bin', 'python');
|
||||
|
||||
const run = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd?: string; timeout?: number } = {},
|
||||
): CommandResult => {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: options.timeout ?? 30_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
return {
|
||||
ok: result.status === 0 && !result.error,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
status: result.status,
|
||||
error: result.error?.message,
|
||||
};
|
||||
};
|
||||
|
||||
const readExpectedVersion = () => {
|
||||
try {
|
||||
const versionText = fs.readFileSync(path.join(toolRoot(), 'version.py'), 'utf8');
|
||||
return /version\s*=\s*"([^"]+)"/.exec(versionText)?.[1] || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const isLaunchEnabled = () =>
|
||||
process.env['NODE_ENV'] === 'development' &&
|
||||
process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web' &&
|
||||
process.env['READEST_REVIEW_EDITOR_LAUNCH'] !== '0';
|
||||
|
||||
const isLoopbackHost = (host: string | null) => {
|
||||
const normalized = (host || '').toLowerCase();
|
||||
return (
|
||||
normalized.startsWith('localhost') ||
|
||||
normalized.startsWith('127.0.0.1') ||
|
||||
normalized.startsWith('[::1]')
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeLoopbackUrl = (url: string) => url.replace('http://localhost:', 'http://127.0.0.1:');
|
||||
|
||||
const requestJson = (
|
||||
port: number,
|
||||
pathname: string,
|
||||
accessToken: string,
|
||||
): Promise<Record<string, unknown> | null> =>
|
||||
new Promise((resolve) => {
|
||||
const req = http.get(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: pathname,
|
||||
timeout: 800,
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(body) as Record<string, unknown>);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
req.on('error', () => resolve(null));
|
||||
});
|
||||
|
||||
const findRunningEditor = async (accessToken: string) => {
|
||||
const expectedVersion = readExpectedVersion();
|
||||
const expectedReviewRoot = path.resolve(reviewRoot());
|
||||
for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SCAN_LIMIT; port++) {
|
||||
const payload = await requestJson(port, '/api/version', accessToken);
|
||||
if (!payload) continue;
|
||||
const runningReviewRoot =
|
||||
typeof payload['review_root'] === 'string' ? path.resolve(payload['review_root']) : '';
|
||||
if (
|
||||
(!expectedVersion || payload['version'] === expectedVersion) &&
|
||||
runningReviewRoot === expectedReviewRoot
|
||||
) {
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const resolvePythonCommand = (): PythonCommand => {
|
||||
const candidates: PythonCommand[] =
|
||||
process.platform === 'win32'
|
||||
? [
|
||||
{ command: 'py', args: ['-3'] },
|
||||
{ command: 'python', args: [] },
|
||||
{ command: 'python3', args: [] },
|
||||
]
|
||||
: [
|
||||
{ command: 'python3', args: [] },
|
||||
{ command: 'python', args: [] },
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const versionCheck = run(candidate.command, [...candidate.args, '--version'], {
|
||||
timeout: 10_000,
|
||||
});
|
||||
if (versionCheck.ok) return candidate;
|
||||
}
|
||||
throw new Error('没有找到 Python 3。请先安装 Python 3,然后重新打开审校器。');
|
||||
};
|
||||
|
||||
const ensureVenv = () => {
|
||||
const pythonPath = venvPython();
|
||||
if (fs.existsSync(pythonPath)) return;
|
||||
fs.mkdirSync(toolRoot(), { recursive: true });
|
||||
const python = resolvePythonCommand();
|
||||
const result = run(python.command, [...python.args, '-m', 'venv', venvRoot()], {
|
||||
cwd: appRoot(),
|
||||
timeout: 120_000,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(`创建 Python 环境失败:${result.stderr || result.error || result.stdout}`);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureDependencies = () => {
|
||||
const pythonPath = venvPython();
|
||||
const flaskCheck = run(
|
||||
pythonPath,
|
||||
['-c', 'import importlib.util, sys; sys.exit(0 if importlib.util.find_spec("flask") else 1)'],
|
||||
{ cwd: toolRoot(), timeout: 30_000 },
|
||||
);
|
||||
if (flaskCheck.ok) return;
|
||||
|
||||
const install = run(
|
||||
pythonPath,
|
||||
['-m', 'pip', 'install', '-r', path.join(toolRoot(), 'requirements.txt')],
|
||||
{ cwd: toolRoot(), timeout: 180_000 },
|
||||
);
|
||||
if (!install.ok) {
|
||||
throw new Error(`安装审校器依赖失败:${install.stderr || install.error || install.stdout}`);
|
||||
}
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isLaunchEnabled()) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: 'EPUB 审校器的 Next 启动入口仅在本地 dev-web 开发环境启用。',
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
if (
|
||||
request.headers.get('x-readest-review-editor-launch') !== '1' ||
|
||||
!isLoopbackHost(request.headers.get('host'))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: 'EPUB 审校器启动请求必须来自本机 Readest 开发页面。',
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (launchState.promise) {
|
||||
const data = await launchState.promise;
|
||||
return NextResponse.json(data, { status: data.ok ? 200 : 500 });
|
||||
}
|
||||
|
||||
launchState.promise = launchEditor();
|
||||
const data = await launchState.promise;
|
||||
launchState.promise = null;
|
||||
return NextResponse.json(data, { status: data.ok ? 200 : 500 });
|
||||
}
|
||||
|
||||
async function launchEditor(): Promise<LaunchResponse> {
|
||||
const bundledToolRoot = toolRoot();
|
||||
const serverPath = path.join(bundledToolRoot, 'server.py');
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `没有找到已迁移的审校器后端:${serverPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
const accessToken = ensureAccessToken();
|
||||
const runningUrl = await findRunningEditor(accessToken);
|
||||
if (runningUrl) {
|
||||
return {
|
||||
ok: true,
|
||||
url: runningUrl,
|
||||
reused: true,
|
||||
reviewRoot: reviewRoot(),
|
||||
version: readExpectedVersion(),
|
||||
accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
ensureVenv();
|
||||
ensureDependencies();
|
||||
fs.mkdirSync(reviewRoot(), { recursive: true });
|
||||
|
||||
const launch = run(
|
||||
venvPython(),
|
||||
[
|
||||
serverPath,
|
||||
'--review-root',
|
||||
reviewRoot(),
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
String(DEFAULT_PORT),
|
||||
'--access-token',
|
||||
accessToken,
|
||||
'--daemon',
|
||||
],
|
||||
{ cwd: bundledToolRoot, timeout: 45_000 },
|
||||
);
|
||||
if (!launch.ok) {
|
||||
throw new Error(`启动审校器失败:${launch.stderr || launch.error || launch.stdout}`);
|
||||
}
|
||||
|
||||
const launchedUrl = normalizeLoopbackUrl(
|
||||
/https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor(accessToken)),
|
||||
);
|
||||
if (!launchedUrl) {
|
||||
throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
url: launchedUrl,
|
||||
reused: false,
|
||||
reviewRoot: reviewRoot(),
|
||||
version: readExpectedVersion(),
|
||||
accessToken,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
reviewRoot: reviewRoot(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -129,8 +129,16 @@ const devHmrPatchScript = `(${patchTauriHmrWebSocket.toString()})(${JSON.stringi
|
||||
// fallback HTML and crash with `Unexpected token '<'`. All runtime-config
|
||||
// consumers fall back to `NEXT_PUBLIC_*` envs baked at build time on Tauri.
|
||||
const shouldInjectRuntimeConfig = process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
|
||||
const shouldUseViewTransitions =
|
||||
process.env['NODE_ENV'] !== 'development' || process.env['NEXT_PUBLIC_APP_PLATFORM'] !== 'web';
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const app = (
|
||||
<EnvProvider>
|
||||
<Providers>{children}</Providers>
|
||||
</EnvProvider>
|
||||
);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang='en'
|
||||
@@ -144,13 +152,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<script dangerouslySetInnerHTML={{ __html: devHmrPatchScript }} />
|
||||
) : null}
|
||||
</head>
|
||||
<body>
|
||||
<ViewTransitions>
|
||||
<EnvProvider>
|
||||
<Providers>{children}</Providers>
|
||||
</EnvProvider>
|
||||
</ViewTransitions>
|
||||
</body>
|
||||
<body>{shouldUseViewTransitions ? <ViewTransitions>{app}</ViewTransitions> : app}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { LuLanguages } from 'react-icons/lu';
|
||||
import { PiX } from 'react-icons/pi';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import type { BilingualFilterMode, BilingualFilterOptions } from '@/services/bilingualEpubFilter';
|
||||
|
||||
interface BilingualFilterAlertProps {
|
||||
bookTitle: string;
|
||||
safeAreaBottom: number;
|
||||
processing: boolean;
|
||||
onCancel: () => void;
|
||||
onFilter: (options: BilingualFilterOptions) => void;
|
||||
}
|
||||
|
||||
const BilingualFilterAlert: React.FC<BilingualFilterAlertProps> = ({
|
||||
bookTitle,
|
||||
safeAreaBottom,
|
||||
processing,
|
||||
onCancel,
|
||||
onFilter,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const divRef = useKeyDownActions({ onCancel });
|
||||
const [mode, setMode] = useState<BilingualFilterMode>('auto');
|
||||
const [removeUnknown, setRemoveUnknown] = useState(false);
|
||||
|
||||
const handleFilter = (removeLanguage: BilingualFilterOptions['removeLanguage']) => {
|
||||
if (processing) return;
|
||||
onFilter({ removeLanguage, mode, removeUnknown });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={divRef}
|
||||
className='fixed bottom-0 left-0 right-0 z-50 flex justify-center px-3'
|
||||
style={{ paddingBottom: `${safeAreaBottom + 16}px` }}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'border-base-content/10 bg-base-200/95 flex w-full max-w-xl flex-col gap-4 rounded-2xl border p-4 shadow-lg backdrop-blur-sm',
|
||||
processing && 'pointer-events-none opacity-80',
|
||||
)}
|
||||
>
|
||||
<div className='relative flex min-w-0 items-center justify-center gap-2 pr-8'>
|
||||
<LuLanguages className='size-5 shrink-0' />
|
||||
<div className='min-w-0 truncate text-center text-sm font-medium'>
|
||||
{_('Bilingual EPUB')}: {bookTitle}
|
||||
</div>
|
||||
<button
|
||||
className={clsx(
|
||||
'absolute right-0 flex items-center justify-center rounded-full p-1.5',
|
||||
'text-base-content/70 transition-colors hover:text-base-content',
|
||||
)}
|
||||
onClick={onCancel}
|
||||
aria-label={_('Cancel')}
|
||||
disabled={processing}
|
||||
>
|
||||
<PiX className='size-5' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center'>
|
||||
<label className='flex min-w-0 items-center gap-2'>
|
||||
<span className='text-neutral-content shrink-0 text-xs'>{_('Detection')}</span>
|
||||
<select
|
||||
className='select select-bordered select-sm min-w-0 flex-1'
|
||||
value={mode}
|
||||
disabled={processing}
|
||||
onChange={(event) => setMode(event.target.value as BilingualFilterMode)}
|
||||
>
|
||||
<option value='auto'>{_('Auto')}</option>
|
||||
<option value='style'>{_('Style')}</option>
|
||||
<option value='script'>{_('Script')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className='flex cursor-pointer items-center gap-2 justify-self-start sm:justify-self-end'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='checkbox checkbox-sm'
|
||||
checked={removeUnknown}
|
||||
disabled={processing}
|
||||
onChange={(event) => setRemoveUnknown(event.target.checked)}
|
||||
/>
|
||||
<span className='text-sm'>{_('Remove uncertain text')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
|
||||
<button
|
||||
className='btn btn-primary btn-sm'
|
||||
disabled={processing}
|
||||
onClick={() => handleFilter('ja')}
|
||||
>
|
||||
{_('Keep Chinese')}
|
||||
</button>
|
||||
<button
|
||||
className='btn btn-secondary btn-sm'
|
||||
disabled={processing}
|
||||
onClick={() => handleFilter('zh')}
|
||||
>
|
||||
{_('Keep Japanese')}
|
||||
</button>
|
||||
<button className='btn btn-ghost btn-sm' disabled={processing} onClick={onCancel}>
|
||||
{processing ? _('Processing...') : _('Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BilingualFilterAlert;
|
||||
@@ -33,6 +33,10 @@ interface BookItemProps {
|
||||
showBookDetailsModal: (book: Book) => void;
|
||||
}
|
||||
|
||||
const CELL_ASPECT_RATIO = 28 / 41;
|
||||
const MIN_FIT_COVER_WIDTH_RATIO = 0.72;
|
||||
const MIN_FIT_COVER_HEIGHT_RATIO = 0.78;
|
||||
|
||||
const BookItem: React.FC<BookItemProps> = ({
|
||||
book,
|
||||
mode,
|
||||
@@ -56,61 +60,84 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
setCoverAspect(null);
|
||||
}, [book.hash, book.metadata?.coverImageUrl, book.coverImageUrl]);
|
||||
|
||||
const CELL_ASPECT_RATIO = 28 / 41;
|
||||
const measuredCoverAspect = coverAspect ?? CELL_ASPECT_RATIO;
|
||||
const fitCoverInGrid = mode === 'grid' && coverFit === 'fit' && coverAspect !== null;
|
||||
const shouldShrinkWidth = fitCoverInGrid && coverAspect! < CELL_ASPECT_RATIO;
|
||||
const fitCoverBaseWidthRatio =
|
||||
fitCoverInGrid && measuredCoverAspect < CELL_ASPECT_RATIO
|
||||
? measuredCoverAspect / CELL_ASPECT_RATIO
|
||||
: 1;
|
||||
const fitCoverBaseHeightRatio =
|
||||
fitCoverInGrid && measuredCoverAspect > CELL_ASPECT_RATIO
|
||||
? CELL_ASPECT_RATIO / measuredCoverAspect
|
||||
: 1;
|
||||
const fitCoverWidthRatio = fitCoverInGrid
|
||||
? Math.max(MIN_FIT_COVER_WIDTH_RATIO, fitCoverBaseWidthRatio)
|
||||
: 1;
|
||||
const fitCoverHeightRatio = fitCoverInGrid
|
||||
? Math.max(MIN_FIT_COVER_HEIGHT_RATIO, fitCoverBaseHeightRatio)
|
||||
: 1;
|
||||
const fitCoverIsZoomed =
|
||||
fitCoverInGrid &&
|
||||
(fitCoverWidthRatio > fitCoverBaseWidthRatio || fitCoverHeightRatio > fitCoverBaseHeightRatio);
|
||||
const bookitemMainStyle = fitCoverInGrid
|
||||
? {
|
||||
aspectRatio: coverAspect!,
|
||||
...(shouldShrinkWidth ? { width: `${(coverAspect! / CELL_ASPECT_RATIO) * 100}%` } : {}),
|
||||
width: `${fitCoverWidthRatio * 100}%`,
|
||||
height: `${fitCoverHeightRatio * 100}%`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const seriesText = formatSeries(book.metadata?.series, book.metadata?.seriesIndex);
|
||||
const coverNode = (
|
||||
<div
|
||||
className={clsx(
|
||||
'bookitem-main relative flex justify-center overflow-hidden rounded',
|
||||
mode === 'grid' && 'items-end',
|
||||
mode === 'grid' && !fitCoverInGrid && 'h-full w-full',
|
||||
mode === 'list' && 'aspect-[28/41] min-w-20 items-center',
|
||||
coverFit === 'crop' && 'shadow-md',
|
||||
)}
|
||||
style={bookitemMainStyle}
|
||||
>
|
||||
<BookCover
|
||||
mode={mode}
|
||||
book={book}
|
||||
coverFit={fitCoverIsZoomed ? 'crop' : coverFit}
|
||||
showSpine={false}
|
||||
imageClassName='rounded shadow-md'
|
||||
onAspectRatioChange={setCoverAspect}
|
||||
/>
|
||||
{bookSelected && (
|
||||
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
|
||||
)}
|
||||
{isSelectMode && (
|
||||
<div className='absolute bottom-1 right-1'>
|
||||
{bookSelected ? (
|
||||
<MdCheckCircle className='fill-blue-500' />
|
||||
) : (
|
||||
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role='none'
|
||||
className={clsx(
|
||||
'book-item flex',
|
||||
mode === 'grid' && 'h-full flex-col justify-end',
|
||||
mode === 'grid' && 'h-full w-full flex-col justify-end',
|
||||
mode === 'list' && 'min-h-28 flex-row gap-4 overflow-hidden',
|
||||
mode === 'list' ? 'library-list-item' : 'library-grid-item',
|
||||
appService?.hasContextMenu ? 'cursor-pointer' : '',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'bookitem-main relative flex justify-center overflow-hidden rounded',
|
||||
!fitCoverInGrid && 'aspect-[28/41]',
|
||||
coverFit === 'crop' && 'shadow-md',
|
||||
mode === 'grid' && 'items-end',
|
||||
mode === 'list' && 'min-w-20 items-center',
|
||||
)}
|
||||
style={bookitemMainStyle}
|
||||
>
|
||||
<BookCover
|
||||
mode={mode}
|
||||
book={book}
|
||||
coverFit={coverFit}
|
||||
showSpine={false}
|
||||
imageClassName='rounded shadow-md'
|
||||
onAspectRatioChange={setCoverAspect}
|
||||
/>
|
||||
{bookSelected && (
|
||||
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
|
||||
)}
|
||||
{isSelectMode && (
|
||||
<div className='absolute bottom-1 right-1'>
|
||||
{bookSelected ? (
|
||||
<MdCheckCircle className='fill-blue-500' />
|
||||
) : (
|
||||
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{mode === 'grid' ? (
|
||||
<div className='flex aspect-[28/41] w-full items-end justify-center'>{coverNode}</div>
|
||||
) : (
|
||||
coverNode
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex w-full flex-col p-0',
|
||||
|
||||
@@ -50,6 +50,7 @@ import { eventDispatcher } from '@/utils/event';
|
||||
import { getLocalBookFilename } from '@/utils/book';
|
||||
import { MIMETYPES, EXTS } from '@/libs/document';
|
||||
import { makeSafeFilename } from '@/utils/misc';
|
||||
import { getBookCategoryValues, type LibraryCategoryFilter } from '../utils/categoryFilters';
|
||||
|
||||
import { useSpatialNavigation } from '../hooks/useSpatialNavigation';
|
||||
import DeleteConfirmAlert from '@/components/DeleteConfirmAlert';
|
||||
@@ -63,6 +64,11 @@ import GroupingModal from './GroupingModal';
|
||||
import SetStatusAlert from './SetStatusAlert';
|
||||
import RecentShelf, { RECENT_SHELF_BOOK_COUNT } from './RecentShelf';
|
||||
import { useOpenBook } from '../hooks/useOpenBook';
|
||||
import BilingualFilterAlert from './BilingualFilterAlert';
|
||||
import {
|
||||
filterBilingualEpubFile,
|
||||
type BilingualFilterOptions,
|
||||
} from '@/services/bilingualEpubFilter';
|
||||
|
||||
interface BookshelfProps {
|
||||
libraryBooks: Book[];
|
||||
@@ -83,6 +89,8 @@ interface BookshelfProps {
|
||||
handleLibraryNavigation: (targetGroup: string) => void;
|
||||
handlePushLibrary: () => Promise<void>;
|
||||
booksTransferProgress: { [key: string]: number | null };
|
||||
categoryFilter?: LibraryCategoryFilter;
|
||||
sidebarVisible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +101,7 @@ interface BookshelfProps {
|
||||
type BookshelfListContext = {
|
||||
autoColumns: boolean;
|
||||
fixedColumns: number;
|
||||
sidebarVisible: boolean;
|
||||
/**
|
||||
* The recently-read shelf, rendered in the Virtuoso header so it scrolls with
|
||||
* the shelf content (not sticky). `null` when hidden. Passed through context
|
||||
@@ -102,9 +111,12 @@ type BookshelfListContext = {
|
||||
recentShelfHeader: React.ReactNode;
|
||||
};
|
||||
|
||||
const BOOKSHELF_GRID_CLASSES =
|
||||
'bookshelf-items transform-wrapper grid gap-x-4 px-4 sm:gap-x-0 sm:px-2 ' +
|
||||
const BOOKSHELF_GRID_BASE_CLASSES =
|
||||
'bookshelf-items transform-wrapper grid gap-x-4 px-4 sm:gap-x-0 sm:px-2';
|
||||
const BOOKSHELF_GRID_DEFAULT_COLUMNS =
|
||||
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12';
|
||||
const BOOKSHELF_GRID_SIDEBAR_COLUMNS =
|
||||
'grid-cols-3 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-8';
|
||||
|
||||
const BOOKSHELF_LIST_CLASSES = 'bookshelf-items transform-wrapper flex flex-col';
|
||||
|
||||
@@ -115,7 +127,11 @@ const BookshelfGridList: GridComponents<BookshelfListContext>['List'] = React.fo
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid={testId}
|
||||
className={clsx(BOOKSHELF_GRID_CLASSES, className)}
|
||||
className={clsx(
|
||||
BOOKSHELF_GRID_BASE_CLASSES,
|
||||
context?.sidebarVisible ? BOOKSHELF_GRID_SIDEBAR_COLUMNS : BOOKSHELF_GRID_DEFAULT_COLUMNS,
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...style,
|
||||
gridTemplateColumns:
|
||||
@@ -170,6 +186,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleLibraryNavigation,
|
||||
handlePushLibrary,
|
||||
booksTransferProgress,
|
||||
categoryFilter = 'all',
|
||||
sidebarVisible = false,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -180,6 +198,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
|
||||
const groupId = searchParams?.get('group') || '';
|
||||
const queryTerm = searchParams?.get('q') || null;
|
||||
const categoryValue = searchParams?.get('categoryValue') || '';
|
||||
const viewMode = searchParams?.get('view') || settings.libraryViewMode;
|
||||
const storedSortBy = ensureLibrarySortByType(searchParams?.get('sort'), settings.librarySortBy);
|
||||
const sortOrder = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc');
|
||||
@@ -199,6 +218,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [showStatusAlert, setShowStatusAlert] = useState(false);
|
||||
const [showGroupingModal, setShowGroupingModal] = useState(false);
|
||||
const [showBilingualFilterAlert, setShowBilingualFilterAlert] = useState(false);
|
||||
const [bilingualFilterBook, setBilingualFilterBook] = useState<Book | null>(null);
|
||||
const [bilingualFilterProcessing, setBilingualFilterProcessing] = useState(false);
|
||||
const [importBookUrl] = useState(searchParams?.get('url') || '');
|
||||
|
||||
const abortDeletionRef = useRef(false);
|
||||
@@ -241,10 +263,20 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const categoryFilteredBooks = useMemo(() => {
|
||||
if (categoryFilter === 'all' || !categoryValue) return libraryBooks;
|
||||
return libraryBooks.filter(
|
||||
(book) =>
|
||||
!book.deletedAt && getBookCategoryValues(book, categoryFilter).includes(categoryValue),
|
||||
);
|
||||
}, [libraryBooks, categoryFilter, categoryValue]);
|
||||
|
||||
const filteredBooks = useMemo(() => {
|
||||
const bookFilter = createBookFilter(queryTerm);
|
||||
return queryTerm ? libraryBooks.filter((book) => bookFilter(book)) : libraryBooks;
|
||||
}, [libraryBooks, queryTerm]);
|
||||
return queryTerm
|
||||
? categoryFilteredBooks.filter((book) => bookFilter(book))
|
||||
: categoryFilteredBooks;
|
||||
}, [categoryFilteredBooks, queryTerm]);
|
||||
|
||||
const currentBookshelfItems = useMemo(() => {
|
||||
if (groupBy === LibraryGroupByType.Group) {
|
||||
@@ -452,6 +484,112 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
setShowStatusAlert(true);
|
||||
};
|
||||
|
||||
const getSingleSelectedBook = () => {
|
||||
const ids = getSelectedBooks();
|
||||
if (ids.length !== 1) return;
|
||||
return filteredBooks.find((book) => book.hash === ids[0]);
|
||||
};
|
||||
|
||||
const showBilingualFilterSelection = () => {
|
||||
const book = getSingleSelectedBook();
|
||||
if (!book || book.format !== 'EPUB') return;
|
||||
setBilingualFilterBook(null);
|
||||
setShowSelectModeActions(false);
|
||||
setShowBilingualFilterAlert(true);
|
||||
};
|
||||
|
||||
const showBilingualFilterBook = useCallback(
|
||||
(book: Book) => {
|
||||
if (book.format !== 'EPUB') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'warning',
|
||||
message: _('Only EPUB books can be filtered'),
|
||||
timeout: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBilingualFilterBook(book);
|
||||
setShowSelectModeActions(false);
|
||||
setShowBilingualFilterAlert(true);
|
||||
},
|
||||
[_],
|
||||
);
|
||||
|
||||
const closeBilingualFilterSelection = () => {
|
||||
if (bilingualFilterProcessing) return;
|
||||
setShowBilingualFilterAlert(false);
|
||||
setBilingualFilterBook(null);
|
||||
if (isSelectMode) setShowSelectModeActions(true);
|
||||
};
|
||||
|
||||
const runBilingualFilter = async (options: BilingualFilterOptions) => {
|
||||
const book = bilingualFilterBook ?? getSingleSelectedBook();
|
||||
if (!book || !appService) return;
|
||||
if (book.format !== 'EPUB') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'warning',
|
||||
message: _('Only EPUB books can be filtered'),
|
||||
timeout: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setBilingualFilterProcessing(true);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (!(await appService.isBookAvailable(book))) {
|
||||
if (!book.uploadedAt || !(await handleBookDownload(book, { queued: false }))) {
|
||||
throw new Error('Book file is not available locally');
|
||||
}
|
||||
}
|
||||
|
||||
const { file } = await appService.loadBookContent(book);
|
||||
try {
|
||||
const result = await filterBilingualEpubFile(file, options);
|
||||
const importedBook = await appService.importBook(result.file, libraryBooks);
|
||||
if (!importedBook) throw new Error('Failed to import generated EPUB');
|
||||
|
||||
importedBook.group = book.group;
|
||||
importedBook.groupId = book.groupId;
|
||||
importedBook.groupName = book.groupName;
|
||||
importedBook.tags = book.tags;
|
||||
importedBook.updatedAt = Date.now();
|
||||
|
||||
await updateBooks(envConfig, [importedBook]);
|
||||
handlePushLibrary();
|
||||
setSelectedBooks([]);
|
||||
setBilingualFilterBook(null);
|
||||
setShowBilingualFilterAlert(false);
|
||||
handleSetSelectMode(false);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: _('Created {{title}}. Removed {{count}} paragraph(s).', {
|
||||
title: importedBook.title || result.title,
|
||||
count: result.stats.removed,
|
||||
}),
|
||||
timeout: 3500,
|
||||
});
|
||||
} finally {
|
||||
const closable = file as File & { close?: () => Promise<void> | void };
|
||||
await closable.close?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to filter bilingual EPUB:', error);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Failed to filter bilingual EPUB'),
|
||||
timeout: 3000,
|
||||
});
|
||||
setBilingualFilterBook(null);
|
||||
setShowBilingualFilterAlert(false);
|
||||
if (isSelectMode) setShowSelectModeActions(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setBilingualFilterProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendSelectedBook = async () => {
|
||||
// "Send" hands the actual book file (epub/pdf/...) to the OS share
|
||||
// sheet (UIActivityViewController on iOS, Intent.ACTION_SEND on
|
||||
@@ -678,6 +816,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
);
|
||||
|
||||
const selectedBooks = getSelectedBooks();
|
||||
const selectedBilingualBook =
|
||||
selectedBooks.length === 1
|
||||
? filteredBooks.find((book) => book.hash === selectedBooks[0])
|
||||
: null;
|
||||
const bilingualFilterEnabled = !!selectedBilingualBook && selectedBilingualBook.format === 'EPUB';
|
||||
const isGridMode = viewMode === 'grid';
|
||||
const hasItems = sortedBookshelfItems.length > 0;
|
||||
// In grid mode the Import-Books "+" tile is rendered as an extra grid cell
|
||||
@@ -716,6 +859,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
coverFit={coverFit as LibraryCoverFitType}
|
||||
autoColumns={settings.libraryAutoColumns}
|
||||
fixedColumns={settings.libraryColumns}
|
||||
sidebarVisible={sidebarVisible}
|
||||
onOpenBook={openRecentBook}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
@@ -728,6 +872,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
coverFit,
|
||||
settings.libraryAutoColumns,
|
||||
settings.libraryColumns,
|
||||
sidebarVisible,
|
||||
openRecentBook,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
@@ -739,9 +884,10 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
() => ({
|
||||
autoColumns: settings.libraryAutoColumns,
|
||||
fixedColumns: settings.libraryColumns,
|
||||
sidebarVisible,
|
||||
recentShelfHeader,
|
||||
}),
|
||||
[settings.libraryAutoColumns, settings.libraryColumns, recentShelfHeader],
|
||||
[settings.libraryAutoColumns, settings.libraryColumns, sidebarVisible, recentShelfHeader],
|
||||
);
|
||||
|
||||
const renderBookshelfItem = useCallback(
|
||||
@@ -791,6 +937,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleBilingualFilterBook={showBilingualFilterBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
handleUpdateReadingStatus={handleUpdateReadingStatus}
|
||||
transferProgress={
|
||||
@@ -816,6 +963,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
showBilingualFilterBook,
|
||||
handleLibraryNavigation,
|
||||
handleUpdateReadingStatus,
|
||||
],
|
||||
@@ -889,9 +1037,20 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
onGroup={groupSelectedBooks}
|
||||
onDetails={openBookDetails}
|
||||
onStatus={showStatusSelection}
|
||||
onBilingualFilter={showBilingualFilterSelection}
|
||||
onSend={sendSelectedBook}
|
||||
onDelete={deleteSelectedBooks}
|
||||
onCancel={() => handleSetSelectMode(false)}
|
||||
bilingualFilterEnabled={bilingualFilterEnabled}
|
||||
/>
|
||||
)}
|
||||
{showBilingualFilterAlert && (bilingualFilterBook || selectedBilingualBook) && (
|
||||
<BilingualFilterAlert
|
||||
bookTitle={(bilingualFilterBook || selectedBilingualBook)?.title || ''}
|
||||
safeAreaBottom={safeAreaInsets?.bottom || 0}
|
||||
processing={bilingualFilterProcessing}
|
||||
onCancel={closeBilingualFilterSelection}
|
||||
onFilter={runBilingualFilter}
|
||||
/>
|
||||
)}
|
||||
{showGroupingModal && selectedBooks.length > 0 && (
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import clsx from 'clsx';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { Menu, type MenuItemOptions } from '@tauri-apps/api/menu';
|
||||
import { Menu as TauriMenu } from '@tauri-apps/api/menu';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { openExternalUrl } from '@/utils/open';
|
||||
import { getBookGoodreadsQuery, getGoodreadsSearchUrl } from '@/utils/goodreads';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { Overlay } from '@/components/Overlay';
|
||||
import Menu from '@/components/Menu';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
|
||||
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
|
||||
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
|
||||
@@ -24,6 +27,17 @@ import BookItem from './BookItem';
|
||||
import GroupItem from './GroupItem';
|
||||
import { useOpenBook } from '../hooks/useOpenBook';
|
||||
|
||||
type WebContextMenuItem = {
|
||||
text: string;
|
||||
action: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
type WebContextMenuState = {
|
||||
x: number;
|
||||
y: number;
|
||||
items: WebContextMenuItem[];
|
||||
} | null;
|
||||
|
||||
export const generateBookshelfItems = (
|
||||
books: Book[],
|
||||
parentGroupName: string,
|
||||
@@ -103,6 +117,7 @@ interface BookshelfItemProps {
|
||||
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
handleBilingualFilterBook: (book: Book) => void;
|
||||
handleLibraryNavigation: (targetGroup: string) => void;
|
||||
handleUpdateReadingStatus: (book: Book, status: ReadingStatus | undefined) => void;
|
||||
}
|
||||
@@ -121,6 +136,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
handleBookDownload,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
handleBilingualFilterBook,
|
||||
handleLibraryNavigation,
|
||||
handleUpdateReadingStatus,
|
||||
}) => {
|
||||
@@ -128,6 +144,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const { appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { openBook } = useOpenBook({ setLoading, handleBookDownload });
|
||||
const [webContextMenu, setWebContextMenu] = useState<WebContextMenuState>(null);
|
||||
|
||||
const showBookDetailsModal = useCallback(async (book: Book) => {
|
||||
handleShowDetailsBook(book);
|
||||
@@ -157,8 +174,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
[isSelectMode, handleLibraryNavigation],
|
||||
);
|
||||
|
||||
const bookContextMenuHandler = async (book: Book) => {
|
||||
if (!appService?.hasContextMenu) return;
|
||||
const bookContextMenuHandler = async (book: Book, event?: React.MouseEvent) => {
|
||||
const osPlatform = getOSPlatform();
|
||||
const fileRevealLabel =
|
||||
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
|
||||
@@ -166,7 +182,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
// in a single Menu.new({ items }) call. Appending items one-by-one with
|
||||
// un-awaited Menu.append() promises races on the Tauri IPC boundary and
|
||||
// shuffles the order on every open (issue #4389).
|
||||
const itemOptions: Record<BookContextMenuItemId, MenuItemOptions> = {
|
||||
const itemOptions: Record<BookContextMenuItemId, WebContextMenuItem> = {
|
||||
select: {
|
||||
text: itemSelected ? _('Deselect Book') : _('Select Book'),
|
||||
action: async () => {
|
||||
@@ -214,6 +230,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
showBookDetailsModal(book);
|
||||
},
|
||||
},
|
||||
bilingual: {
|
||||
text: _('Bilingual'),
|
||||
action: async () => {
|
||||
handleBilingualFilterBook(book);
|
||||
},
|
||||
},
|
||||
showInFinder: {
|
||||
text: _(fileRevealLabel),
|
||||
action: async () => {
|
||||
@@ -254,16 +276,22 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
},
|
||||
},
|
||||
};
|
||||
const items = getBookContextMenuItemIds(book).map((id) => itemOptions[id]);
|
||||
const menu = await Menu.new({ items });
|
||||
await menu.popup();
|
||||
const itemIds = getBookContextMenuItemIds(book).filter(
|
||||
(id) => appService?.hasContextMenu || id !== 'showInFinder',
|
||||
);
|
||||
const items = itemIds.map((id) => itemOptions[id]);
|
||||
if (appService?.hasContextMenu) {
|
||||
const menu = await TauriMenu.new({ items });
|
||||
await menu.popup();
|
||||
return;
|
||||
}
|
||||
if (event) setWebContextMenu({ x: event.clientX, y: event.clientY, items });
|
||||
};
|
||||
|
||||
const groupContextMenuHandler = async (group: BooksGroup) => {
|
||||
if (!appService?.hasContextMenu) return;
|
||||
const groupContextMenuHandler = async (group: BooksGroup, event?: React.MouseEvent) => {
|
||||
// Single Menu.new({ items }) call keeps the order deterministic — see the
|
||||
// note in bookContextMenuHandler about the Menu.append() IPC race (#4389).
|
||||
const items: MenuItemOptions[] = [
|
||||
const items: WebContextMenuItem[] = [
|
||||
{
|
||||
text: itemSelected ? _('Deselect Group') : _('Select Group'),
|
||||
action: async () => {
|
||||
@@ -293,8 +321,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
},
|
||||
},
|
||||
];
|
||||
const menu = await Menu.new({ items });
|
||||
await menu.popup();
|
||||
if (appService?.hasContextMenu) {
|
||||
const menu = await TauriMenu.new({ items });
|
||||
await menu.popup();
|
||||
return;
|
||||
}
|
||||
if (event) setWebContextMenu({ x: event.clientX, y: event.clientY, items });
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -330,14 +362,28 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleContextMenu = useCallback(
|
||||
throttle(() => {
|
||||
throttle((event?: React.MouseEvent) => {
|
||||
if ('format' in item) {
|
||||
bookContextMenuHandler(item as Book);
|
||||
bookContextMenuHandler(item as Book, event);
|
||||
} else {
|
||||
groupContextMenuHandler(item as BooksGroup);
|
||||
groupContextMenuHandler(item as BooksGroup, event);
|
||||
}
|
||||
}, 100),
|
||||
[itemSelected, settings.localBooksDir],
|
||||
[
|
||||
appService?.hasContextMenu,
|
||||
appService?.isMobileApp,
|
||||
handleBookDownload,
|
||||
handleBookUpload,
|
||||
handleBilingualFilterBook,
|
||||
handleGroupBooks,
|
||||
handleSetSelectMode,
|
||||
handleUpdateReadingStatus,
|
||||
item,
|
||||
itemSelected,
|
||||
settings.localBooksDir,
|
||||
showBookDetailsModal,
|
||||
toggleSelection,
|
||||
],
|
||||
);
|
||||
|
||||
const { pressing, handlers } = useLongPress(
|
||||
@@ -348,9 +394,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
onTap: () => {
|
||||
handleOpenItem();
|
||||
},
|
||||
onContextMenu: () => {
|
||||
onContextMenu: (event) => {
|
||||
if (appService?.hasContextMenu) {
|
||||
handleContextMenu();
|
||||
} else if (!appService?.isMobileApp) {
|
||||
handleContextMenu(event);
|
||||
} else if (appService?.isAndroidApp) {
|
||||
handleSelectItem();
|
||||
}
|
||||
@@ -380,6 +428,29 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
|
||||
return (
|
||||
<div className={clsx(mode === 'grid' ? 'h-full' : 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
|
||||
{webContextMenu && (
|
||||
<>
|
||||
<Overlay onDismiss={() => setWebContextMenu(null)} className='z-40' />
|
||||
<Menu
|
||||
className='bg-base-100 border-base-300 fixed z-50 min-w-56 rounded-lg border p-1 shadow-xl'
|
||||
style={{ left: webContextMenu.x, top: webContextMenu.y }}
|
||||
onCancel={() => setWebContextMenu(null)}
|
||||
>
|
||||
{webContextMenu.items.map((menuItem) => (
|
||||
<MenuItem
|
||||
key={menuItem.text}
|
||||
label={menuItem.text}
|
||||
noIcon
|
||||
transient
|
||||
onClick={() => {
|
||||
setWebContextMenu(null);
|
||||
void menuItem.action();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'visible-focus-inset-2 group',
|
||||
|
||||
@@ -22,6 +22,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||
const [showRightArrow, setShowRightArrow] = useState(false);
|
||||
const isSingleBookGrid = mode === 'grid' && group.books.length === 1;
|
||||
|
||||
const checkScrollArrows = () => {
|
||||
if (mode === 'list' && scrollContainerRef.current) {
|
||||
@@ -115,7 +116,10 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
|
||||
<div
|
||||
ref={mode === 'list' ? scrollContainerRef : undefined}
|
||||
className={clsx(
|
||||
mode === 'grid' && 'grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden',
|
||||
mode === 'grid' &&
|
||||
(isSingleBookGrid
|
||||
? 'flex w-full overflow-hidden'
|
||||
: 'grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden'),
|
||||
mode === 'list' && 'flex h-28 gap-2 overflow-x-auto overflow-y-hidden',
|
||||
mode === 'list' ? 'library-list-item' : 'library-grid-item',
|
||||
)}
|
||||
@@ -137,7 +141,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
|
||||
key={book.hash}
|
||||
className={clsx(
|
||||
'relative aspect-[28/41] h-full',
|
||||
mode === 'grid' && 'w-full',
|
||||
mode === 'grid' && (isSingleBookGrid ? 'mx-auto' : 'w-full'),
|
||||
mode === 'list' && 'flex-shrink-0',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import Menu from '@/components/Menu';
|
||||
|
||||
interface ImportMenuProps {
|
||||
setIsDropdownOpen?: (open: boolean) => void;
|
||||
menuClassName?: string;
|
||||
onImportBooksFromFiles: () => void;
|
||||
onImportBooksFromDirectory?: () => void;
|
||||
onImportBookFromUrl?: () => void;
|
||||
@@ -16,6 +17,7 @@ interface ImportMenuProps {
|
||||
|
||||
const ImportMenu: React.FC<ImportMenuProps> = ({
|
||||
setIsDropdownOpen,
|
||||
menuClassName,
|
||||
onImportBooksFromFiles,
|
||||
onImportBooksFromDirectory,
|
||||
onImportBookFromUrl,
|
||||
@@ -46,7 +48,10 @@ const ImportMenu: React.FC<ImportMenuProps> = ({
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={clsx('dropdown-content bg-base-100 rounded-box !relative z-[1] mt-3 p-2 shadow')}
|
||||
className={clsx(
|
||||
'dropdown-content bg-base-100 rounded-box !relative z-[1] mt-3 p-2 shadow',
|
||||
menuClassName,
|
||||
)}
|
||||
onCancel={() => setIsDropdownOpen?.(false)}
|
||||
>
|
||||
<MenuItem
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useTrafficLight } from '@/hooks/useTrafficLight';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useIsMobileViewport } from '@/hooks/useIsMobileViewport';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
@@ -33,6 +34,7 @@ interface LibraryHeaderProps {
|
||||
onToggleSelectMode: () => void;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
@@ -46,6 +48,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
onToggleSelectMode,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
onToggleSidebar,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -59,6 +62,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
const { isTrafficLightVisible } = useTrafficLight(headerRef);
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const { safeAreaInsets: insets } = useThemeStore();
|
||||
const isNarrowViewport = useIsMobileViewport(641);
|
||||
|
||||
useShortcuts({
|
||||
onToggleSelectMode,
|
||||
@@ -92,7 +96,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
|
||||
if (!insets) return null;
|
||||
|
||||
const isMobile = appService?.isMobile || window.innerWidth <= 640;
|
||||
const isMobile = appService?.isMobile || isNarrowViewport;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -109,8 +113,17 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
|
||||
<div className='exclude-title-bar-mousedown relative flex w-full items-center pl-4'>
|
||||
<div className='relative flex h-9 w-full items-center sm:h-7'>
|
||||
<div className='exclude-title-bar-mousedown relative flex w-full items-center gap-2 pl-2 sm:pl-4'>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-ghost h-8 min-h-8 w-8 shrink-0 p-0'
|
||||
aria-label={_('Toggle Sidebar')}
|
||||
title={_('Toggle Sidebar')}
|
||||
onClick={onToggleSidebar}
|
||||
>
|
||||
<MdOutlineMenu role='none' size={iconSize18} />
|
||||
</button>
|
||||
<div className='relative flex h-9 min-w-0 flex-1 items-center sm:h-7'>
|
||||
<span className='text-base-content/50 absolute ps-3'>
|
||||
<FaSearch className='h-4 w-4' />
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
PiBooks,
|
||||
PiCalendarBlank,
|
||||
PiDotsThreeCircle,
|
||||
PiGear,
|
||||
PiGlobeHemisphereEast,
|
||||
PiHouseLine,
|
||||
PiPlus,
|
||||
PiSelectionAll,
|
||||
PiSelectionAllFill,
|
||||
PiStackSimple,
|
||||
PiTag,
|
||||
PiUser,
|
||||
} from 'react-icons/pi';
|
||||
import { MdBusiness, MdKeyboardArrowDown, MdKeyboardArrowUp, MdOutlineMenu } from 'react-icons/md';
|
||||
import { IoMdCloseCircle } from 'react-icons/io';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import type { IconType } from 'react-icons';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import ImportMenu from './ImportMenu';
|
||||
import SettingsMenu from './SettingsMenu';
|
||||
import ViewMenu from './ViewMenu';
|
||||
import type { Book } from '@/types/book';
|
||||
import {
|
||||
countCategoryValues,
|
||||
ensureLibraryCategoryFilter,
|
||||
type CategoryValue,
|
||||
type LibraryCategoryFilter,
|
||||
} from '../utils/categoryFilters';
|
||||
|
||||
interface LibrarySidebarProps {
|
||||
books: Book[];
|
||||
isSelectMode: boolean;
|
||||
onPullLibrary: () => void;
|
||||
onImportBooksFromFiles: () => void;
|
||||
onImportBooksFromDirectory?: () => void;
|
||||
onImportBookFromUrl?: () => void;
|
||||
onOpenCatalogManager: () => void;
|
||||
onToggleSelectMode: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
isViewportResolved: boolean;
|
||||
}
|
||||
|
||||
type CategoryItem = {
|
||||
id: LibraryCategoryFilter;
|
||||
label: string;
|
||||
count: number;
|
||||
Icon: IconType;
|
||||
values: CategoryValue[];
|
||||
};
|
||||
|
||||
const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
|
||||
books,
|
||||
isSelectMode,
|
||||
onPullLibrary,
|
||||
onImportBooksFromFiles,
|
||||
onImportBooksFromDirectory,
|
||||
onImportBookFromUrl,
|
||||
onOpenCatalogManager,
|
||||
onToggleSelectMode,
|
||||
onToggleSidebar,
|
||||
isViewportResolved,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
|
||||
const activeCategory = ensureLibraryCategoryFilter(searchParams?.get('category'));
|
||||
const activeCategoryValue = searchParams?.get('categoryValue') || '';
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<LibraryCategoryFilter>>(
|
||||
() => new Set(activeCategory !== 'all' ? [activeCategory] : ['author']),
|
||||
);
|
||||
|
||||
const activeBooks = useMemo(() => books.filter((book) => !book.deletedAt), [books]);
|
||||
const categories: CategoryItem[] = useMemo(() => {
|
||||
const makeCategory = (
|
||||
id: LibraryCategoryFilter,
|
||||
label: string,
|
||||
Icon: IconType,
|
||||
): CategoryItem => {
|
||||
const values = countCategoryValues(activeBooks, id);
|
||||
return { id, label, count: values.length, Icon, values };
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
label: _('All Books'),
|
||||
count: activeBooks.length,
|
||||
Icon: PiBooks,
|
||||
values: [],
|
||||
},
|
||||
makeCategory('author', _('Authors'), PiUser),
|
||||
makeCategory('publisher', _('Publisher'), MdBusiness),
|
||||
makeCategory('year', _('Publication Year'), PiCalendarBlank),
|
||||
makeCategory('language', _('Language'), PiGlobeHemisphereEast),
|
||||
makeCategory('format', _('Category'), PiStackSimple),
|
||||
makeCategory('subject', _('Subject'), PiHouseLine),
|
||||
makeCategory('tag', _('Tags'), PiTag),
|
||||
];
|
||||
}, [_, activeBooks]);
|
||||
|
||||
const updateParams = useCallback(
|
||||
(updates: Record<string, string | null>) => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (!value) {
|
||||
params.delete(key);
|
||||
} else {
|
||||
params.set(key, value);
|
||||
}
|
||||
}
|
||||
if (params.get('category') === 'all') params.delete('category');
|
||||
navigateToLibrary(router, params.toString());
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedUpdateQueryParam = useCallback(
|
||||
debounce((value: string) => updateParams({ q: value || null }), 500),
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newQuery = event.target.value;
|
||||
setSearchQuery(newQuery);
|
||||
debouncedUpdateQueryParam(newQuery);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('');
|
||||
debouncedUpdateQueryParam('');
|
||||
};
|
||||
|
||||
const toggleCategory = (category: LibraryCategoryFilter) => {
|
||||
if (category === 'all') {
|
||||
updateParams({ category: null, categoryValue: null, group: null });
|
||||
return;
|
||||
}
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectCategoryValue = (category: LibraryCategoryFilter, value: string) => {
|
||||
updateParams({ category, categoryValue: value, group: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
'library-sidebar bg-base-200/95 border-base-300/70 fixed inset-y-0 start-0 z-50 h-full w-[min(82vw,248px)] shrink-0 flex-col border-r md:relative md:inset-auto md:z-auto md:w-[248px]',
|
||||
isViewportResolved ? 'flex' : 'hidden md:flex',
|
||||
'pt-[max(env(safe-area-inset-top),0px)]',
|
||||
)}
|
||||
>
|
||||
<div className='exclude-title-bar-mousedown flex min-h-0 flex-1 flex-col px-3 py-3'>
|
||||
<div className='mb-3 flex h-8 items-center gap-2 px-1'>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-ghost h-7 min-h-7 w-7 shrink-0 p-0'
|
||||
aria-label={_('Toggle Sidebar')}
|
||||
title={_('Toggle Sidebar')}
|
||||
onClick={onToggleSidebar}
|
||||
>
|
||||
<MdOutlineMenu className='text-base-content/70 h-5 w-5' />
|
||||
</button>
|
||||
<div className='min-w-0 flex-1 truncate text-sm font-semibold'>Readest</div>
|
||||
</div>
|
||||
|
||||
<div className='relative mb-3 flex h-8 items-center'>
|
||||
<span className='text-base-content/45 absolute left-3'>
|
||||
<FaSearch className='h-3.5 w-3.5' />
|
||||
</span>
|
||||
<input
|
||||
type='text'
|
||||
value={searchQuery}
|
||||
placeholder={_('Search Books...')}
|
||||
onChange={handleSearchChange}
|
||||
spellCheck='false'
|
||||
className={clsx(
|
||||
'input input-sm bg-base-100 border-base-300 h-8 w-full rounded-md ps-9 pe-8',
|
||||
'text-sm placeholder:text-base-content/45 focus:outline-none',
|
||||
)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={clearSearch}
|
||||
className='text-base-content/40 hover:text-base-content/70 absolute right-2'
|
||||
aria-label={_('Clear Search')}
|
||||
>
|
||||
<IoMdCloseCircle className='h-4 w-4' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className='min-h-0 flex-1 overflow-y-auto pb-3' aria-label={_('Categories')}>
|
||||
<div className='space-y-1'>
|
||||
{categories.map(({ id, label, count, Icon, values }) => {
|
||||
const active = (activeCategory || 'all') === id && !activeCategoryValue;
|
||||
const expanded = expandedCategories.has(id);
|
||||
return (
|
||||
<div key={id}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => toggleCategory(id)}
|
||||
className={clsx(
|
||||
'flex h-9 w-full items-center gap-3 rounded-md px-2 text-sm transition-colors',
|
||||
active
|
||||
? 'bg-base-300 text-base-content'
|
||||
: 'text-base-content/85 hover:bg-base-300/55',
|
||||
)}
|
||||
>
|
||||
<Icon className='h-4.5 w-4.5 shrink-0' />
|
||||
<span className='min-w-0 flex-1 truncate text-left'>{label}</span>
|
||||
{id !== 'all' &&
|
||||
(expanded ? (
|
||||
<MdKeyboardArrowUp className='text-base-content/60 h-4 w-4 shrink-0' />
|
||||
) : (
|
||||
<MdKeyboardArrowDown className='text-base-content/60 h-4 w-4 shrink-0' />
|
||||
))}
|
||||
<span className='bg-base-content/30 text-base-100 min-w-5 rounded-full px-1.5 text-center text-[11px] font-medium leading-5'>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
{id !== 'all' && expanded && values.length > 0 && (
|
||||
<div className='mt-1 space-y-1 pb-1 ps-7'>
|
||||
{values.map((item) => {
|
||||
const valueActive =
|
||||
activeCategory === id && activeCategoryValue === item.value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type='button'
|
||||
onClick={() => selectCategoryValue(id, item.value)}
|
||||
className={clsx(
|
||||
'flex h-8 w-full items-center gap-2 rounded-md px-2 text-sm transition-colors',
|
||||
valueActive
|
||||
? 'bg-base-300 text-base-content'
|
||||
: 'text-base-content/80 hover:bg-base-300/45',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'h-4 w-0.5 rounded-full',
|
||||
valueActive ? 'bg-primary' : 'bg-transparent',
|
||||
)}
|
||||
/>
|
||||
<span className='min-w-0 flex-1 truncate text-left'>{item.value}</span>
|
||||
<span className='bg-base-content/30 text-base-100 min-w-5 rounded-full px-1.5 text-center text-[11px] font-medium leading-5'>
|
||||
{item.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className='border-base-300/70 flex items-center gap-1 border-t pt-2'>
|
||||
<Dropdown
|
||||
label={_('Import Books')}
|
||||
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
|
||||
menuClassName='mb-2 mt-0'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiPlus role='none' size={iconSize18} />}
|
||||
>
|
||||
<ImportMenu
|
||||
onImportBooksFromFiles={onImportBooksFromFiles}
|
||||
onImportBooksFromDirectory={onImportBooksFromDirectory}
|
||||
onImportBookFromUrl={onImportBookFromUrl}
|
||||
onOpenCatalogManager={onOpenCatalogManager}
|
||||
/>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
label={_('View Menu')}
|
||||
className='exclude-title-bar-mousedown dropdown-top dropdown-center'
|
||||
menuClassName='mb-2 mt-0'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiDotsThreeCircle role='none' size={iconSize18} />}
|
||||
>
|
||||
<ViewMenu />
|
||||
</Dropdown>
|
||||
<button
|
||||
onClick={onToggleSelectMode}
|
||||
aria-label={_('Select Books')}
|
||||
title={_('Select Books')}
|
||||
className='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
>
|
||||
{isSelectMode ? (
|
||||
<PiSelectionAllFill className='h-[18px] w-[18px]' />
|
||||
) : (
|
||||
<PiSelectionAll className='h-[18px] w-[18px]' />
|
||||
)}
|
||||
</button>
|
||||
<div className='flex-1' />
|
||||
<Dropdown
|
||||
label={_('Settings Menu')}
|
||||
className='exclude-title-bar-mousedown dropdown-top dropdown-end'
|
||||
menuClassName='mb-2 mt-0'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiGear role='none' size={iconSize18} />}
|
||||
>
|
||||
<SettingsMenu onPullLibrary={onPullLibrary} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibrarySidebar;
|
||||
@@ -19,6 +19,7 @@ interface RecentShelfProps {
|
||||
// Mirror the bookshelf grid's column model so covers are the same size.
|
||||
autoColumns: boolean;
|
||||
fixedColumns: number;
|
||||
sidebarVisible?: boolean;
|
||||
onOpenBook: (book: Book) => void;
|
||||
handleBookUpload: (book: Book) => void;
|
||||
handleBookDownload: (book: Book, options?: { redownload?: boolean; queued?: boolean }) => void;
|
||||
@@ -110,6 +111,7 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
|
||||
coverFit,
|
||||
autoColumns,
|
||||
fixedColumns,
|
||||
sidebarVisible = false,
|
||||
onOpenBook,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
@@ -121,7 +123,9 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
|
||||
// `--rs-gap` mirrors the grid's `gap-x-4 sm:gap-x-0` so the width formula
|
||||
// subtracts the right gap at each breakpoint.
|
||||
const colsClass = autoColumns
|
||||
? '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:6] xl:[--rs-cols:8] 2xl:[--rs-cols:12]'
|
||||
? sidebarVisible
|
||||
? '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:3] lg:[--rs-cols:4] xl:[--rs-cols:5] 2xl:[--rs-cols:8]'
|
||||
: '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:6] xl:[--rs-cols:8] 2xl:[--rs-cols:12]'
|
||||
: '';
|
||||
const colsStyle = autoColumns
|
||||
? undefined
|
||||
@@ -162,7 +166,7 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [measure, books, autoColumns, fixedColumns, coverFit]);
|
||||
}, [measure, books, autoColumns, fixedColumns, sidebarVisible, coverFit]);
|
||||
|
||||
const scrollByPage = (direction: -1 | 1) => {
|
||||
const el = scrollerRef.current;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
MdCheckCircleOutline,
|
||||
} from 'react-icons/md';
|
||||
import { IoShareSocialOutline } from 'react-icons/io5';
|
||||
import { LuFolderPlus } from 'react-icons/lu';
|
||||
import { LuFolderPlus, LuLanguages } from 'react-icons/lu';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { isMd5 } from '@/utils/md5';
|
||||
@@ -25,6 +25,7 @@ interface SelectModeActionsProps {
|
||||
onGroup: () => void;
|
||||
onDetails: () => void;
|
||||
onStatus: () => void;
|
||||
onBilingualFilter: () => void;
|
||||
// The macOS / iPad share popover is anchored to the selected book's
|
||||
// cover (located via its data-book-hash attribute), not to this
|
||||
// button — the user's visual focus is on the cover they just tapped.
|
||||
@@ -32,6 +33,7 @@ interface SelectModeActionsProps {
|
||||
onSend: () => void;
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
bilingualFilterEnabled?: boolean;
|
||||
}
|
||||
|
||||
const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
@@ -42,9 +44,11 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
onGroup,
|
||||
onDetails,
|
||||
onStatus,
|
||||
onBilingualFilter,
|
||||
onSend,
|
||||
onDelete,
|
||||
onCancel,
|
||||
bilingualFilterEnabled = false,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
|
||||
@@ -110,13 +114,21 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
<MdInfoOutline />
|
||||
<div>{_('Details')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={onBilingualFilter}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
!bilingualFilterEnabled && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<LuLanguages />
|
||||
<div>{_('Bilingual')}</div>
|
||||
</button>
|
||||
{sendEnabled && (
|
||||
<button
|
||||
onClick={onSend}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
// Wraps to the start of the second row on narrow viewports.
|
||||
'max-[500px]:col-start-1',
|
||||
(!hasSingleSelection || !hasValidBooks) && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
@@ -128,12 +140,6 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
onClick={onDelete}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
// Without Send (Linux/Windows/web), Delete needs an explicit
|
||||
// col-start-2 so the wrapped row {Delete, Cancel} stays centred
|
||||
// under the 4-col grid. With Send present, the layout is
|
||||
// {Send, Delete, Cancel} starting at col-start-1, so Delete
|
||||
// naturally lands in col-start-2 without an override.
|
||||
!sendEnabled && 'max-[500px]:col-start-2',
|
||||
!hasSelection && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -47,9 +47,14 @@ import { type AppLockDialogMode, useAppLockStore } from '@/store/appLockStore';
|
||||
interface SettingsMenuProps {
|
||||
onPullLibrary: (fullRefresh?: boolean, verbose?: boolean) => void;
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
menuClassName?: string;
|
||||
}
|
||||
|
||||
const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdownOpen }) => {
|
||||
const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
onPullLibrary,
|
||||
setIsDropdownOpen,
|
||||
menuClassName,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
@@ -313,6 +318,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
|
||||
className={clsx(
|
||||
'settings-menu dropdown-content no-triangle',
|
||||
'z-20 mt-2 max-w-[90vw] shadow-2xl',
|
||||
menuClassName,
|
||||
)}
|
||||
onCancel={() => setIsDropdownOpen?.(false)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -15,17 +16,21 @@ import { navigateToLibrary } from '@/utils/nav';
|
||||
import NumberInput from '@/components/settings/NumberInput';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Menu from '@/components/Menu';
|
||||
import { useIsMobileViewport } from '@/hooks/useIsMobileViewport';
|
||||
|
||||
interface ViewMenuProps {
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
menuClassName?: string;
|
||||
}
|
||||
|
||||
const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen, menuClassName }) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const isPhoneViewport = useIsMobileViewport();
|
||||
const isCompactViewport = useIsMobileViewport(1024);
|
||||
|
||||
const viewMode = settings.libraryViewMode;
|
||||
const coverFit = settings.libraryCoverFit;
|
||||
@@ -170,7 +175,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className='view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl'
|
||||
className={clsx('view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl', menuClassName)}
|
||||
onCancel={() => setIsDropdownOpen?.(false)}
|
||||
>
|
||||
{/* View Mode */}
|
||||
@@ -201,11 +206,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
value={columns}
|
||||
disabled={viewMode === 'list'}
|
||||
onChange={handleSetColumns}
|
||||
min={window.innerWidth < 640 ? 1 : window.innerWidth < 1024 ? 2 : 3}
|
||||
max={window.innerWidth < 640 ? 4 : window.innerWidth < 1024 ? 6 : 12}
|
||||
min={isPhoneViewport ? 1 : isCompactViewport ? 2 : 3}
|
||||
max={isPhoneViewport ? 4 : isCompactViewport ? 6 : 12}
|
||||
/>
|
||||
}
|
||||
onClick={() => handleToggleAutoColumns()}
|
||||
transient
|
||||
/>
|
||||
|
||||
{/* Book Covers */}
|
||||
|
||||
@@ -100,6 +100,8 @@ import {
|
||||
} from './utils/libraryUtils';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import LibraryHeader from './components/LibraryHeader';
|
||||
import LibrarySidebar from './components/LibrarySidebar';
|
||||
import { ensureLibraryCategoryFilter } from './utils/categoryFilters';
|
||||
import Bookshelf from './components/Bookshelf';
|
||||
import LibraryEmptyState from './components/LibraryEmptyState';
|
||||
import GroupHeader from './components/GroupHeader';
|
||||
@@ -120,6 +122,7 @@ import DropIndicator from '@/components/DropIndicator';
|
||||
import SettingsDialog from '@/components/settings/SettingsDialog';
|
||||
import ModalPortal from '@/components/ModalPortal';
|
||||
import TransferQueuePanel from './components/TransferQueuePanel';
|
||||
import { Overlay } from '@/components/Overlay';
|
||||
|
||||
/** Skip tiny non-book artifacts during folder auto-scan (matches the manual import dialog default). */
|
||||
const AUTO_IMPORT_MIN_SIZE_BYTES = 20 * 1024;
|
||||
@@ -156,6 +159,19 @@ const LAST_IMPORT_FOLDER_MIN_SIZE_KEY = 'readest:lastImportFolderMinSizeKB';
|
||||
* dialog forces the toggle ON regardless of this value.
|
||||
*/
|
||||
const LAST_IMPORT_FOLDER_READ_IN_PLACE_KEY = 'readest:lastImportFolderReadInPlace';
|
||||
const LIBRARY_SIDEBAR_VISIBLE_KEY = 'readest:librarySidebarVisible';
|
||||
|
||||
const readLibrarySidebarVisibility = (): boolean | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(LIBRARY_SIDEBAR_VISIBLE_KEY);
|
||||
if (stored === '1') return true;
|
||||
if (stored === '0') return false;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const LibraryPageWithSearchParams = () => {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -216,6 +232,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const [isSelectNone, setIsSelectNone] = useState(false);
|
||||
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
|
||||
const [failedImportsModal, setFailedImportsModal] = useState<FailedImport[] | null>(null);
|
||||
const [isDesktopLibraryViewport, setDesktopLibraryViewport] = useState(true);
|
||||
const [isLibrarySidebarDocked, setLibrarySidebarDockedState] = useState(true);
|
||||
const [isLibrarySidebarDrawerOpen, setLibrarySidebarDrawerOpen] = useState(false);
|
||||
const [isLibraryViewportResolved, setLibraryViewportResolved] = useState(false);
|
||||
// "Import from folder" dialog state. Held as a small object rather
|
||||
// than a boolean because we need a default starting directory to seed
|
||||
// the path field, and we want the dialog to remain mounted long
|
||||
@@ -298,6 +318,51 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
useClipUrlIngress();
|
||||
useTransferQueue(libraryLoaded);
|
||||
|
||||
const setLibrarySidebarDocked = useCallback((visible: boolean) => {
|
||||
setLibrarySidebarDockedState(visible);
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(LIBRARY_SIDEBAR_VISIBLE_KEY, visible ? '1' : '0');
|
||||
} catch {
|
||||
// localStorage can be unavailable in constrained webviews; the in-memory
|
||||
// state still gives the user a working toggle for this session.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mediaQuery = window.matchMedia('(min-width: 768px)');
|
||||
const syncSidebarMode = () => {
|
||||
const isDesktop = mediaQuery.matches;
|
||||
setDesktopLibraryViewport(isDesktop);
|
||||
setLibrarySidebarDrawerOpen(false);
|
||||
if (isDesktop) {
|
||||
setLibrarySidebarDockedState(readLibrarySidebarVisibility() ?? true);
|
||||
}
|
||||
setLibraryViewportResolved(true);
|
||||
};
|
||||
|
||||
syncSidebarMode();
|
||||
mediaQuery.addEventListener('change', syncSidebarMode);
|
||||
return () => mediaQuery.removeEventListener('change', syncSidebarMode);
|
||||
}, []);
|
||||
|
||||
const openLibrarySidebar = useCallback(() => {
|
||||
if (isDesktopLibraryViewport) {
|
||||
setLibrarySidebarDocked(true);
|
||||
} else {
|
||||
setLibrarySidebarDrawerOpen(true);
|
||||
}
|
||||
}, [isDesktopLibraryViewport, setLibrarySidebarDocked]);
|
||||
|
||||
const closeLibrarySidebar = useCallback(() => {
|
||||
if (isDesktopLibraryViewport) {
|
||||
setLibrarySidebarDocked(false);
|
||||
} else {
|
||||
setLibrarySidebarDrawerOpen(false);
|
||||
}
|
||||
}, [isDesktopLibraryViewport, setLibrarySidebarDocked]);
|
||||
|
||||
const { pullLibrary, pushLibrary } = useBooksSync();
|
||||
// Library-scoped auto-sync for the active third-party cloud provider (WebDAV /
|
||||
// Google Drive): keeps library.json current on import / delete / book-close,
|
||||
@@ -1592,133 +1657,188 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
}
|
||||
|
||||
const showBookshelf = libraryLoaded || libraryBooks.length > 0;
|
||||
const categoryFilter = ensureLibraryCategoryFilter(searchParams?.get('category'));
|
||||
const isLibrarySidebarVisible = isDesktopLibraryViewport
|
||||
? isLibrarySidebarDocked
|
||||
: isLibrarySidebarDrawerOpen;
|
||||
const isLibrarySidebarDockedVisible = isDesktopLibraryViewport && isLibrarySidebarDocked;
|
||||
const showLibraryHeader = !isLibrarySidebarVisible;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageRef}
|
||||
aria-label={_('Your Library')}
|
||||
className={clsx(
|
||||
'library-page text-base-content full-height flex select-none flex-col overflow-hidden',
|
||||
'library-page text-base-content full-height flex select-none overflow-hidden',
|
||||
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
|
||||
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='relative top-0 z-40 w-full'
|
||||
role='banner'
|
||||
tabIndex={-1}
|
||||
aria-label={_('Library Header')}
|
||||
>
|
||||
<LibraryHeader
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
onPullLibrary={pullLibrary}
|
||||
onImportBooksFromFiles={handleImportBooksFromFiles}
|
||||
onImportBooksFromDirectory={
|
||||
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
|
||||
}
|
||||
onImportBookFromUrl={isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined}
|
||||
onOpenCatalogManager={handleShowOPDSDialog}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
/>
|
||||
<progress
|
||||
aria-label={_('Library Sync Progress')}
|
||||
aria-hidden={isSyncing ? 'false' : 'true'}
|
||||
className={clsx(
|
||||
'progress progress-success absolute bottom-0 left-0 right-0 h-1 translate-y-[2px] transition-opacity duration-200 sm:translate-y-[4px]',
|
||||
isSyncing ? 'opacity-100' : 'opacity-0',
|
||||
{isLibrarySidebarVisible && (
|
||||
<>
|
||||
{!isDesktopLibraryViewport && (
|
||||
<Overlay className='z-40 bg-black/20' onDismiss={closeLibrarySidebar} />
|
||||
)}
|
||||
value={syncProgress * 100}
|
||||
max='100'
|
||||
/>
|
||||
</div>
|
||||
{(loading || isSyncing) && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
</div>
|
||||
<LibrarySidebar
|
||||
books={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
onPullLibrary={pullLibrary}
|
||||
onImportBooksFromFiles={handleImportBooksFromFiles}
|
||||
onImportBooksFromDirectory={
|
||||
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
|
||||
}
|
||||
onImportBookFromUrl={
|
||||
isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined
|
||||
}
|
||||
onOpenCatalogManager={handleShowOPDSDialog}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
onToggleSidebar={closeLibrarySidebar}
|
||||
isViewportResolved={isLibraryViewportResolved}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{currentGroupPath && (
|
||||
<div className='flex min-w-0 flex-1 flex-col overflow-hidden'>
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
currentGroupPath ? 'opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
className={clsx(
|
||||
'relative top-0 z-40 w-full',
|
||||
isLibraryViewportResolved
|
||||
? showLibraryHeader
|
||||
? 'block'
|
||||
: 'hidden'
|
||||
: 'block md:hidden',
|
||||
)}
|
||||
role='banner'
|
||||
tabIndex={-1}
|
||||
aria-label={_('Library Header')}
|
||||
>
|
||||
<div className='flex flex-wrap items-center gap-y-1 px-4 text-base'>
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(undefined)}
|
||||
className='hover:bg-base-300 text-base-content/85 rounded px-2 py-1'
|
||||
>
|
||||
{_('All')}
|
||||
</button>
|
||||
{getBreadcrumbs(currentGroupPath).map((crumb, index, array) => {
|
||||
const isLast = index === array.length - 1;
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<MdChevronRight size={iconSize} className='text-neutral-content' />
|
||||
{isLast ? (
|
||||
<span className='truncate rounded px-2 py-1'>{crumb.name}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(crumb.path)}
|
||||
className='hover:bg-base-300 text-base-content/85 truncate rounded px-2 py-1'
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<LibraryHeader
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
onPullLibrary={pullLibrary}
|
||||
onImportBooksFromFiles={handleImportBooksFromFiles}
|
||||
onImportBooksFromDirectory={
|
||||
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
|
||||
}
|
||||
onImportBookFromUrl={
|
||||
isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined
|
||||
}
|
||||
onOpenCatalogManager={handleShowOPDSDialog}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
onToggleSidebar={openLibrarySidebar}
|
||||
/>
|
||||
<progress
|
||||
aria-label={_('Library Sync Progress')}
|
||||
aria-hidden={isSyncing ? 'false' : 'true'}
|
||||
className={clsx(
|
||||
'progress progress-success absolute bottom-0 left-0 right-0 h-1 translate-y-[2px] transition-opacity duration-200 sm:translate-y-[4px]',
|
||||
isSyncing ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
value={syncProgress * 100}
|
||||
max='100'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{currentSeriesAuthorGroup && (
|
||||
<GroupHeader
|
||||
groupBy={currentSeriesAuthorGroup.groupBy}
|
||||
groupName={currentSeriesAuthorGroup.groupName}
|
||||
/>
|
||||
)}
|
||||
{showBookshelf &&
|
||||
(libraryBooks.some((book) => !book.deletedAt) ? (
|
||||
<div aria-label={_('Your Bookshelf')} className='flex min-h-0 flex-grow flex-col'>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'scroll-container drop-zone flex min-h-0 flex-grow flex-col',
|
||||
isDragging && 'drag-over',
|
||||
)}
|
||||
style={{
|
||||
paddingRight: `${insets.right}px`,
|
||||
paddingLeft: `${insets.left}px`,
|
||||
}}
|
||||
>
|
||||
<DropIndicator />
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
isSelectNone={isSelectNone}
|
||||
onScrollerRef={handleScrollerRef}
|
||||
handleImportBooks={handleImportBooksFromFiles}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete('both')}
|
||||
handleBookPurge={handleBookDelete('purge')}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
booksTransferProgress={booksTransferProgress}
|
||||
handlePushLibrary={pushLibrary}
|
||||
/>
|
||||
{!showLibraryHeader && (
|
||||
<progress
|
||||
aria-label={_('Library Sync Progress')}
|
||||
aria-hidden={isSyncing ? 'false' : 'true'}
|
||||
className={clsx(
|
||||
'progress progress-success hidden h-1 rounded-none transition-opacity duration-200 md:block',
|
||||
isSyncing ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
value={syncProgress * 100}
|
||||
max='100'
|
||||
/>
|
||||
)}
|
||||
{(loading || isSyncing) && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
</div>
|
||||
)}
|
||||
{currentGroupPath && (
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
currentGroupPath ? 'opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className='flex flex-wrap items-center gap-y-1 px-4 py-2 text-base'>
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(undefined)}
|
||||
className='hover:bg-base-300 text-base-content/85 rounded px-2 py-1'
|
||||
>
|
||||
{_('All')}
|
||||
</button>
|
||||
{getBreadcrumbs(currentGroupPath).map((crumb, index, array) => {
|
||||
const isLast = index === array.length - 1;
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<MdChevronRight size={iconSize} className='text-neutral-content' />
|
||||
{isLast ? (
|
||||
<span className='truncate rounded px-2 py-1'>{crumb.name}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(crumb.path)}
|
||||
className='hover:bg-base-300 text-base-content/85 truncate rounded px-2 py-1'
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='hero drop-zone h-screen items-center justify-center'>
|
||||
<DropIndicator />
|
||||
<LibraryEmptyState onImport={handleImportBooksFromFiles} />
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{currentSeriesAuthorGroup && (
|
||||
<GroupHeader
|
||||
groupBy={currentSeriesAuthorGroup.groupBy}
|
||||
groupName={currentSeriesAuthorGroup.groupName}
|
||||
/>
|
||||
)}
|
||||
{showBookshelf &&
|
||||
(libraryBooks.some((book) => !book.deletedAt) ? (
|
||||
<div aria-label={_('Your Bookshelf')} className='flex min-h-0 flex-grow flex-col'>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'scroll-container drop-zone flex min-h-0 flex-grow flex-col',
|
||||
isDragging && 'drag-over',
|
||||
)}
|
||||
style={{
|
||||
paddingRight: `${insets.right}px`,
|
||||
paddingLeft: `${insets.left}px`,
|
||||
}}
|
||||
>
|
||||
<DropIndicator />
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
categoryFilter={categoryFilter}
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
isSelectNone={isSelectNone}
|
||||
onScrollerRef={handleScrollerRef}
|
||||
handleImportBooks={handleImportBooksFromFiles}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete('both')}
|
||||
handleBookPurge={handleBookDelete('purge')}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
booksTransferProgress={booksTransferProgress}
|
||||
handlePushLibrary={pushLibrary}
|
||||
sidebarVisible={isLibrarySidebarDockedVisible}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='hero drop-zone h-screen items-center justify-center'>
|
||||
<DropIndicator />
|
||||
<LibraryEmptyState onImport={handleImportBooksFromFiles} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<NowPlayingBar isSelectMode={isSelectMode} />
|
||||
{showDetailsBook && (
|
||||
<BookDetailModal
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Book } from '@/types/book';
|
||||
import { formatAuthors, formatLanguage, formatPublisher } from '@/utils/book';
|
||||
import { parseAuthors } from './libraryUtils';
|
||||
|
||||
export type LibraryCategoryFilter =
|
||||
| 'all'
|
||||
| 'author'
|
||||
| 'publisher'
|
||||
| 'year'
|
||||
| 'language'
|
||||
| 'format'
|
||||
| 'subject'
|
||||
| 'tag';
|
||||
|
||||
const LIBRARY_CATEGORY_FILTERS: LibraryCategoryFilter[] = [
|
||||
'all',
|
||||
'author',
|
||||
'publisher',
|
||||
'year',
|
||||
'language',
|
||||
'format',
|
||||
'subject',
|
||||
'tag',
|
||||
];
|
||||
|
||||
export type CategoryValue = {
|
||||
value: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const ensureLibraryCategoryFilter = (
|
||||
value: string | null | undefined,
|
||||
): LibraryCategoryFilter =>
|
||||
LIBRARY_CATEGORY_FILTERS.includes(value as LibraryCategoryFilter)
|
||||
? (value as LibraryCategoryFilter)
|
||||
: 'all';
|
||||
|
||||
const getPublishedYear = (book: Book) =>
|
||||
typeof book.metadata?.published === 'string'
|
||||
? book.metadata.published.match(/\d{4}/)?.[0]
|
||||
: undefined;
|
||||
|
||||
const getSubjects = (book: Book): string[] => {
|
||||
const subject = book.metadata?.subject;
|
||||
if (!subject) return [];
|
||||
if (Array.isArray(subject)) return subject.map((item) => `${item}`.trim()).filter(Boolean);
|
||||
if (typeof subject === 'string') {
|
||||
return subject
|
||||
.split(/[,;,、]/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [formatAuthors(subject)].filter(Boolean);
|
||||
};
|
||||
|
||||
export const getBookCategoryValues = (book: Book, category: LibraryCategoryFilter): string[] => {
|
||||
switch (category) {
|
||||
case 'author':
|
||||
return parseAuthors(formatAuthors(book.author, book.primaryLanguage));
|
||||
case 'publisher': {
|
||||
const publisher = formatPublisher(book.metadata?.publisher || '').trim();
|
||||
return publisher ? [publisher] : [];
|
||||
}
|
||||
case 'year': {
|
||||
const year = getPublishedYear(book);
|
||||
return year ? [year] : [];
|
||||
}
|
||||
case 'language': {
|
||||
const language = formatLanguage(book.metadata?.language).trim();
|
||||
return language ? [language] : [];
|
||||
}
|
||||
case 'format':
|
||||
return book.format ? [book.format] : [];
|
||||
case 'subject':
|
||||
return getSubjects(book);
|
||||
case 'tag':
|
||||
return book.tags?.map((tag) => tag.trim()).filter(Boolean) || [];
|
||||
case 'all':
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const countCategoryValues = (books: Book[], category: LibraryCategoryFilter) => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const book of books) {
|
||||
for (const value of getBookCategoryValues(book, category)) {
|
||||
counts.set(value, (counts.get(value) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((a, b) => a.value.localeCompare(b.value, navigator.language));
|
||||
};
|
||||
@@ -651,6 +651,7 @@ export type BookContextMenuItemId =
|
||||
| 'markAbandoned'
|
||||
| 'clearStatus'
|
||||
| 'showDetails'
|
||||
| 'bilingual'
|
||||
| 'showInFinder'
|
||||
| 'searchGoodreads'
|
||||
| 'download'
|
||||
@@ -750,7 +751,11 @@ export const getBookContextMenuItemIds = (book: Book): BookContextMenuItemId[] =
|
||||
) {
|
||||
ids.push('clearStatus');
|
||||
}
|
||||
ids.push('showDetails', 'showInFinder', 'searchGoodreads');
|
||||
ids.push('showDetails');
|
||||
if (book.format?.toUpperCase() === 'EPUB') {
|
||||
ids.push('bilingual');
|
||||
}
|
||||
ids.push('showInFinder', 'searchGoodreads');
|
||||
if (book.uploadedAt && !book.downloadedAt) ids.push('download');
|
||||
if (!book.uploadedAt && book.downloadedAt) ids.push('upload');
|
||||
// Share is offered for any local-or-uploaded book; the dialog uploads first
|
||||
|
||||
@@ -17,6 +17,7 @@ import FoliateViewer from './FoliateViewer';
|
||||
import SectionInfo from './SectionInfo';
|
||||
import HeaderBar from './HeaderBar';
|
||||
import PageNavigationButtons from './PageNavigationButtons';
|
||||
import ReviewModeController from './ReviewModeController';
|
||||
import FooterBar from './footerbar/FooterBar';
|
||||
import ProgressBar from './ProgressBar';
|
||||
import Ribbon from './Ribbon';
|
||||
@@ -171,6 +172,7 @@ const BookCellInner: React.FC<BookCellProps> = ({
|
||||
gridInsets={gridInsets}
|
||||
contentInsets={contentInsets}
|
||||
/>
|
||||
<ReviewModeController bookKey={bookKey} bookDoc={bookDoc} />
|
||||
{viewSettings.vertical && viewSettings.scrolled && (
|
||||
<>
|
||||
{(showFooter || viewSettings.doubleBorder) && (
|
||||
@@ -337,7 +339,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook, onGoToLibr
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('books-grid bg-base-100 relative grid h-full flex-grow')}
|
||||
className={clsx('books-grid bg-base-100 relative grid h-full min-w-0 flex-grow')}
|
||||
style={gridStyle}
|
||||
role='main'
|
||||
aria-label={_('Books Content')}
|
||||
|
||||
@@ -27,6 +27,7 @@ import QuickActionMenu from './annotator/QuickActionMenu';
|
||||
import SidebarToggler from './SidebarToggler';
|
||||
import BookmarkToggler from './BookmarkToggler';
|
||||
import NotebookToggler from './NotebookToggler';
|
||||
import ReviewModeToggler from './ReviewModeToggler';
|
||||
import SettingsToggler from './SettingsToggler';
|
||||
import TranslationToggler from './TranslationToggler';
|
||||
import ViewMenu from './ViewMenu';
|
||||
@@ -282,6 +283,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
|
||||
<div className='header-tools-end bg-base-100 z-20 ms-auto flex h-full min-w-max items-center gap-x-4 ps-2 max-[350px]:gap-x-2'>
|
||||
{!isHeaderCompact && <SettingsToggler bookKey={bookKey} />}
|
||||
<ReviewModeToggler bookKey={bookKey} />
|
||||
<NotebookToggler bookKey={bookKey} />
|
||||
<Dropdown
|
||||
label={_('View Options')}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useReviewModeStore } from '@/store/reviewModeStore';
|
||||
import { useGamepad } from '@/hooks/useGamepad';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
@@ -37,6 +38,7 @@ import Spinner from '@/components/Spinner';
|
||||
import SideBar from './sidebar/SideBar';
|
||||
import Notebook from './notebook/Notebook';
|
||||
import BooksGrid from './BooksGrid';
|
||||
import ReviewPanel from './ReviewPanel';
|
||||
import SettingsDialog from '@/components/settings/SettingsDialog';
|
||||
|
||||
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
|
||||
@@ -50,6 +52,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
const { getConfig, getBookData, saveConfig } = useBookDataStore();
|
||||
const { getView, setBookKeys, getViewSettings } = useReaderStore();
|
||||
const { initViewState, getViewState, clearViewState } = useReaderStore();
|
||||
const clearReviewBook = useReviewModeStore((state) => state.clearBook);
|
||||
const { isSettingsDialogOpen, settingsDialogBookKey } = useSettingsStore();
|
||||
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
|
||||
const [shareDialogState, setShareDialogState] = useState<{
|
||||
@@ -176,6 +179,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
|
||||
const saveConfigAndCloseBook = async (bookKey: string, keepTTSAlive = false) => {
|
||||
console.log('Closing book', bookKey);
|
||||
clearReviewBook(bookKey);
|
||||
|
||||
const viewState = getViewState(bookKey);
|
||||
if (viewState?.isPrimary && appService?.isDesktopApp) {
|
||||
@@ -208,7 +212,17 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
navigateBackToLibrary();
|
||||
};
|
||||
|
||||
const handleCloseReaderToLibrary = () => {
|
||||
const handleCloseReaderToLibrary = async () => {
|
||||
const dirtyReview = Object.values(useReviewModeStore.getState().books).some(
|
||||
(book) => book.hasUnsavedEdit,
|
||||
);
|
||||
if (
|
||||
dirtyReview &&
|
||||
appService &&
|
||||
!(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return handleCloseBooks(true);
|
||||
};
|
||||
|
||||
@@ -226,6 +240,16 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
// SPA navigation in the main window (or on web) keeps the webview alive:
|
||||
// TTS may continue headless. Non-main Tauri windows close their webview
|
||||
// below, but their per-window TTS dies with the window either way.
|
||||
const dirtyReview = Object.values(useReviewModeStore.getState().books).some(
|
||||
(book) => book.hasUnsavedEdit,
|
||||
);
|
||||
if (
|
||||
dirtyReview &&
|
||||
appService &&
|
||||
!(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleCloseBooks(true);
|
||||
if (isTauriAppPlatform()) {
|
||||
const currentWindow = getCurrentWindow();
|
||||
@@ -246,6 +270,14 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
// Header X / pane close: an SPA-side close on web and the main window.
|
||||
// The Tauri reader-window branches below destroy their webview, which
|
||||
// takes the per-window TTS with it either way.
|
||||
const review = useReviewModeStore.getState().books[bookKey];
|
||||
if (
|
||||
review?.hasUnsavedEdit &&
|
||||
appService &&
|
||||
!(await appService.ask('当前段落有未保存的修改,确定关闭这本书吗?'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
saveConfigAndCloseBook(bookKey, true);
|
||||
if (sideBarBookKey === bookKey) {
|
||||
setSideBarBookKey(getNextBookKey(sideBarBookKey));
|
||||
@@ -291,6 +323,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
onGoToLibrary={handleCloseBooksToLibrary}
|
||||
/>
|
||||
{isSettingsDialogOpen && <SettingsDialog bookKey={settingsDialogBookKey} />}
|
||||
<ReviewPanel />
|
||||
<Notebook />
|
||||
{showDetailsBook && (
|
||||
<BookDetailModal
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
import type { ReviewRow } from '@/services/reviewEditorService';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useReviewModeStore } from '@/store/reviewModeStore';
|
||||
|
||||
const STYLE_ID = 'readest-inline-review-style';
|
||||
const ROW_ATTR = 'data-readest-review-row-id';
|
||||
const ROLE_ATTR = 'data-readest-review-role';
|
||||
const ORIGINAL_HTML_ATTR = 'data-readest-review-original-html';
|
||||
|
||||
const reviewHtmlPurifyOptions = {
|
||||
ALLOWED_TAGS: [
|
||||
'ruby',
|
||||
'rb',
|
||||
'rt',
|
||||
'rp',
|
||||
'span',
|
||||
'mark',
|
||||
'br',
|
||||
'em',
|
||||
'strong',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
's',
|
||||
'sub',
|
||||
'sup',
|
||||
],
|
||||
ALLOWED_ATTR: ['class', 'title'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
};
|
||||
|
||||
const sanitizeReviewHtml = (html: string) =>
|
||||
DOMPurify.sanitize(html || '', reviewHtmlPurifyOptions);
|
||||
|
||||
const emptyReviewRows: ReviewRow[] = [];
|
||||
|
||||
const normalizeFile = (value?: string) =>
|
||||
(() => {
|
||||
const raw = (value || '').split('#')[0] || '';
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
})()
|
||||
.replaceAll('\\', '/')
|
||||
.replace(/^\/+/, '')
|
||||
.toLowerCase();
|
||||
|
||||
const sameFile = (rowFile: string, sectionHref?: string) => {
|
||||
const row = normalizeFile(rowFile);
|
||||
const href = normalizeFile(sectionHref);
|
||||
if (!row || !href) return false;
|
||||
return row === href || row.endsWith(`/${href}`) || href.endsWith(`/${row}`);
|
||||
};
|
||||
|
||||
const sectionHrefAt = (bookDoc: BookDoc, index: number | undefined) => {
|
||||
const section = bookDoc.sections[index ?? -1];
|
||||
return section?.href || section?.id;
|
||||
};
|
||||
|
||||
const clearReviewMarks = (doc: Document) => {
|
||||
doc.getElementById(STYLE_ID)?.remove();
|
||||
doc.querySelectorAll(`[${ROW_ATTR}]`).forEach((element) => {
|
||||
const originalHtml = element.getAttribute(ORIGINAL_HTML_ATTR);
|
||||
if (originalHtml !== null) {
|
||||
element.innerHTML = originalHtml;
|
||||
}
|
||||
element.removeAttribute(ROW_ATTR);
|
||||
element.removeAttribute(ROLE_ATTR);
|
||||
element.removeAttribute(ORIGINAL_HTML_ATTR);
|
||||
element.classList.remove(
|
||||
'readest-review-source',
|
||||
'readest-review-target',
|
||||
'readest-review-active',
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const ensureReviewStyle = (doc: Document) => {
|
||||
if (doc.getElementById(STYLE_ID)) return;
|
||||
const style = doc.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
[${ROW_ATTR}] {
|
||||
cursor: pointer;
|
||||
user-select: text;
|
||||
outline-offset: 0.18em;
|
||||
transition: background-color 120ms ease, outline-color 120ms ease;
|
||||
}
|
||||
.readest-review-source {
|
||||
background: rgba(127, 127, 127, 0.12);
|
||||
outline: 1px dashed rgba(127, 127, 127, 0.64);
|
||||
}
|
||||
.readest-review-target {
|
||||
background: rgba(22, 163, 74, 0.14);
|
||||
outline: 1px solid rgba(22, 163, 74, 0.72);
|
||||
}
|
||||
.readest-review-active {
|
||||
background: rgba(37, 99, 235, 0.18) !important;
|
||||
outline: 2px solid currentColor !important;
|
||||
}
|
||||
`;
|
||||
doc.head.appendChild(style);
|
||||
};
|
||||
|
||||
const closestReviewTarget = (target: EventTarget | null): HTMLElement | null => {
|
||||
const element = target as Element | null;
|
||||
if (!element || typeof element.closest !== 'function') return null;
|
||||
if (element.closest('a, button, input, textarea, select, video, audio')) return null;
|
||||
return element.closest(`[${ROW_ATTR}]`) as HTMLElement | null;
|
||||
};
|
||||
|
||||
const closestReviewTargetFromNode = (node: Node | null): HTMLElement | null => {
|
||||
const element =
|
||||
node?.nodeType === Node.ELEMENT_NODE
|
||||
? (node as Element)
|
||||
: (node?.parentElement as Element | null);
|
||||
if (!element || typeof element.closest !== 'function') return null;
|
||||
return element.closest(`[${ROW_ATTR}]`) as HTMLElement | null;
|
||||
};
|
||||
|
||||
const selectedReviewTarget = (doc: Document): HTMLElement | null => {
|
||||
const selection = doc.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
|
||||
const range = selection.getRangeAt(0);
|
||||
return (
|
||||
closestReviewTargetFromNode(range.commonAncestorContainer) ||
|
||||
closestReviewTargetFromNode(range.startContainer) ||
|
||||
closestReviewTargetFromNode(range.endContainer)
|
||||
);
|
||||
};
|
||||
|
||||
const markParagraph = (
|
||||
element: Element | undefined,
|
||||
row: ReviewRow,
|
||||
role: 'source' | 'target',
|
||||
active: boolean,
|
||||
) => {
|
||||
const htmlElement = element as HTMLElement | undefined;
|
||||
if (!htmlElement || typeof htmlElement.setAttribute !== 'function') return;
|
||||
htmlElement.setAttribute(ROW_ATTR, row.id);
|
||||
htmlElement.setAttribute(ROLE_ATTR, role);
|
||||
htmlElement.classList.add(role === 'source' ? 'readest-review-source' : 'readest-review-target');
|
||||
htmlElement.classList.toggle('readest-review-active', active);
|
||||
if (role === 'target') {
|
||||
if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) {
|
||||
htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML);
|
||||
}
|
||||
htmlElement.innerHTML = sanitizeReviewHtml(row.current_html);
|
||||
}
|
||||
};
|
||||
|
||||
const applyActiveReviewMark = (doc: Document, selectedRowId: string) => {
|
||||
doc.querySelectorAll(`[${ROW_ATTR}]`).forEach((element) => {
|
||||
element.classList.toggle(
|
||||
'readest-review-active',
|
||||
element.getAttribute(ROW_ATTR) === selectedRowId,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const applyReviewMarks = (
|
||||
doc: Document,
|
||||
sectionHref: string | undefined,
|
||||
rows: ReviewRow[],
|
||||
selectedRowId: string,
|
||||
) => {
|
||||
clearReviewMarks(doc);
|
||||
const sectionRows = rows.filter((row) => sameFile(row.file, sectionHref));
|
||||
if (!sectionRows.length) return;
|
||||
|
||||
ensureReviewStyle(doc);
|
||||
const paragraphs = Array.from(doc.querySelectorAll('p'));
|
||||
for (const row of sectionRows) {
|
||||
const active = row.id === selectedRowId;
|
||||
markParagraph(paragraphs[Number(row.ja_p_index)], row, 'source', active);
|
||||
markParagraph(paragraphs[Number(row.cn_p_index)], row, 'target', active);
|
||||
}
|
||||
};
|
||||
|
||||
export const __testing = {
|
||||
applyReviewMarks,
|
||||
applyActiveReviewMark,
|
||||
clearReviewMarks,
|
||||
sectionHrefAt,
|
||||
};
|
||||
|
||||
const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
|
||||
bookKey,
|
||||
bookDoc,
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const view = useReaderStore((state) => state.viewStates[bookKey]?.view);
|
||||
const enabled = useReviewModeStore((state) => !!state.books[bookKey]?.enabled);
|
||||
const rows = useReviewModeStore((state) => state.books[bookKey]?.rows ?? emptyReviewRows);
|
||||
const selectedRowId = useReviewModeStore((state) => state.books[bookKey]?.selectedRowId || '');
|
||||
const selectRow = useReviewModeStore((state) => state.selectRow);
|
||||
const selectedRowIdRef = useRef(selectedRowId);
|
||||
|
||||
useEffect(() => {
|
||||
selectedRowIdRef.current = selectedRowId;
|
||||
}, [selectedRowId]);
|
||||
|
||||
const selectReviewRow = async (rowId: string) => {
|
||||
const state = useReviewModeStore.getState();
|
||||
const previousBookKey = state.activeBookKey;
|
||||
const current = state.books[previousBookKey || ''];
|
||||
if (
|
||||
current?.hasUnsavedEdit &&
|
||||
(state.activeBookKey !== bookKey || current.selectedRowId !== rowId) &&
|
||||
appService &&
|
||||
!(await appService.ask('当前段落有未保存的修改,确定切换到其他段落吗?'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (previousBookKey && current?.hasUnsavedEdit) {
|
||||
state.setBookUnsavedEdit(previousBookKey, false);
|
||||
}
|
||||
selectRow(bookKey, rowId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!view) return;
|
||||
|
||||
const cleanupLoadedDocs = () => {
|
||||
for (const { doc } of view.renderer.getContents()) {
|
||||
clearReviewMarks(doc);
|
||||
}
|
||||
};
|
||||
|
||||
if (!enabled || !rows.length) {
|
||||
cleanupLoadedDocs();
|
||||
return;
|
||||
}
|
||||
|
||||
const applyToLoadedDocs = () => {
|
||||
for (const { doc, index } of view.renderer.getContents()) {
|
||||
applyReviewMarks(doc, sectionHrefAt(bookDoc, index), rows, selectedRowIdRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (event: Event) => {
|
||||
const target = closestReviewTarget(event.target);
|
||||
if (!target) return;
|
||||
const rowId = target.getAttribute(ROW_ATTR);
|
||||
if (!rowId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if ('stopImmediatePropagation' in event) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
void selectReviewRow(rowId);
|
||||
};
|
||||
|
||||
const handleSelectionEnd = (event: Event) => {
|
||||
const doc = event.currentTarget as Document | null;
|
||||
if (!doc?.getSelection) return;
|
||||
window.setTimeout(() => {
|
||||
const target = selectedReviewTarget(doc);
|
||||
const rowId = target?.getAttribute(ROW_ATTR);
|
||||
if (rowId) void selectReviewRow(rowId);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const listenedDocs = new Set<Document>();
|
||||
|
||||
const addDocListeners = (doc: Document) => {
|
||||
if (listenedDocs.has(doc)) return;
|
||||
listenedDocs.add(doc);
|
||||
doc.addEventListener('click', handleClick, true);
|
||||
doc.addEventListener('mouseup', handleSelectionEnd, true);
|
||||
doc.addEventListener('touchend', handleSelectionEnd, true);
|
||||
doc.addEventListener('keyup', handleSelectionEnd, true);
|
||||
};
|
||||
|
||||
const removeDocListeners = (doc: Document) => {
|
||||
listenedDocs.delete(doc);
|
||||
doc.removeEventListener('click', handleClick, true);
|
||||
doc.removeEventListener('mouseup', handleSelectionEnd, true);
|
||||
doc.removeEventListener('touchend', handleSelectionEnd, true);
|
||||
doc.removeEventListener('keyup', handleSelectionEnd, true);
|
||||
};
|
||||
|
||||
const handleLoad = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ doc?: Document; index?: number }>).detail;
|
||||
if (!detail?.doc) return;
|
||||
applyReviewMarks(
|
||||
detail.doc,
|
||||
sectionHrefAt(bookDoc, detail.index),
|
||||
rows,
|
||||
selectedRowIdRef.current,
|
||||
);
|
||||
addDocListeners(detail.doc);
|
||||
};
|
||||
|
||||
for (const { doc } of view.renderer.getContents()) {
|
||||
addDocListeners(doc);
|
||||
}
|
||||
applyToLoadedDocs();
|
||||
view.addEventListener('load', handleLoad);
|
||||
|
||||
return () => {
|
||||
view.removeEventListener('load', handleLoad);
|
||||
for (const doc of listenedDocs) {
|
||||
removeDocListeners(doc);
|
||||
clearReviewMarks(doc);
|
||||
}
|
||||
};
|
||||
}, [appService, bookDoc, bookKey, enabled, rows, selectRow, view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!view || !enabled || !rows.length) return;
|
||||
for (const { doc } of view.renderer.getContents()) {
|
||||
applyActiveReviewMark(doc, selectedRowId);
|
||||
}
|
||||
}, [bookKey, enabled, rows.length, selectedRowId, view]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ReviewModeController;
|
||||
@@ -0,0 +1,129 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useReviewModeStore } from '@/store/reviewModeStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import {
|
||||
canLaunchInlineReviewEditor,
|
||||
launchInlineReviewEditor,
|
||||
loadInlineReviewData,
|
||||
} from '@/services/reviewEditorService';
|
||||
|
||||
interface ReviewModeTogglerProps {
|
||||
bookKey: string;
|
||||
}
|
||||
|
||||
const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { setHoveredBookKey } = useReaderStore();
|
||||
const bookData = useBookDataStore((state) => state.getBookData(bookKey));
|
||||
const reviewState = useReviewModeStore((state) => state.books[bookKey]);
|
||||
const {
|
||||
setActiveBookKey,
|
||||
setPanelVisible,
|
||||
setBookLoading,
|
||||
setBookError,
|
||||
setBookEnabled,
|
||||
setBookData,
|
||||
} = useReviewModeStore();
|
||||
|
||||
const enabled = !!reviewState?.enabled;
|
||||
const loading = !!reviewState?.loading;
|
||||
|
||||
if (!canLaunchInlineReviewEditor(appService) || bookData?.book?.format !== 'EPUB') return null;
|
||||
|
||||
const handleToggleReviewMode = async () => {
|
||||
if (appService?.isMobile) {
|
||||
setHoveredBookKey('');
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
if (
|
||||
reviewState?.hasUnsavedEdit &&
|
||||
appService &&
|
||||
!(await appService.ask('当前段落有未保存的修改,确定关闭审校模式吗?'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setBookEnabled(bookKey, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bookData?.book) {
|
||||
setBookError(bookKey, _('Unable to open book'));
|
||||
return;
|
||||
}
|
||||
|
||||
const activeState = useReviewModeStore.getState();
|
||||
const activeReview = activeState.books[activeState.activeBookKey || ''];
|
||||
if (
|
||||
activeState.activeBookKey !== bookKey &&
|
||||
activeReview?.hasUnsavedEdit &&
|
||||
appService &&
|
||||
!(await appService.ask('另一册书的当前段落有未保存修改,确定切换审校书籍吗?'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (activeState.activeBookKey && activeReview?.hasUnsavedEdit) {
|
||||
activeState.setBookUnsavedEdit(activeState.activeBookKey, false);
|
||||
}
|
||||
|
||||
setActiveBookKey(bookKey);
|
||||
setPanelVisible(true);
|
||||
setBookLoading(bookKey, true);
|
||||
|
||||
try {
|
||||
const launch = await launchInlineReviewEditor(appService, bookData.book);
|
||||
const data = await loadInlineReviewData(
|
||||
launch.url,
|
||||
launch.sessionId,
|
||||
launch.accessToken,
|
||||
);
|
||||
setBookData(bookKey, {
|
||||
baseUrl: launch.url,
|
||||
accessToken: launch.accessToken,
|
||||
sessionId: launch.sessionId,
|
||||
reviewRoot: launch.reviewRoot,
|
||||
version: launch.version,
|
||||
session: data.session,
|
||||
rows: data.rows,
|
||||
gptConfig: data.gptConfig,
|
||||
selectedRowId: data.rows[0]?.id || '',
|
||||
});
|
||||
setBookEnabled(bookKey, true);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: data.rows.length
|
||||
? `审校模式已开启,共 ${data.rows.length} 段`
|
||||
: '审校模式已开启,但当前 EPUB 没有识别到双语段落',
|
||||
timeout: 2500,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setBookError(bookKey, message);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message,
|
||||
timeout: 3500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={<span className='text-sm font-semibold leading-none'>校</span>}
|
||||
onClick={handleToggleReviewMode}
|
||||
disabled={loading}
|
||||
label={enabled ? _('Disable Review Mode') : _('Review Mode')}
|
||||
className={clsx(enabled && 'bg-base-300/50')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReviewModeToggler;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react';
|
||||
|
||||
type Position = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type UseReviewPanelDragOptions = {
|
||||
enabled: boolean;
|
||||
panelRef: RefObject<HTMLElement | null>;
|
||||
margin: number;
|
||||
defaultWidthRatio: number;
|
||||
topInset: number;
|
||||
};
|
||||
|
||||
const pointerTargetIsInteractive = (target: EventTarget | null) =>
|
||||
target instanceof HTMLElement &&
|
||||
Boolean(target.closest('button, input, textarea, select, a, .drag-bar'));
|
||||
|
||||
export const useReviewPanelDrag = ({
|
||||
enabled,
|
||||
panelRef,
|
||||
margin,
|
||||
defaultWidthRatio,
|
||||
topInset,
|
||||
}: UseReviewPanelDragOptions) => {
|
||||
const initialPosition = () => ({
|
||||
x:
|
||||
typeof globalThis.window === 'undefined'
|
||||
? margin
|
||||
: Math.max(
|
||||
margin,
|
||||
globalThis.window.innerWidth -
|
||||
Math.round(globalThis.window.innerWidth * defaultWidthRatio) -
|
||||
margin,
|
||||
),
|
||||
y: typeof globalThis.window === 'undefined' ? margin : topInset + margin,
|
||||
});
|
||||
const panelPositionRef = useRef<Position>(initialPosition());
|
||||
const [panelPosition, setPanelPosition] = useState(panelPositionRef.current);
|
||||
|
||||
const clampPanelPosition = useCallback(
|
||||
(next: Position) => {
|
||||
const panel = panelRef.current;
|
||||
const width = panel?.offsetWidth || Math.round(window.innerWidth * defaultWidthRatio);
|
||||
const height = panel?.offsetHeight || Math.round(window.innerHeight * 0.82);
|
||||
return {
|
||||
x: Math.min(Math.max(margin, next.x), Math.max(margin, window.innerWidth - width - margin)),
|
||||
y: Math.min(
|
||||
Math.max(margin, next.y),
|
||||
Math.max(margin, window.innerHeight - height - margin),
|
||||
),
|
||||
};
|
||||
},
|
||||
[defaultWidthRatio, margin, panelRef],
|
||||
);
|
||||
|
||||
const updatePanelPosition = useCallback(
|
||||
(next: Position) => {
|
||||
const clamped = clampPanelPosition(next);
|
||||
panelPositionRef.current = clamped;
|
||||
setPanelPosition(clamped);
|
||||
},
|
||||
[clampPanelPosition],
|
||||
);
|
||||
|
||||
const moveToDefaultPosition = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
const panel = panelRef.current;
|
||||
const width = panel?.offsetWidth || Math.round(window.innerWidth * defaultWidthRatio);
|
||||
updatePanelPosition({
|
||||
x: window.innerWidth - width - margin,
|
||||
y: topInset + margin,
|
||||
});
|
||||
}, [defaultWidthRatio, enabled, margin, panelRef, topInset, updatePanelPosition]);
|
||||
|
||||
const handlePanelDragStart = useCallback(
|
||||
(event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!enabled || pointerTargetIsInteractive(event.target)) return;
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startPosition = panelPositionRef.current;
|
||||
document.body.style.userSelect = 'none';
|
||||
document.documentElement.style.cursor = 'move';
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
updatePanelPosition({
|
||||
x: startPosition.x + moveEvent.clientX - startX,
|
||||
y: startPosition.y + moveEvent.clientY - startY,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
document.body.style.userSelect = '';
|
||||
document.documentElement.style.cursor = '';
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleEnd);
|
||||
window.removeEventListener('pointercancel', handleEnd);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleEnd);
|
||||
window.addEventListener('pointercancel', handleEnd);
|
||||
},
|
||||
[enabled, updatePanelPosition],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handleResize = () => updatePanelPosition(panelPositionRef.current);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [enabled, updatePanelPosition]);
|
||||
|
||||
return {
|
||||
panelPosition,
|
||||
panelPositionRef,
|
||||
updatePanelPosition,
|
||||
moveToDefaultPosition,
|
||||
handlePanelDragStart,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useCallback } from 'react';
|
||||
import type {
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
MutableRefObject,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
type Position = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type UseReviewPanelFloatingResizeOptions = {
|
||||
enabled: boolean;
|
||||
panelRef: RefObject<HTMLElement | null>;
|
||||
panelPositionRef: MutableRefObject<Position>;
|
||||
panelWidth: string;
|
||||
panelHeight: string;
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
setPanelWidth: (width: string) => void;
|
||||
setPanelHeight: (height: string) => void;
|
||||
updatePanelPosition: (position: Position) => void;
|
||||
};
|
||||
|
||||
const toPanelPercent = (fraction: number) => `${Math.round(fraction * 10000) / 100}%`;
|
||||
const toPanelVh = (fraction: number) => `${Math.round(fraction * 10000) / 100}vh`;
|
||||
|
||||
export const useReviewPanelFloatingResize = ({
|
||||
enabled,
|
||||
panelRef,
|
||||
panelPositionRef,
|
||||
panelWidth,
|
||||
panelHeight,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
setPanelWidth,
|
||||
setPanelHeight,
|
||||
updatePanelPosition,
|
||||
}: UseReviewPanelFloatingResizeOptions) => {
|
||||
const resizeWidth = useCallback(
|
||||
(clientX: number, rightEdge: number) => {
|
||||
const fraction = Math.max(
|
||||
minWidth,
|
||||
Math.min(maxWidth, (rightEdge - clientX) / window.innerWidth),
|
||||
);
|
||||
const width = fraction * window.innerWidth;
|
||||
setPanelWidth(toPanelPercent(fraction));
|
||||
updatePanelPosition({ x: rightEdge - width, y: panelPositionRef.current.y });
|
||||
},
|
||||
[maxWidth, minWidth, panelPositionRef, setPanelWidth, updatePanelPosition],
|
||||
);
|
||||
|
||||
const resizeHeight = useCallback(
|
||||
(clientY: number, topEdge: number) => {
|
||||
const fraction = Math.max(
|
||||
minHeight,
|
||||
Math.min(maxHeight, (clientY - topEdge) / window.innerHeight),
|
||||
);
|
||||
setPanelHeight(toPanelVh(fraction));
|
||||
requestAnimationFrame(() => updatePanelPosition(panelPositionRef.current));
|
||||
},
|
||||
[maxHeight, minHeight, panelPositionRef, setPanelHeight, updatePanelPosition],
|
||||
);
|
||||
|
||||
const handleWidthPointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!enabled) return;
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const rightEdge = panel.getBoundingClientRect().right;
|
||||
document.body.style.pointerEvents = 'none';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.documentElement.style.cursor = 'col-resize';
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
resizeWidth(moveEvent.clientX, rightEdge);
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
document.body.style.pointerEvents = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.documentElement.style.cursor = '';
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleEnd);
|
||||
window.removeEventListener('pointercancel', handleEnd);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleEnd);
|
||||
window.addEventListener('pointercancel', handleEnd);
|
||||
},
|
||||
[enabled, panelRef, resizeWidth],
|
||||
);
|
||||
|
||||
const handleHeightPointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!enabled) return;
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const topEdge = panel.getBoundingClientRect().top;
|
||||
document.body.style.pointerEvents = 'none';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.documentElement.style.cursor = 'row-resize';
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
resizeHeight(moveEvent.clientY, topEdge);
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
document.body.style.pointerEvents = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.documentElement.style.cursor = '';
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleEnd);
|
||||
window.removeEventListener('pointercancel', handleEnd);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleEnd);
|
||||
window.addEventListener('pointercancel', handleEnd);
|
||||
},
|
||||
[enabled, panelRef, resizeHeight],
|
||||
);
|
||||
|
||||
const handleWidthKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent) => {
|
||||
if (!enabled || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return;
|
||||
const currentWidth = parseFloat(panelWidth) / 100;
|
||||
const nextWidth =
|
||||
event.key === 'ArrowLeft'
|
||||
? Math.min(maxWidth, currentWidth + 0.02)
|
||||
: Math.max(minWidth, currentWidth - 0.02);
|
||||
const panel = panelRef.current;
|
||||
const rightEdge =
|
||||
panel?.getBoundingClientRect().right ||
|
||||
panelPositionRef.current.x + currentWidth * window.innerWidth;
|
||||
setPanelWidth(toPanelPercent(nextWidth));
|
||||
updatePanelPosition({
|
||||
x: rightEdge - nextWidth * window.innerWidth,
|
||||
y: panelPositionRef.current.y,
|
||||
});
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
[
|
||||
enabled,
|
||||
maxWidth,
|
||||
minWidth,
|
||||
panelPositionRef,
|
||||
panelRef,
|
||||
panelWidth,
|
||||
setPanelWidth,
|
||||
updatePanelPosition,
|
||||
],
|
||||
);
|
||||
|
||||
const handleHeightKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent) => {
|
||||
if (!enabled || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return;
|
||||
const currentHeight = parseFloat(panelHeight) / 100;
|
||||
const nextHeight =
|
||||
event.key === 'ArrowDown'
|
||||
? Math.min(maxHeight, currentHeight + 0.03)
|
||||
: Math.max(minHeight, currentHeight - 0.03);
|
||||
setPanelHeight(toPanelVh(nextHeight));
|
||||
requestAnimationFrame(() => updatePanelPosition(panelPositionRef.current));
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
[
|
||||
enabled,
|
||||
maxHeight,
|
||||
minHeight,
|
||||
panelHeight,
|
||||
panelPositionRef,
|
||||
setPanelHeight,
|
||||
updatePanelPosition,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
handleWidthPointerDown,
|
||||
handleWidthKeyDown,
|
||||
handleHeightPointerDown,
|
||||
handleHeightKeyDown,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,14 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, isValidElement, ReactElement, ReactNode, useRef, useId } from 'react';
|
||||
import React, {
|
||||
useState,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useRef,
|
||||
useId,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { useDropdownContext } from '@/context/DropdownContext';
|
||||
import { Overlay } from './Overlay';
|
||||
import MenuItem from './MenuItem';
|
||||
|
||||
interface DropdownProps {
|
||||
@@ -74,6 +81,7 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
const dropdownId = useId();
|
||||
const context = useDropdownContext();
|
||||
const isOpen = context ? context.openDropdownId === dropdownId : false;
|
||||
const hasOpenDropdown = context ? context.openDropdownId !== null : isOpen;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
@@ -108,6 +116,21 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (containerRef.current?.contains(event.target as Node)) return;
|
||||
if (event.target instanceof Element && event.target.closest('.dropdown-container') !== null) {
|
||||
return;
|
||||
}
|
||||
setIsDropdownOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
const childrenWithToggle = isValidElement(children)
|
||||
? React.cloneElement(children, {
|
||||
...(typeof children.type !== 'string' && {
|
||||
@@ -120,8 +143,7 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={clsx('dropdown-container flex', containerClassName)}>
|
||||
{isOpen && <Overlay onDismiss={() => setIsDropdownOpen(false)} />}
|
||||
<div className={clsx('relative', isOpen && 'z-50')}>
|
||||
<div className={clsx('relative', isOpen ? 'z-[60]' : hasOpenDropdown && 'z-[70]')}>
|
||||
<button
|
||||
tabIndex={0}
|
||||
aria-haspopup='menu'
|
||||
|
||||
@@ -9,6 +9,7 @@ import { initReplicaSync } from '@/services/sync/replicaSync';
|
||||
import { createSettingsCursorStore } from '@/services/sync/replicaCursorStore';
|
||||
import { startReplicaTransferIntegration } from '@/services/sync/replicaTransferIntegration';
|
||||
import { enableReplicaAutoPersist } from '@/services/sync/replicaPersist';
|
||||
import { isViewTransitionTimeoutError } from '@/utils/error';
|
||||
|
||||
interface EnvContextType {
|
||||
envConfig: EnvConfigType;
|
||||
@@ -40,14 +41,33 @@ export const EnvProvider = ({ children }: { children: ReactNode }) => {
|
||||
console.warn('replica sync init failed', err);
|
||||
}
|
||||
});
|
||||
window.addEventListener('error', (e) => {
|
||||
const handleError = (e: ErrorEvent) => {
|
||||
if (e.message === 'ResizeObserver loop limit exceeded') {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (isViewTransitionTimeoutError(e.error)) {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
const handleUnhandledRejection = (e: PromiseRejectionEvent) => {
|
||||
if (isViewTransitionTimeoutError(e.reason)) {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
window.addEventListener('error', handleError);
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
return () => {
|
||||
window.removeEventListener('error', handleError);
|
||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
};
|
||||
}, [envConfig]);
|
||||
|
||||
const value = useMemo(() => ({ envConfig, appService }), [envConfig, appService]);
|
||||
|
||||
@@ -2,7 +2,12 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTransitionRouter } from 'next-view-transitions';
|
||||
|
||||
export const useAppRouter = () => {
|
||||
const isWebDevMode =
|
||||
process.env['NODE_ENV'] === 'development' && process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
|
||||
|
||||
const usePlainAppRouter = () => useRouter();
|
||||
|
||||
const useTransitionAppRouter = () => {
|
||||
const { appService } = useEnv();
|
||||
const transitionRouter = useTransitionRouter();
|
||||
const plainRouter = useRouter();
|
||||
@@ -15,3 +20,5 @@ export const useAppRouter = () => {
|
||||
// seen on unsupported webviews (Sentry READEST-9).
|
||||
return appService?.supportsViewTransitionsAPI ? transitionRouter : plainRouter;
|
||||
};
|
||||
|
||||
export const useAppRouter = isWebDevMode ? usePlainAppRouter : useTransitionAppRouter;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useIsMobileViewport = (breakpoint = 640) => {
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const mediaQuery = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
|
||||
const update = () => setIsMobileViewport(mediaQuery.matches);
|
||||
update();
|
||||
mediaQuery.addEventListener('change', update);
|
||||
return () => mediaQuery.removeEventListener('change', update);
|
||||
}, [breakpoint]);
|
||||
|
||||
return isMobileViewport;
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
interface UseLongPressOptions {
|
||||
onTap?: () => void;
|
||||
onLongPress?: () => void;
|
||||
onContextMenu?: () => void;
|
||||
onContextMenu?: (event: React.MouseEvent) => void;
|
||||
onCancel?: () => void;
|
||||
threshold?: number;
|
||||
moveThreshold?: number;
|
||||
@@ -149,7 +149,7 @@ export const useLongPress = (
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTimeout(() => {
|
||||
onContextMenu();
|
||||
onContextMenu(e);
|
||||
}, 100);
|
||||
}
|
||||
reset();
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import { makeSafeFilename } from '@/utils/misc';
|
||||
import { getBaseFilename } from '@/utils/path';
|
||||
|
||||
export type BilingualFilterLanguage = 'ja' | 'zh';
|
||||
export type BilingualFilterMode = 'auto' | 'style' | 'script';
|
||||
|
||||
export interface BilingualFilterOptions {
|
||||
removeLanguage: BilingualFilterLanguage;
|
||||
mode?: BilingualFilterMode;
|
||||
removeUnknown?: boolean;
|
||||
}
|
||||
|
||||
export interface BilingualFilterFileStats {
|
||||
path: string;
|
||||
paragraphs: number;
|
||||
removed: number;
|
||||
kept: number;
|
||||
unknown: number;
|
||||
anchorsMoved: number;
|
||||
byLanguage: Record<BilingualFilterLanguage | 'unknown', number>;
|
||||
}
|
||||
|
||||
export interface BilingualFilterStats {
|
||||
filesSeen: number;
|
||||
htmlFiles: number;
|
||||
changedFiles: number;
|
||||
paragraphs: number;
|
||||
removed: number;
|
||||
kept: number;
|
||||
unknown: number;
|
||||
anchorsMoved: number;
|
||||
fileStats: BilingualFilterFileStats[];
|
||||
}
|
||||
|
||||
export interface BilingualFilterResult {
|
||||
file: File;
|
||||
filename: string;
|
||||
title: string;
|
||||
stats: BilingualFilterStats;
|
||||
}
|
||||
|
||||
const HTML_EXTENSIONS = ['.html', '.htm', '.xhtml'];
|
||||
const TEXT_BLOCK_RE = /<p\b[^>]*>.*?<\/p>/gis;
|
||||
const BODY_END_RE = /<\/body\s*>/i;
|
||||
const ANCHOR_RE =
|
||||
/<a\b(?=[^>]*(?:\bid\s*=\s*['"][^'"]+['"]|\bname\s*=\s*['"][^'"]+['"]))[^>]*>\s*<\/a\s*>/gis;
|
||||
const TAG_RE = /<[^>]+>/g;
|
||||
const OPF_EXT = '.opf';
|
||||
|
||||
const makeEmptyFileStats = (path: string): BilingualFilterFileStats => ({
|
||||
path,
|
||||
paragraphs: 0,
|
||||
removed: 0,
|
||||
kept: 0,
|
||||
unknown: 0,
|
||||
anchorsMoved: 0,
|
||||
byLanguage: { ja: 0, zh: 0, unknown: 0 },
|
||||
});
|
||||
|
||||
const makeEmptyRunStats = (): BilingualFilterStats => ({
|
||||
filesSeen: 0,
|
||||
htmlFiles: 0,
|
||||
changedFiles: 0,
|
||||
paragraphs: 0,
|
||||
removed: 0,
|
||||
kept: 0,
|
||||
unknown: 0,
|
||||
anchorsMoved: 0,
|
||||
fileStats: [],
|
||||
});
|
||||
|
||||
const isHtmlPath = (path: string) => {
|
||||
const lower = path.toLowerCase();
|
||||
return HTML_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
};
|
||||
|
||||
const decodeHtmlEntities = (text: string) => {
|
||||
if (typeof document !== 'undefined') {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = text;
|
||||
return textarea.value;
|
||||
}
|
||||
return text
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
const stripTags = (fragment: string) => decodeHtmlEntities(fragment.replace(TAG_RE, '')).trim();
|
||||
|
||||
const getOpeningTag = (fragment: string) => fragment.match(/^<p\b[^>]*>/is)?.[0] ?? '';
|
||||
|
||||
const isImageOrEmptyParagraph = (fragment: string) => {
|
||||
if (stripTags(fragment)) return false;
|
||||
return /<(img|svg|math)\b/i.test(fragment);
|
||||
};
|
||||
|
||||
const getStyleAttribute = (openingTag: string) =>
|
||||
openingTag.match(/\bstyle\s*=\s*(["'])(.*?)\1/is)?.[2] ?? '';
|
||||
|
||||
const getClassAttribute = (openingTag: string) =>
|
||||
openingTag.match(/\bclass\s*=\s*(["'])(.*?)\1/is)?.[2] ?? '';
|
||||
|
||||
const hasGrayColor = (style: string) => {
|
||||
const compact = style.replace(/\s+/g, '').toLowerCase();
|
||||
return (
|
||||
/(^|;)color:(gray|grey)(;|$)/i.test(style) ||
|
||||
compact.includes('color:#808080') ||
|
||||
compact.includes('color:#888') ||
|
||||
compact.includes('color:rgb(128,128,128)') ||
|
||||
compact.includes('color:rgba(128,128,128,')
|
||||
);
|
||||
};
|
||||
|
||||
const isProbablyJapaneseByStyle = (fragment: string) => {
|
||||
const openingTag = getOpeningTag(fragment);
|
||||
const style = getStyleAttribute(openingTag);
|
||||
const className = getClassAttribute(openingTag).toLowerCase();
|
||||
return (
|
||||
/\bopacity\s*:\s*0(?:\.40*|\.4|\.5|\.50*)\b/i.test(style) ||
|
||||
hasGrayColor(style) ||
|
||||
/\b(japanese|source|original|src|ja|jp)\b/.test(className)
|
||||
);
|
||||
};
|
||||
|
||||
const isProbablyChineseByStyle = (fragment: string) => {
|
||||
const openingTag = getOpeningTag(fragment);
|
||||
const className = getClassAttribute(openingTag).toLowerCase();
|
||||
return /\b(chinese|translation|translated|target|zh|cn)\b/.test(className);
|
||||
};
|
||||
|
||||
const scriptCounts = (text: string) => {
|
||||
const counts = { hiragana: 0, katakana: 0, kana: 0, cjk: 0, ascii: 0 };
|
||||
for (const ch of text) {
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
if (code >= 0x3040 && code <= 0x309f) {
|
||||
counts.hiragana += 1;
|
||||
counts.kana += 1;
|
||||
} else if (
|
||||
(code >= 0x30a0 && code <= 0x30ff) ||
|
||||
(code >= 0x31f0 && code <= 0x31ff) ||
|
||||
(code >= 0xff66 && code <= 0xff9d)
|
||||
) {
|
||||
counts.katakana += 1;
|
||||
counts.kana += 1;
|
||||
} else if (
|
||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
||||
(code >= 0x3400 && code <= 0x4dbf) ||
|
||||
(code >= 0xf900 && code <= 0xfaff)
|
||||
) {
|
||||
counts.cjk += 1;
|
||||
} else if (/^[a-z]$/i.test(ch)) {
|
||||
counts.ascii += 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
|
||||
const classifyByScript = (fragment: string): BilingualFilterLanguage | 'unknown' => {
|
||||
const text = stripTags(fragment);
|
||||
if (!text) return 'unknown';
|
||||
|
||||
const counts = scriptCounts(text);
|
||||
const meaningful = counts.kana + counts.cjk + counts.ascii;
|
||||
if (meaningful === 0) return 'unknown';
|
||||
|
||||
if (counts.kana >= 2) return 'ja';
|
||||
if (counts.kana === 1 && counts.cjk <= 2) return 'ja';
|
||||
if (counts.cjk >= 2 && counts.kana === 0) return 'zh';
|
||||
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
const classifyParagraph = (
|
||||
fragment: string,
|
||||
mode: BilingualFilterMode,
|
||||
): BilingualFilterLanguage | 'unknown' => {
|
||||
if (isImageOrEmptyParagraph(fragment)) return 'unknown';
|
||||
if (mode === 'auto' || mode === 'style') {
|
||||
if (isProbablyJapaneseByStyle(fragment)) return 'ja';
|
||||
if (isProbablyChineseByStyle(fragment)) return 'zh';
|
||||
}
|
||||
if (mode === 'style') {
|
||||
return stripTags(fragment) ? 'zh' : 'unknown';
|
||||
}
|
||||
return classifyByScript(fragment);
|
||||
};
|
||||
|
||||
const looksLikeStyleBilingual = (text: string) => {
|
||||
const paragraphs = text.match(TEXT_BLOCK_RE) ?? [];
|
||||
if (paragraphs.length < 10) return false;
|
||||
const textBlocks = paragraphs.filter((paragraph) => stripTags(paragraph)).length;
|
||||
if (textBlocks === 0) return false;
|
||||
const styled = paragraphs.filter(isProbablyJapaneseByStyle).length;
|
||||
const ratio = styled / textBlocks;
|
||||
return styled >= 5 && ratio >= 0.15 && ratio <= 0.85;
|
||||
};
|
||||
|
||||
const extractEmptyAnchors = (fragment: string) => fragment.match(ANCHOR_RE) ?? [];
|
||||
|
||||
const insertAnchorsIntoParagraph = (paragraph: string, anchors: string[]) => {
|
||||
if (anchors.length === 0) return paragraph;
|
||||
const insertion = anchors.join('');
|
||||
const match = paragraph.match(/^<p\b[^>]*>/is);
|
||||
if (!match) return insertion + paragraph;
|
||||
const index = match[0].length;
|
||||
return paragraph.slice(0, index) + insertion + paragraph.slice(index);
|
||||
};
|
||||
|
||||
const insertPendingAnchorsBeforeBodyEnd = (text: string, anchors: string[]) => {
|
||||
if (anchors.length === 0) return text;
|
||||
const fallback = `<p style="display:none;">${anchors.join('')}</p>\n`;
|
||||
const match = text.match(BODY_END_RE);
|
||||
if (!match || match.index === undefined) return `${text}\n${fallback}`;
|
||||
return text.slice(0, match.index) + fallback + text.slice(match.index);
|
||||
};
|
||||
|
||||
const shouldRemove = (
|
||||
language: BilingualFilterLanguage | 'unknown',
|
||||
removeLanguage: BilingualFilterLanguage,
|
||||
removeUnknown: boolean,
|
||||
) => language === removeLanguage || (removeUnknown && language === 'unknown');
|
||||
|
||||
const filterHtmlText = (text: string, options: Required<BilingualFilterOptions>, path: string) => {
|
||||
const stats = makeEmptyFileStats(path);
|
||||
const output: string[] = [];
|
||||
let pendingAnchors: string[] = [];
|
||||
let position = 0;
|
||||
let changed = false;
|
||||
const mode = options.mode === 'auto' && looksLikeStyleBilingual(text) ? 'style' : options.mode;
|
||||
|
||||
for (const match of text.matchAll(TEXT_BLOCK_RE)) {
|
||||
if (match.index === undefined) continue;
|
||||
output.push(text.slice(position, match.index));
|
||||
|
||||
let paragraph = match[0];
|
||||
const language = classifyParagraph(paragraph, mode);
|
||||
stats.paragraphs += 1;
|
||||
stats.byLanguage[language] += 1;
|
||||
|
||||
if (shouldRemove(language, options.removeLanguage, options.removeUnknown)) {
|
||||
const anchors = extractEmptyAnchors(paragraph);
|
||||
pendingAnchors = [...pendingAnchors, ...anchors];
|
||||
stats.removed += 1;
|
||||
stats.anchorsMoved += anchors.length;
|
||||
changed = true;
|
||||
} else {
|
||||
if (language === 'unknown') stats.unknown += 1;
|
||||
stats.kept += 1;
|
||||
if (pendingAnchors.length > 0) {
|
||||
paragraph = insertAnchorsIntoParagraph(paragraph, pendingAnchors);
|
||||
pendingAnchors = [];
|
||||
changed = true;
|
||||
}
|
||||
output.push(paragraph);
|
||||
}
|
||||
|
||||
position = match.index + match[0].length;
|
||||
}
|
||||
|
||||
output.push(text.slice(position));
|
||||
let newText = output.join('');
|
||||
if (pendingAnchors.length > 0) {
|
||||
newText = insertPendingAnchorsBeforeBodyEnd(newText, pendingAnchors);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return { text: changed ? newText : text, stats };
|
||||
};
|
||||
|
||||
const escapeXml = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const decodeText = (data: Uint8Array) => {
|
||||
const encodings = ['utf-8', 'shift_jis', 'gb18030'];
|
||||
for (const encoding of encodings) {
|
||||
try {
|
||||
const decoder = new TextDecoder(encoding, { fatal: true });
|
||||
return decoder.decode(data);
|
||||
} catch {
|
||||
// Try the next common EPUB encoding.
|
||||
}
|
||||
}
|
||||
return new TextDecoder('utf-8').decode(data);
|
||||
};
|
||||
|
||||
const setDocumentLanguageAndTitle = (text: string, title: string, language: string) => {
|
||||
let updated = text;
|
||||
if (/<title\b[^>]*>.*?<\/title>/is.test(updated)) {
|
||||
updated = updated.replace(/<title\b[^>]*>.*?<\/title>/is, `<title>${escapeXml(title)}</title>`);
|
||||
}
|
||||
if (/\bxml:lang\s*=\s*["'][^"']*["']/i.test(updated)) {
|
||||
updated = updated.replace(/\bxml:lang\s*=\s*(["'])[^"']*\1/i, `xml:lang="${language}"`);
|
||||
}
|
||||
if (/\blang\s*=\s*["'][^"']*["']/i.test(updated)) {
|
||||
updated = updated.replace(/\blang\s*=\s*(["'])[^"']*\1/i, `lang="${language}"`);
|
||||
} else {
|
||||
updated = updated.replace(/<html\b/i, `<html lang="${language}"`);
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
|
||||
const extractOpfTitle = (text: string) => {
|
||||
const match = text.match(/<dc:title\b[^>]*>(.*?)<\/dc:title>/is);
|
||||
return match ? stripTags(match[1] ?? '') : '';
|
||||
};
|
||||
|
||||
const getVariantInfo = (
|
||||
sourceName: string,
|
||||
removeLanguage: BilingualFilterLanguage,
|
||||
metadataTitle?: string,
|
||||
) => {
|
||||
const sourceTitle = metadataTitle?.trim() || getBaseFilename(sourceName);
|
||||
const suffix = removeLanguage === 'ja' ? '简中译本' : '日文原文版';
|
||||
const language = removeLanguage === 'ja' ? 'zh-CN' : 'ja';
|
||||
const title = `${sourceTitle}(${suffix})`;
|
||||
const filename = `${makeSafeFilename(`${sourceTitle}_${suffix}`)}.epub`;
|
||||
const identifierSuffix = removeLanguage === 'ja' ? 'readest-no-ja' : 'readest-no-zh';
|
||||
return { title, filename, language, identifierSuffix };
|
||||
};
|
||||
|
||||
const patchOpfMetadata = (
|
||||
text: string,
|
||||
variant: ReturnType<typeof getVariantInfo>,
|
||||
removeLanguage: BilingualFilterLanguage,
|
||||
) => {
|
||||
let updated = text;
|
||||
const title = escapeXml(variant.title);
|
||||
if (/<dc:title\b[^>]*>.*?<\/dc:title>/is.test(updated)) {
|
||||
updated = updated.replace(
|
||||
/<dc:title\b([^>]*)>.*?<\/dc:title>/is,
|
||||
`<dc:title$1>${title}</dc:title>`,
|
||||
);
|
||||
}
|
||||
if (/<dc:language\b[^>]*>.*?<\/dc:language>/is.test(updated)) {
|
||||
updated = updated.replace(
|
||||
/<dc:language\b([^>]*)>.*?<\/dc:language>/is,
|
||||
`<dc:language$1>${variant.language}</dc:language>`,
|
||||
);
|
||||
}
|
||||
if (/<dc:identifier\b[^>]*>.*?<\/dc:identifier>/is.test(updated)) {
|
||||
updated = updated.replace(
|
||||
/<dc:identifier\b([^>]*)>(.*?)<\/dc:identifier>/gis,
|
||||
(_match, attrs: string, value: string) => {
|
||||
const trimmed = value.trim();
|
||||
const suffix = `:${variant.identifierSuffix}`;
|
||||
const nextValue = trimmed.includes(suffix) ? trimmed : `${trimmed}${suffix}`;
|
||||
return `<dc:identifier${attrs}>${escapeXml(nextValue)}</dc:identifier>`;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
updated = updated.replace(
|
||||
/<\/metadata\s*>/i,
|
||||
`<dc:identifier id="readest-filter-id">readest:${removeLanguage}:${Date.now()}</dc:identifier></metadata>`,
|
||||
);
|
||||
}
|
||||
updated = updated.replace(
|
||||
/<meta\b([^>]*\bproperty\s*=\s*["']dcterms:modified["'][^>]*)>.*?<\/meta>/is,
|
||||
`<meta$1>${new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')}</meta>`,
|
||||
);
|
||||
return updated;
|
||||
};
|
||||
|
||||
const updateRunStats = (runStats: BilingualFilterStats, fileStats: BilingualFilterFileStats) => {
|
||||
runStats.fileStats.push(fileStats);
|
||||
runStats.paragraphs += fileStats.paragraphs;
|
||||
runStats.removed += fileStats.removed;
|
||||
runStats.kept += fileStats.kept;
|
||||
runStats.unknown += fileStats.unknown;
|
||||
runStats.anchorsMoved += fileStats.anchorsMoved;
|
||||
};
|
||||
|
||||
export async function filterBilingualEpubFile(
|
||||
sourceFile: File,
|
||||
filterOptions: BilingualFilterOptions,
|
||||
): Promise<BilingualFilterResult> {
|
||||
const options: Required<BilingualFilterOptions> = {
|
||||
mode: 'auto',
|
||||
removeUnknown: false,
|
||||
...filterOptions,
|
||||
};
|
||||
const {
|
||||
BlobReader,
|
||||
BlobWriter,
|
||||
TextReader,
|
||||
Uint8ArrayReader,
|
||||
Uint8ArrayWriter,
|
||||
ZipReader,
|
||||
ZipWriter,
|
||||
} = await import('@zip.js/zip.js');
|
||||
|
||||
const reader = new ZipReader(new BlobReader(sourceFile));
|
||||
const writer = new ZipWriter(new BlobWriter('application/epub+zip'));
|
||||
const runStats = makeEmptyRunStats();
|
||||
|
||||
try {
|
||||
const entries = await reader.getEntries();
|
||||
const readableEntries = entries.filter(isReadableZipEntry);
|
||||
const opfEntry = readableEntries.find((entry) =>
|
||||
entry.filename.toLowerCase().endsWith(OPF_EXT),
|
||||
);
|
||||
const opfTitle = opfEntry
|
||||
? extractOpfTitle(decodeText(await opfEntry.getData(new Uint8ArrayWriter())))
|
||||
: '';
|
||||
const variant = getVariantInfo(sourceFile.name, options.removeLanguage, opfTitle);
|
||||
const seen = new Set<string>();
|
||||
const mimetype = entries.find((entry) => entry.filename.toLowerCase() === 'mimetype');
|
||||
if (mimetype) {
|
||||
await writer.add('mimetype', new TextReader('application/epub+zip'), { level: 0 });
|
||||
seen.add(mimetype.filename);
|
||||
runStats.filesSeen += 1;
|
||||
}
|
||||
|
||||
for (const entry of readableEntries) {
|
||||
if (seen.has(entry.filename)) continue;
|
||||
seen.add(entry.filename);
|
||||
runStats.filesSeen += 1;
|
||||
|
||||
const lowerName = entry.filename.toLowerCase();
|
||||
const rawData = await entry.getData(new Uint8ArrayWriter());
|
||||
let outData: Uint8Array | string = rawData;
|
||||
|
||||
if (isHtmlPath(lowerName)) {
|
||||
runStats.htmlFiles += 1;
|
||||
const originalText = decodeText(rawData);
|
||||
const filtered = filterHtmlText(originalText, options, entry.filename);
|
||||
const patchedText = setDocumentLanguageAndTitle(
|
||||
filtered.text,
|
||||
variant.title,
|
||||
variant.language,
|
||||
);
|
||||
updateRunStats(runStats, filtered.stats);
|
||||
if (patchedText !== originalText) runStats.changedFiles += 1;
|
||||
outData = patchedText;
|
||||
} else if (lowerName.endsWith(OPF_EXT)) {
|
||||
const originalText = decodeText(rawData);
|
||||
const patchedText = patchOpfMetadata(originalText, variant, options.removeLanguage);
|
||||
if (patchedText !== originalText) runStats.changedFiles += 1;
|
||||
outData = patchedText;
|
||||
}
|
||||
|
||||
const writerReader =
|
||||
typeof outData === 'string'
|
||||
? new TextReader(outData)
|
||||
: new Uint8ArrayReader(outData as Uint8Array);
|
||||
await writer.add(entry.filename, writerReader, zipOptionsForEntry(entry));
|
||||
}
|
||||
|
||||
const blob = await writer.close();
|
||||
const file = new File([blob], variant.filename, { type: 'application/epub+zip' });
|
||||
return { file, filename: variant.filename, title: variant.title, stats: runStats };
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
}
|
||||
|
||||
const isReadableZipEntry = (
|
||||
entry: import('@zip.js/zip.js').Entry,
|
||||
): entry is import('@zip.js/zip.js').FileEntry =>
|
||||
!entry.directory && typeof entry.getData === 'function';
|
||||
|
||||
const zipOptionsForEntry = (entry: import('@zip.js/zip.js').Entry) =>
|
||||
entry.filename.toLowerCase() === 'mimetype' ? { level: 0 } : {};
|
||||
@@ -0,0 +1,436 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import type { Book } from '@/types/book';
|
||||
import type { AppService } from '@/types/system';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
|
||||
export type ReviewEditorLaunchResponse = {
|
||||
ok: boolean;
|
||||
url?: string;
|
||||
reused?: boolean;
|
||||
reviewRoot?: string;
|
||||
version?: string;
|
||||
accessToken?: string;
|
||||
sessionId?: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ReviewSessionSummary = {
|
||||
id: string;
|
||||
source_name?: string;
|
||||
title?: string;
|
||||
source_epub?: string;
|
||||
row_count?: number;
|
||||
touched_count?: number;
|
||||
marked_count?: number;
|
||||
feedback_md?: string;
|
||||
feedback_jsonl?: string;
|
||||
latest_export?: string;
|
||||
};
|
||||
|
||||
export type ReviewSessionPayload = {
|
||||
has_session: boolean;
|
||||
id?: string;
|
||||
title?: string;
|
||||
source_name?: string;
|
||||
source_epub?: string;
|
||||
row_count?: number;
|
||||
touched_count?: number;
|
||||
marked_count?: number;
|
||||
feedback_md?: string;
|
||||
feedback_jsonl?: string;
|
||||
review_root?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ReviewRow = {
|
||||
id: string;
|
||||
file: string;
|
||||
file_label?: string;
|
||||
document_title?: string;
|
||||
file_order?: number;
|
||||
file_row_index?: number;
|
||||
ja_p_index?: number;
|
||||
cn_p_index?: number;
|
||||
jp_html: string;
|
||||
jp_text: string;
|
||||
cn_html: string;
|
||||
cn_text?: string;
|
||||
current_html: string;
|
||||
marked?: boolean;
|
||||
issue_type?: string;
|
||||
severity?: string;
|
||||
comment?: string;
|
||||
learn_note?: string;
|
||||
tags?: string;
|
||||
edited?: boolean;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type ReviewRowsPayload = {
|
||||
rows: ReviewRow[];
|
||||
};
|
||||
|
||||
export type ReviewGptConfig = {
|
||||
configured?: boolean;
|
||||
base_url?: string;
|
||||
model?: string;
|
||||
key_source?: string;
|
||||
updated_at?: string;
|
||||
translation_prompt?: string;
|
||||
format_prompt?: string;
|
||||
character_prompt?: string;
|
||||
glossary_path?: string;
|
||||
prompt_defaults?: {
|
||||
translation_prompt?: string;
|
||||
format_prompt?: string;
|
||||
character_prompt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ReviewEditPayload = {
|
||||
current_html: string;
|
||||
marked: boolean;
|
||||
issue_type: string;
|
||||
severity: string;
|
||||
tags: string;
|
||||
comment: string;
|
||||
learn_note: string;
|
||||
};
|
||||
|
||||
export const isReviewEditDirty = (row: ReviewRow, edit: ReviewEditPayload) =>
|
||||
edit.current_html !== (row.current_html ?? row.cn_html ?? '') ||
|
||||
edit.marked !== Boolean(row.marked) ||
|
||||
edit.issue_type !== (row.issue_type || '') ||
|
||||
edit.severity !== (row.severity || '') ||
|
||||
edit.tags !== (row.tags || '') ||
|
||||
edit.comment !== (row.comment || '') ||
|
||||
edit.learn_note !== (row.learn_note || '');
|
||||
|
||||
export type ReviewSaveRowPayload = {
|
||||
status?: string;
|
||||
row_id?: string;
|
||||
updated_at?: string;
|
||||
current_html?: string;
|
||||
};
|
||||
|
||||
export type ReviewGlossaryEntry = {
|
||||
source: string;
|
||||
target: string;
|
||||
clean_target?: string;
|
||||
};
|
||||
|
||||
export type ReviewGlossaryPayload = {
|
||||
path?: string;
|
||||
exists?: boolean;
|
||||
count?: number;
|
||||
entries?: ReviewGlossaryEntry[];
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type ReviewBootstrapPayload = {
|
||||
session: ReviewSessionPayload;
|
||||
rows: ReviewRow[];
|
||||
gptConfig: ReviewGptConfig;
|
||||
};
|
||||
|
||||
export type ReviewSessionFromPathOptions = {
|
||||
activate?: boolean;
|
||||
reset?: boolean;
|
||||
};
|
||||
|
||||
const ensureBaseUrl = (baseUrl: string) => {
|
||||
if (!baseUrl) throw new Error('审校器后端尚未启动');
|
||||
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
};
|
||||
|
||||
const sidecarAccessTokens = new Map<string, string>();
|
||||
|
||||
const rememberSidecarAccessToken = (baseUrl: string, accessToken: string) => {
|
||||
sidecarAccessTokens.set(ensureBaseUrl(baseUrl), accessToken);
|
||||
};
|
||||
|
||||
export const canLaunchInlineReviewEditor = (
|
||||
appService: Pick<AppService, 'isDesktopApp'> | null | undefined,
|
||||
localWebDevelopment =
|
||||
process.env['NODE_ENV'] === 'development' && isWebAppPlatform(),
|
||||
) => Boolean(appService?.isDesktopApp || localWebDevelopment);
|
||||
|
||||
export async function sidecarApi<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
sessionId?: string | null,
|
||||
accessToken?: string,
|
||||
): Promise<T> {
|
||||
const url = new URL(path, ensureBaseUrl(baseUrl));
|
||||
if (sessionId) {
|
||||
url.searchParams.set('session_id', sessionId);
|
||||
}
|
||||
const resolvedAccessToken = accessToken || sidecarAccessTokens.get(ensureBaseUrl(baseUrl));
|
||||
const headers = {
|
||||
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(resolvedAccessToken ? { Authorization: `Bearer ${resolvedAccessToken}` } : {}),
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const response = await fetch(url.toString(), {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(String(data.error || `HTTP ${response.status}`));
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export async function createReviewSessionFromPath(
|
||||
baseUrl: string,
|
||||
epubPath: string,
|
||||
options: ReviewSessionFromPathOptions = {},
|
||||
accessToken?: string,
|
||||
): Promise<ReviewSessionSummary> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
'/api/session/from-path',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
epub_path: epubPath,
|
||||
activate: options.activate ?? false,
|
||||
reset: options.reset ?? false,
|
||||
}),
|
||||
},
|
||||
null,
|
||||
accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
async function createReviewSessionFromBook(
|
||||
baseUrl: string,
|
||||
appService: AppService,
|
||||
book: Book,
|
||||
accessToken: string,
|
||||
): Promise<ReviewSessionSummary> {
|
||||
const { file } = await appService.loadBookContent(book);
|
||||
const formData = new FormData();
|
||||
formData.append('epub', file, file.name || `${book.title || 'book'}.epub`);
|
||||
formData.append('activate', 'false');
|
||||
formData.append('book_key', book.hash);
|
||||
const response = await fetch(new URL('/api/upload', ensureBaseUrl(baseUrl)).toString(), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(String(data.error || `HTTP ${response.status}`));
|
||||
}
|
||||
return data as ReviewSessionSummary;
|
||||
}
|
||||
|
||||
export async function launchInlineReviewEditor(
|
||||
appService: AppService | null | undefined,
|
||||
book: Book,
|
||||
tauriPlatform = isTauriAppPlatform(),
|
||||
): Promise<
|
||||
ReviewEditorLaunchResponse & { url: string; sessionId: string | null; accessToken: string }
|
||||
> {
|
||||
if (!appService) throw new Error('Readest 服务尚未初始化');
|
||||
if (book.format !== 'EPUB') throw new Error('审校模式目前只支持 EPUB 书籍');
|
||||
|
||||
const epubPath = tauriPlatform ? await appService.resolveNativeBookFilePath(book) : null;
|
||||
if (tauriPlatform && !epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径');
|
||||
|
||||
const launch = tauriPlatform
|
||||
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', { epubPath })
|
||||
: await fetch('/api/review-editor/launch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-readest-review-editor-launch': '1',
|
||||
},
|
||||
}).then((response) => response.json() as Promise<ReviewEditorLaunchResponse>);
|
||||
|
||||
if (!launch.ok || !launch.url) {
|
||||
throw new Error(launch.error || '审校器启动失败');
|
||||
}
|
||||
if (!launch.accessToken) throw new Error('审校器启动响应缺少访问令牌');
|
||||
rememberSidecarAccessToken(launch.url, launch.accessToken);
|
||||
|
||||
let sessionId = launch.sessionId || null;
|
||||
if (!sessionId) {
|
||||
const createdSession = epubPath
|
||||
? await createReviewSessionFromPath(launch.url, epubPath, {}, launch.accessToken)
|
||||
: await createReviewSessionFromBook(launch.url, appService, book, launch.accessToken);
|
||||
sessionId = createdSession.id || null;
|
||||
}
|
||||
|
||||
return { ...launch, url: launch.url, sessionId, accessToken: launch.accessToken };
|
||||
}
|
||||
|
||||
export async function loadInlineReviewData(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
accessToken?: string,
|
||||
): Promise<ReviewBootstrapPayload> {
|
||||
if (!sessionId) throw new Error('审校器没有返回当前书籍会话');
|
||||
const [session, rowsPayload, gptConfig] = await Promise.all([
|
||||
sidecarApi<ReviewSessionPayload>(baseUrl, '/api/session', {}, sessionId, accessToken),
|
||||
sidecarApi<ReviewRowsPayload>(baseUrl, '/api/rows', {}, sessionId, accessToken),
|
||||
sidecarApi<ReviewGptConfig>(baseUrl, '/api/gpt/config', {}, sessionId, accessToken),
|
||||
]);
|
||||
return {
|
||||
session,
|
||||
rows: rowsPayload.rows || [],
|
||||
gptConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveReviewRow(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
rowId: string,
|
||||
edit: ReviewEditPayload,
|
||||
): Promise<ReviewSaveRowPayload> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
`/api/row/${encodeURIComponent(rowId)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(edit),
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function retranslateReviewRow(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
rowId: string,
|
||||
instruction: string,
|
||||
): Promise<{
|
||||
candidate_html?: string;
|
||||
candidate_text?: string;
|
||||
glossary_matches?: string[];
|
||||
}> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
`/api/row/${encodeURIComponent(rowId)}/retranslate`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ instruction }),
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveReviewGptConfig(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
config: {
|
||||
base_url?: string;
|
||||
model?: string;
|
||||
api_key?: string;
|
||||
glossary_path?: string;
|
||||
translation_prompt?: string;
|
||||
format_prompt?: string;
|
||||
character_prompt?: string;
|
||||
},
|
||||
): Promise<ReviewGptConfig> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
'/api/gpt/config',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...config,
|
||||
keep_existing: true,
|
||||
}),
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadReviewGlossary(
|
||||
baseUrl: string,
|
||||
sessionId?: string | null,
|
||||
): Promise<ReviewGlossaryPayload> {
|
||||
return sidecarApi(baseUrl, '/api/glossary', {}, sessionId);
|
||||
}
|
||||
|
||||
export async function switchReviewGlossaryPath(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
path: string,
|
||||
): Promise<ReviewGlossaryPayload> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
'/api/glossary',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path }),
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveReviewGlossary(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
path: string,
|
||||
entries: ReviewGlossaryEntry[],
|
||||
): Promise<ReviewGlossaryPayload> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
'/api/glossary',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path, entries }),
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateReviewFeedback(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
): Promise<{ feedback_md?: string; feedback_jsonl?: string }> {
|
||||
return sidecarApi(baseUrl, '/api/feedback', {}, sessionId);
|
||||
}
|
||||
|
||||
export async function exportReviewedEpub(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
): Promise<{ output?: string; download_url?: string }> {
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
'/api/export',
|
||||
{
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadReviewedEpub(
|
||||
downloadUrl: string,
|
||||
accessToken = '',
|
||||
): Promise<void> {
|
||||
const url = new URL(downloadUrl);
|
||||
const resolvedAccessToken = accessToken || sidecarAccessTokens.get(ensureBaseUrl(url.origin));
|
||||
if (!resolvedAccessToken) throw new Error('审校器访问令牌不可用');
|
||||
const filename = decodeURIComponent(url.pathname.split('/').pop() || 'reviewed.epub');
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${resolvedAccessToken}` },
|
||||
});
|
||||
if (!response.ok) throw new Error(`EPUB 下载失败:HTTP ${response.status}`);
|
||||
const blobUrl = URL.createObjectURL(await response.blob());
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = blobUrl;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { create } from 'zustand';
|
||||
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
|
||||
import type {
|
||||
ReviewGptConfig,
|
||||
ReviewRow,
|
||||
ReviewSessionPayload,
|
||||
} from '@/services/reviewEditorService';
|
||||
|
||||
export type ReviewBookState = {
|
||||
enabled: boolean;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
baseUrl: string;
|
||||
accessToken: string;
|
||||
sessionId: string | null;
|
||||
reviewRoot?: string;
|
||||
version?: string;
|
||||
rows: ReviewRow[];
|
||||
session: ReviewSessionPayload | null;
|
||||
gptConfig: ReviewGptConfig | null;
|
||||
selectedRowId: string;
|
||||
hasUnsavedEdit: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_PANEL_WIDTH = '32%';
|
||||
const DEFAULT_PANEL_HEIGHT = '68vh';
|
||||
const MIN_PANEL_WIDTH_PERCENT = 24;
|
||||
const MAX_PANEL_WIDTH_PERCENT = 48;
|
||||
const MIN_PANEL_HEIGHT_VH = 35;
|
||||
const MAX_PANEL_HEIGHT_VH = 92;
|
||||
|
||||
type ReviewModeStore = {
|
||||
activeBookKey: string | null;
|
||||
isPanelVisible: boolean;
|
||||
isPanelPinned: boolean;
|
||||
panelWidth: string;
|
||||
panelHeight: string;
|
||||
books: Record<string, ReviewBookState>;
|
||||
getBookState: (bookKey: string | null) => ReviewBookState | null;
|
||||
setActiveBookKey: (bookKey: string | null) => void;
|
||||
setPanelVisible: (visible: boolean) => void;
|
||||
togglePanelPin: () => void;
|
||||
setPanelWidth: (width: string) => void;
|
||||
setPanelHeight: (height: string) => void;
|
||||
setBookLoading: (bookKey: string, loading: boolean) => void;
|
||||
setBookError: (bookKey: string, error: string) => void;
|
||||
setBookEnabled: (bookKey: string, enabled: boolean) => void;
|
||||
setBookData: (
|
||||
bookKey: string,
|
||||
data: Partial<Omit<ReviewBookState, 'enabled' | 'loading' | 'error'>>,
|
||||
) => void;
|
||||
selectRow: (bookKey: string, rowId: string) => void;
|
||||
setBookUnsavedEdit: (bookKey: string, hasUnsavedEdit: boolean) => void;
|
||||
updateRow: (bookKey: string, row: ReviewRow) => void;
|
||||
clearBook: (bookKey: string) => void;
|
||||
};
|
||||
|
||||
const emptyBookState = (): ReviewBookState => ({
|
||||
enabled: false,
|
||||
loading: false,
|
||||
error: '',
|
||||
baseUrl: '',
|
||||
accessToken: '',
|
||||
sessionId: null,
|
||||
rows: [],
|
||||
session: null,
|
||||
gptConfig: null,
|
||||
selectedRowId: '',
|
||||
hasUnsavedEdit: false,
|
||||
});
|
||||
|
||||
const formatSizedValue = (value: number, unit: '%' | 'vh') =>
|
||||
`${Math.round(value * 100) / 100}${unit}`;
|
||||
|
||||
const normalizeSizedValue = (value: unknown, unit: '%' | 'vh', min: number, max: number) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const match = value.trim().match(new RegExp(`^(-?\\d+(?:\\.\\d+)?)${unit}$`));
|
||||
if (!match) return null;
|
||||
const numericValue = Number(match[1]);
|
||||
if (!Number.isFinite(numericValue)) return null;
|
||||
return formatSizedValue(Math.min(max, Math.max(min, numericValue)), unit);
|
||||
};
|
||||
|
||||
const normalizePersistedLayout = (state: unknown) => {
|
||||
if (!state || typeof state !== 'object') return {};
|
||||
const persisted = state as Partial<ReviewModeStore>;
|
||||
const panelWidth = normalizeSizedValue(
|
||||
persisted.panelWidth,
|
||||
'%',
|
||||
MIN_PANEL_WIDTH_PERCENT,
|
||||
MAX_PANEL_WIDTH_PERCENT,
|
||||
);
|
||||
const panelHeight = normalizeSizedValue(
|
||||
persisted.panelHeight,
|
||||
'vh',
|
||||
MIN_PANEL_HEIGHT_VH,
|
||||
MAX_PANEL_HEIGHT_VH,
|
||||
);
|
||||
|
||||
return {
|
||||
...(typeof persisted.isPanelPinned === 'boolean'
|
||||
? { isPanelPinned: persisted.isPanelPinned }
|
||||
: {}),
|
||||
...(panelWidth ? { panelWidth } : {}),
|
||||
...(panelHeight ? { panelHeight } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const withBookState = (
|
||||
books: Record<string, ReviewBookState>,
|
||||
bookKey: string,
|
||||
patch: Partial<ReviewBookState>,
|
||||
) => ({
|
||||
...books,
|
||||
[bookKey]: {
|
||||
...(books[bookKey] || emptyBookState()),
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
|
||||
export const useReviewModeStore = create<ReviewModeStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
activeBookKey: null,
|
||||
isPanelVisible: false,
|
||||
isPanelPinned: false,
|
||||
panelWidth: DEFAULT_PANEL_WIDTH,
|
||||
panelHeight: DEFAULT_PANEL_HEIGHT,
|
||||
books: {},
|
||||
getBookState: (bookKey) => (bookKey ? get().books[bookKey] || null : null),
|
||||
setActiveBookKey: (bookKey) => set({ activeBookKey: bookKey }),
|
||||
setPanelVisible: (visible) => set({ isPanelVisible: visible }),
|
||||
togglePanelPin: () => set((state) => ({ isPanelPinned: !state.isPanelPinned })),
|
||||
setPanelWidth: (width) => set({ panelWidth: width }),
|
||||
setPanelHeight: (height) => set({ panelHeight: height }),
|
||||
setBookLoading: (bookKey, loading) =>
|
||||
set((state) => ({
|
||||
books: withBookState(
|
||||
state.books,
|
||||
bookKey,
|
||||
loading ? { loading, error: '' } : { loading },
|
||||
),
|
||||
})),
|
||||
setBookError: (bookKey, error) =>
|
||||
set((state) => ({
|
||||
books: withBookState(state.books, bookKey, { error, loading: false }),
|
||||
})),
|
||||
setBookEnabled: (bookKey, enabled) =>
|
||||
set((state) => ({
|
||||
activeBookKey: enabled
|
||||
? bookKey
|
||||
: state.activeBookKey === bookKey
|
||||
? null
|
||||
: state.activeBookKey,
|
||||
isPanelVisible: enabled
|
||||
? true
|
||||
: state.activeBookKey === bookKey
|
||||
? false
|
||||
: state.isPanelVisible,
|
||||
books: withBookState(state.books, bookKey, {
|
||||
enabled,
|
||||
...(!enabled ? { hasUnsavedEdit: false } : {}),
|
||||
}),
|
||||
})),
|
||||
setBookData: (bookKey, data) =>
|
||||
set((state) => ({
|
||||
books: withBookState(state.books, bookKey, {
|
||||
...data,
|
||||
loading: false,
|
||||
error: '',
|
||||
}),
|
||||
})),
|
||||
selectRow: (bookKey, rowId) =>
|
||||
set((state) => {
|
||||
if (state.activeBookKey === bookKey && state.books[bookKey]?.selectedRowId === rowId) {
|
||||
return state.isPanelVisible ? state : { isPanelVisible: true };
|
||||
}
|
||||
return {
|
||||
activeBookKey: bookKey,
|
||||
isPanelVisible: true,
|
||||
books: withBookState(state.books, bookKey, {
|
||||
selectedRowId: rowId,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
setBookUnsavedEdit: (bookKey, hasUnsavedEdit) =>
|
||||
set((state) => ({
|
||||
books: withBookState(state.books, bookKey, { hasUnsavedEdit }),
|
||||
})),
|
||||
updateRow: (bookKey, row) =>
|
||||
set((state) => {
|
||||
const current = state.books[bookKey] || emptyBookState();
|
||||
return {
|
||||
books: withBookState(state.books, bookKey, {
|
||||
rows: current.rows.map((item) => (item.id === row.id ? row : item)),
|
||||
}),
|
||||
};
|
||||
}),
|
||||
clearBook: (bookKey) =>
|
||||
set((state) => {
|
||||
const books = { ...state.books };
|
||||
delete books[bookKey];
|
||||
return {
|
||||
books,
|
||||
activeBookKey: state.activeBookKey === bookKey ? null : state.activeBookKey,
|
||||
isPanelVisible: state.activeBookKey === bookKey ? false : state.isPanelVisible,
|
||||
};
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'readest-review-panel-layout',
|
||||
storage: createJSONStorage(() => window.localStorage),
|
||||
partialize: (state) => ({
|
||||
isPanelPinned: state.isPanelPinned,
|
||||
panelWidth: state.panelWidth,
|
||||
panelHeight: state.panelHeight,
|
||||
}),
|
||||
merge: (persistedState, currentState) => ({
|
||||
...currentState,
|
||||
...normalizePersistedLayout(persistedState),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,4 +1,17 @@
|
||||
const VIEW_TRANSITION_TIMEOUT = 'Transition was aborted because of timeout in DOM update';
|
||||
|
||||
export const isViewTransitionTimeoutError = (error: unknown) => {
|
||||
if (process.env['NODE_ENV'] !== 'development') return false;
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.name === 'TimeoutError' &&
|
||||
error.message === VIEW_TRANSITION_TIMEOUT
|
||||
);
|
||||
};
|
||||
|
||||
export const handleGlobalError = (e: Error) => {
|
||||
if (isViewTransitionTimeoutError(e)) return;
|
||||
|
||||
const isChunkError = e?.message?.includes('Loading chunk');
|
||||
|
||||
if (!isChunkError) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
epub_review_sessions/
|
||||
review_sessions/
|
||||
*.log
|
||||
*.epub
|
||||
@@ -0,0 +1,135 @@
|
||||
# Readest 内嵌 EPUB 审校维护手册
|
||||
|
||||
## 目标与边界
|
||||
|
||||
当前审校器是 Readest 原生阅读功能的增强模式,不再是独立应用。用户在阅读页顶栏点击“校”,直接在 Foliate 正文中选择中文或日文段落,并通过右侧面板完成修改、标记、重翻、反馈和导出。
|
||||
|
||||
不得恢复或重新引入以下旧入口:
|
||||
|
||||
- `/review-editor` 页面
|
||||
- 书库设置菜单中的独立审校器入口
|
||||
- 书籍右键菜单中的独立校对/翻译入口
|
||||
- `tools/epub-review-editor/static/` 独立网页
|
||||
- `open_editor.ps1` 或 `epub-reviewer:dev` 独立启动链路
|
||||
|
||||
整书翻译、独立审校书架、独立目录/检索页面等功能不属于内嵌审校模式,不应迁入阅读页。
|
||||
|
||||
## 必须保留的链路
|
||||
|
||||
以下代码是新内嵌审校功能的依赖,不得作为旧迁移代码删除:
|
||||
|
||||
- `src/app/reader/components/ReviewModeController.tsx`
|
||||
- `src/app/reader/components/ReviewModeToggler.tsx`
|
||||
- `src/app/reader/components/ReviewPanel.tsx`
|
||||
- `src/app/reader/hooks/useReviewPanelDrag.ts`
|
||||
- `src/app/reader/hooks/useReviewPanelFloatingResize.ts`
|
||||
- `src/services/reviewEditorService.ts`
|
||||
- `src/store/reviewModeStore.ts`
|
||||
- `src/app/api/review-editor/launch/route.ts`
|
||||
- `src-tauri/src/review_editor.rs` 及其 command 注册
|
||||
- `tools/epub-review-editor/server.py`
|
||||
- `tools/epub-review-editor/version.py`
|
||||
- `tools/epub-review-editor/requirements.txt`
|
||||
|
||||
Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧静态资源和独立启动器不进入安装包。
|
||||
|
||||
## 会话与数据
|
||||
|
||||
所有审校 API 调用必须携带当前书的 `session_id`,不能依赖 sidecar 的全局 active session。多窗口或多书同时审校时,不得发生串读、串写。
|
||||
|
||||
Next/Tauri 启动器在 review root 的 `.access-token` 中生成并复用随机令牌,启动 sidecar 时通过 `--access-token` 传入。所有 API、资源和下载请求(包括 `/api/version` 健康探测)必须携带 `Authorization: Bearer <accessToken>`;响应、日志、反馈和导出文件不得包含该令牌。CORS 预检不要求令牌,但只允许受信任的本机 origin,并显式允许 `Authorization` 请求头。
|
||||
|
||||
典型 session:
|
||||
|
||||
```text
|
||||
<review-root>/
|
||||
gpt_config.json
|
||||
<session-id>/
|
||||
source.epub
|
||||
extracted/
|
||||
review_state/
|
||||
rows.json
|
||||
state.json
|
||||
session.json
|
||||
gpt_config.json
|
||||
feedback/
|
||||
translation_feedback.jsonl
|
||||
feedback_for_codex.md
|
||||
exports/
|
||||
```
|
||||
|
||||
- `rows.json` 保存初始解析结果,不因用户编辑而重写。
|
||||
- `state.json.edits` 保存逐段修改和标记。
|
||||
- session 级 GPT 配置保存模型、提示词和术语表路径,但不保存 API Key。
|
||||
- API Key 只保存在 review root 的本地配置中,任何公开响应都不得包含密钥。
|
||||
- 导出前必须写回已保存的中文修改,并保持原 EPUB 图片和结构。
|
||||
|
||||
## API 契约
|
||||
|
||||
内嵌模式至少依赖:
|
||||
|
||||
- `GET /api/version`
|
||||
- `POST /api/session/from-path`
|
||||
- `POST /api/upload`(本地 `dev-web` 使用 multipart 上传,内嵌调用必须传 `activate=false` 与稳定 `book_key`)
|
||||
- `GET /api/session?session_id=...`
|
||||
- `GET /api/rows?session_id=...`
|
||||
- `POST /api/row/<row_id>?session_id=...`
|
||||
- `POST /api/row/<row_id>/retranslate?session_id=...`
|
||||
- `GET|POST /api/gpt/config?session_id=...`
|
||||
- `GET|POST /api/glossary?session_id=...`
|
||||
- `POST /api/feedback?session_id=...`
|
||||
- `POST /api/export?session_id=...`
|
||||
- 导出、反馈和 EPUB 资源下载路由
|
||||
|
||||
中文 HTML 清洗必须允许必要的 `ruby/rb/rt/rp/mark/span` 内联标签并移除脚本。保存响应应返回清洗后的 `current_html`,前端只能用清洗结果更新 store 和正文预览。
|
||||
|
||||
sidecar 只能监听本机 loopback,CLI 必须拒绝 `0.0.0.0`、`::` 等非 loopback 地址。Readest/Tauri 本机来源可获得受限 CORS;本地 `dev-web` 端口可能动态变化,应按 loopback origin 校验而非固定端口。不得使用 `Access-Control-Allow-Origin: *`。Cross-Origin Resource Policy 必须允许 Readest 页面直接读取 API 与下载资源。loopback 与 CORS 不是请求鉴权的替代品,任何有副作用的端点都不得绕过 Bearer 令牌。
|
||||
|
||||
桌面内嵌模式通过本机 EPUB 路径创建 session;本地 `dev-web` 中书籍位于 IndexedDB,不能把逻辑路径交给 Python,必须读取当前 `File` 后上传到 `/api/upload`。上传必须携带 Readest 书籍 hash 作为稳定 `book_key`,让同一本书再次开启审校时复用原 session。部署版 Web 没有本地 Node/Python/文件系统链路,当前不在支持范围内。
|
||||
|
||||
## 前端验收标准
|
||||
|
||||
- 审校模式关闭时,原阅读、书库和其他功能行为不变。
|
||||
- 点击顶栏“校”能启动或复用 sidecar,并注册当前 EPUB session。
|
||||
- 只有中日双语段落可选择;点击或文本选择任一语言都打开同一条审校记录。
|
||||
- 关闭审校模式后,正文高亮、事件监听和临时预览全部清理。
|
||||
- 右侧面板不显示独立段落列表;API、提示词和术语表位于内容末尾。
|
||||
- 标签表示检索分类,本段备注记录当前问题,长期规则建议用于后续人工沉淀,三者不得混为一个字段。
|
||||
- 译文预览默认折叠;注音和注释按钮在当前选区插入可编辑标签并自动展开预览。
|
||||
- 保存按钮位于中文编辑区附近,保存后正文立即反映清洗后的译文。
|
||||
- 固定时面板挤开正文,切换固定状态或调整固定宽度后恢复原阅读位置。
|
||||
- 浮动时面板不挤开正文,可横向和纵向拖动,并可调整宽度和高度。
|
||||
- 面板变窄时文字和控件换行,所有内容仍可滚动到并完整操作。
|
||||
- 移动窄屏使用抽屉或底部面板,不遮挡主要操作。
|
||||
|
||||
## 版本规则
|
||||
|
||||
版本号位于 `version.py`:
|
||||
|
||||
- PATCH:bug 或兼容性小修
|
||||
- MINOR:向后兼容的能力或 API 扩展
|
||||
- MAJOR:删除入口、破坏 API 或改变持久化行为
|
||||
|
||||
发版时同步 `version.py`、`README.md` 和本文件。`1.0.0` 删除了旧独立 UI 和启动方式,是破坏兼容的边界收敛。`1.0.1` 修复本地 `dev-web` 上传/下载、会话隔离、译文恢复与空译文预览,并增加未保存修改提醒、动态端口 CORS 和 loopback 监听约束。`2.0.0` 为所有 sidecar 请求增加 Bearer 令牌,并强制书籍审校 API 显式携带 `session_id`,不再回退全局 active session。
|
||||
|
||||
## 回归清单
|
||||
|
||||
每次修改至少执行:
|
||||
|
||||
```powershell
|
||||
python -B -m py_compile tools/epub-review-editor/server.py tools/epub-review-editor/version.py
|
||||
python tools/epub-review-editor/test_inline_review_session_scope.py
|
||||
pnpm exec vitest run src/__tests__/services/reviewEditorService.test.ts src/__tests__/store/review-mode-store.test.ts src/__tests__/components/ReviewModeController.test.ts src/__tests__/components/ReviewModeToggler.test.tsx
|
||||
pnpm exec tsgo --noEmit --pretty false
|
||||
$env:READEST_REVIEW_E2E='1'; pnpm exec playwright test e2e/tests/review-mode.spec.ts --project=chromium
|
||||
```
|
||||
|
||||
涉及 Tauri 配置或 Rust 启动器时继续执行:
|
||||
|
||||
```powershell
|
||||
pnpm fmt:check
|
||||
pnpm clippy:check
|
||||
pnpm test:rust
|
||||
```
|
||||
|
||||
人工验收:打开桌面端和 Web 端,在真实中日双语 EPUB 中完成开启审校、选段、编辑、注音/注释、保存、重翻、反馈、导出、固定/浮动切换、拖动和宽高调整。确认书库与设置菜单中不再出现旧独立审校器入口。
|
||||
@@ -0,0 +1,95 @@
|
||||
# Readest 内嵌 EPUB 审校后端
|
||||
|
||||
当前版本:`2.0.0`
|
||||
|
||||
本目录只为 Readest 阅读页内的审校模式提供本地 sidecar API。打开 EPUB 后,点击阅读页顶栏的“校”按钮即可启动或复用服务,并在原阅读界面中选择中日文段落进行审校。
|
||||
|
||||
## 当前边界
|
||||
|
||||
保留的用户入口只有 Readest 阅读页内嵌审校模式。旧版独立审校器已经删除,包括:
|
||||
|
||||
- `/review-editor` 独立页面
|
||||
- 书库设置中的“EPUB 审校器”入口
|
||||
- 书籍右键菜单中的独立校对和翻译入口
|
||||
- `static/` 浏览器前端、独立启动脚本和开发脚本
|
||||
|
||||
`server.py`、`version.py` 和 `requirements.txt` 仍由桌面端打包,因为内嵌审校模式依赖它们完成 EPUB 解析、会话保存、重翻、术语表、反馈和导出。`src/app/api/review-editor/launch` 也仍用于本地 `dev-web` 启动 sidecar,不是旧独立页面。
|
||||
|
||||
## 使用方式
|
||||
|
||||
桌面端:
|
||||
|
||||
```powershell
|
||||
pnpm --filter @readest/readest-app tauri dev
|
||||
```
|
||||
|
||||
Web 开发端:
|
||||
|
||||
```powershell
|
||||
pnpm --filter @readest/readest-app dev-web
|
||||
```
|
||||
|
||||
在 Readest 中打开 EPUB,再点击阅读页顶栏“校”。桌面端通过 Tauri command `launch_epub_review_editor` 启动 sidecar,并用本机路径注册 session;本地 Web 开发端通过 `POST /api/review-editor/launch` 启动,再以 `POST /api/upload` 上传 IndexedDB 中的当前 EPUB,且使用 `activate=false` 保持多书会话隔离。启动器会在 review root 生成仅供本机使用的随机访问令牌;前端后续请求必须同时携带 `Authorization: Bearer <accessToken>` 和当前书的 `session_id`。
|
||||
|
||||
该 Web 链路仅支持本机 `dev-web`,依赖本机 Next.js Node 进程和 Python sidecar。部署到 Cloudflare 等托管环境的 Readest Web 不支持当前内嵌审校器。
|
||||
|
||||
本地端到端回归可设置 `READEST_REVIEW_E2E=1` 后运行 `pnpm exec playwright test e2e/tests/review-mode.spec.ts --project=chromium`。测试会即时生成不含版权内容的最小双语 EPUB,并验证开启、保存、重开续审和导出下载。
|
||||
|
||||
## 审校能力
|
||||
|
||||
- 在 Foliate 原阅读正文中识别并选择中日双语段落
|
||||
- 修改中文 HTML,并快捷插入 ruby 注音或内联注释
|
||||
- 记录问题类型、严重程度、标签、本段备注和长期规则建议
|
||||
- 配置 OpenAI 兼容 API、提示词和正式术语表
|
||||
- 对当前段落发起 API 重翻,并手动应用候选译文
|
||||
- 生成结构化反馈并导出修订后的 EPUB
|
||||
- 固定面板时挤开正文并恢复阅读位置;浮动时可自由移动和调整宽高
|
||||
|
||||
## 数据目录
|
||||
|
||||
默认审校数据位于应用数据目录下的 `epub_review_sessions/`。开发环境也可以通过 `READEST_REVIEW_ROOT` 指定位置。
|
||||
|
||||
每个 session 的关键文件:
|
||||
|
||||
```text
|
||||
<session-id>/
|
||||
source.epub
|
||||
extracted/
|
||||
review_state/
|
||||
rows.json
|
||||
state.json
|
||||
session.json
|
||||
gpt_config.json
|
||||
feedback/
|
||||
translation_feedback.jsonl
|
||||
feedback_for_codex.md
|
||||
exports/
|
||||
```
|
||||
|
||||
API Key 只写入本地运行配置,接口不得回传密钥,也不得写入 EPUB、反馈或导出文件。
|
||||
|
||||
sidecar 的所有 API 和下载请求都必须携带启动器返回的访问令牌,`/api/version` 健康探测也不例外。除创建 session 的 `/api/upload`、`/api/session/from-path` 等入口外,书籍审校接口缺少显式 `session_id` 时直接返回 `400`,不再回退全局 active session。
|
||||
|
||||
## 主要 API
|
||||
|
||||
- `GET /api/version`
|
||||
- `POST /api/session/from-path`
|
||||
- `POST /api/upload`(本地 `dev-web`,multipart,`activate=false`,携带稳定 `book_key` 复用会话)
|
||||
- `GET /api/session?session_id=...`
|
||||
- `GET /api/rows?session_id=...`
|
||||
- `POST /api/row/<row_id>?session_id=...`
|
||||
- `POST /api/row/<row_id>/retranslate?session_id=...`
|
||||
- `GET|POST /api/gpt/config?session_id=...`
|
||||
- `GET|POST /api/glossary?session_id=...`
|
||||
- `POST /api/feedback?session_id=...`
|
||||
- `POST /api/export?session_id=...`
|
||||
|
||||
详细维护边界和回归标准见 `MAINTENANCE.md`。
|
||||
|
||||
## 版本说明
|
||||
|
||||
- `0.16.0`:新增 Readest 阅读页内嵌审校模式。
|
||||
- `0.16.1` 至 `0.16.5`:完善停靠/浮动布局、宽高调整、注音注释快捷操作和响应式工程结构。
|
||||
- `1.0.0`:删除旧独立审校页面、书库入口、静态浏览器前端及独立启动链路,只保留内嵌审校所需的共享 sidecar API。
|
||||
- `1.0.1`:修复本地 `dev-web` EPUB 上传与下载、会话隔离、恢复初始译文写回、空译文预览、未保存修改提醒及动态开发端口 CORS;限制 sidecar 仅监听 loopback。
|
||||
- `2.0.0`:所有 sidecar API 和下载请求启用 Bearer 访问令牌;书籍审校 API 强制显式 `session_id`,移除对全局 active session 的隐式回退。
|
||||
@@ -0,0 +1 @@
|
||||
Flask>=3.0,<4.0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,376 @@
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SERVER_PATH = Path(__file__).with_name("server.py")
|
||||
SPEC = importlib.util.spec_from_file_location("epub_review_editor_server", SERVER_PATH)
|
||||
server = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
SPEC.loader.exec_module(server)
|
||||
|
||||
|
||||
def write_json(path: Path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
class InlineReviewSessionScopeTest(unittest.TestCase):
|
||||
ACCESS_TOKEN = "test-sidecar-token"
|
||||
|
||||
def make_app(self, review_root: Path, **kwargs):
|
||||
return server.create_app(review_root, access_token=self.ACCESS_TOKEN, **kwargs)
|
||||
|
||||
def client(self, app):
|
||||
client = app.test_client()
|
||||
client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {self.ACCESS_TOKEN}"
|
||||
return client
|
||||
|
||||
def test_cli_rejects_non_loopback_host(self):
|
||||
self.assertNotEqual(
|
||||
server.main(["--host", "0.0.0.0", "--access-token", self.ACCESS_TOKEN]), 0
|
||||
)
|
||||
|
||||
def test_local_dev_web_origin_on_dynamic_port_receives_cors_headers(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
app = self.make_app(Path(temp_dir))
|
||||
|
||||
response = self.client(app).get(
|
||||
"/api/version", headers={"Origin": "http://127.0.0.1:3017"}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
response.headers.get("Access-Control-Allow-Origin"),
|
||||
"http://127.0.0.1:3017",
|
||||
)
|
||||
self.assertIn(
|
||||
"Authorization", response.headers.get("Access-Control-Allow-Headers", "")
|
||||
)
|
||||
|
||||
def test_sidecar_rejects_missing_or_incorrect_access_token(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
app = self.make_app(Path(temp_dir))
|
||||
client = app.test_client()
|
||||
|
||||
missing = client.get("/api/version")
|
||||
incorrect = client.get(
|
||||
"/api/version", headers={"Authorization": "Bearer incorrect"}
|
||||
)
|
||||
authorized = client.get(
|
||||
"/api/version",
|
||||
headers={"Authorization": f"Bearer {self.ACCESS_TOKEN}"},
|
||||
)
|
||||
|
||||
self.assertEqual(missing.status_code, 401)
|
||||
self.assertEqual(incorrect.status_code, 401)
|
||||
self.assertEqual(authorized.status_code, 200)
|
||||
self.assertNotIn(self.ACCESS_TOKEN, authorized.get_data(as_text=True))
|
||||
|
||||
def test_cors_preflight_does_not_require_access_token(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
app = self.make_app(Path(temp_dir))
|
||||
|
||||
response = app.test_client().options(
|
||||
"/api/rows?session_id=session-a",
|
||||
headers={
|
||||
"Origin": "http://localhost:3017",
|
||||
"Access-Control-Request-Headers": "authorization,content-type",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.headers.get("Access-Control-Allow-Origin"),
|
||||
"http://localhost:3017",
|
||||
)
|
||||
|
||||
def test_sidecar_has_no_legacy_standalone_page(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
app = self.make_app(Path(temp_dir))
|
||||
|
||||
response = app.test_client().get("/")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_sanitize_cn_html_keeps_reader_review_inline_markup(self):
|
||||
sanitized = server.sanitize_cn_html(
|
||||
'<ruby><rb>文字</rb><rt>注音</rt></ruby>'
|
||||
'<span class="review-annotation">(注:注释)</span>'
|
||||
'<script>alert(1)</script>'
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
sanitized,
|
||||
'<ruby><rb>文字</rb><rt>注音</rt></ruby>'
|
||||
'<span class="review-annotation">(注:注释)</span>',
|
||||
)
|
||||
|
||||
def test_saved_row_restores_initial_html_after_previous_apply(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
session_root = Path(temp_dir)
|
||||
review_state = session_root / "review_state"
|
||||
extracted = session_root / "extracted" / "Text"
|
||||
review_state.mkdir(parents=True)
|
||||
extracted.mkdir(parents=True)
|
||||
row = {
|
||||
"id": "R00001",
|
||||
"file": "Text/chapter.xhtml",
|
||||
"file_label": "chapter.xhtml",
|
||||
"ja_p_index": 0,
|
||||
"cn_p_index": 1,
|
||||
"jp_html": "原文",
|
||||
"jp_text": "原文",
|
||||
"cn_html": "初始译文",
|
||||
"cn_text": "初始译文",
|
||||
}
|
||||
write_json(review_state / "rows.json", [row])
|
||||
write_json(
|
||||
server.rows_meta_path(session_root),
|
||||
{"parser_version": server.ROWS_PARSER_VERSION, "row_count": 1},
|
||||
)
|
||||
write_json(
|
||||
review_state / "state.json",
|
||||
{"edits": {"R00001": {"current_html": "初始译文"}}},
|
||||
)
|
||||
xhtml_path = extracted / "chapter.xhtml"
|
||||
xhtml_path.write_text(
|
||||
'<html><body><p class="sourceText">原文</p><p>先前修改</p></body></html>',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
modified = server.apply_edits_to_xhtml(session_root)
|
||||
|
||||
self.assertEqual(modified, ["Text/chapter.xhtml"])
|
||||
self.assertIn("<p>初始译文</p>", xhtml_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(
|
||||
json.loads((review_state / "rows.json").read_text(encoding="utf-8")),
|
||||
[row],
|
||||
)
|
||||
|
||||
def test_upload_can_create_a_session_without_changing_the_active_session(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
review_root = Path(temp_dir)
|
||||
first_epub = review_root / "first.epub"
|
||||
self.write_minimal_epub(first_epub)
|
||||
first_session = self.make_session(
|
||||
review_root, "first", first_epub, review_root / "glossary.json"
|
||||
)
|
||||
app = self.make_app(review_root=review_root, initial_epub_path=first_epub)
|
||||
client = self.client(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/upload",
|
||||
data={
|
||||
"epub": (io.BytesIO(first_epub.read_bytes()), "uploaded.epub"),
|
||||
"activate": "false",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
uploaded_id = response.get_json()["id"]
|
||||
active_session_id = client.get("/api/sessions").get_json()["active_session_id"]
|
||||
self.assertNotEqual(active_session_id, uploaded_id)
|
||||
self.assertIn("first", active_session_id)
|
||||
scoped_session = client.get(f"/api/session?session_id={uploaded_id}")
|
||||
self.assertEqual(scoped_session.status_code, 200)
|
||||
self.assertEqual(scoped_session.get_json()["id"], uploaded_id)
|
||||
|
||||
for endpoint in (
|
||||
"/api/session",
|
||||
"/api/rows",
|
||||
"/api/gpt/config",
|
||||
"/api/glossary",
|
||||
"/api/feedback",
|
||||
):
|
||||
unscoped = client.get(endpoint)
|
||||
self.assertEqual(unscoped.status_code, 400, endpoint)
|
||||
self.assertIn("session_id", unscoped.get_data(as_text=True))
|
||||
|
||||
def test_inline_upload_reuses_the_book_session(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
review_root = Path(temp_dir)
|
||||
epub = review_root / "book.epub"
|
||||
self.write_minimal_epub(epub)
|
||||
app = self.make_app(review_root=review_root)
|
||||
client = self.client(app)
|
||||
|
||||
def upload(filename: str):
|
||||
return client.post(
|
||||
"/api/upload",
|
||||
data={
|
||||
"epub": (io.BytesIO(epub.read_bytes()), filename),
|
||||
"activate": "false",
|
||||
"book_key": "stable-book-hash",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
first = upload("book.epub")
|
||||
second = upload("renamed-book.epub")
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(second.status_code, 200)
|
||||
self.assertEqual(first.get_json()["id"], second.get_json()["id"])
|
||||
|
||||
@staticmethod
|
||||
def write_minimal_epub(path: Path) -> None:
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr("mimetype", "application/epub+zip")
|
||||
|
||||
def make_session(
|
||||
self,
|
||||
review_root: Path,
|
||||
session_id: str,
|
||||
epub_path: Path,
|
||||
glossary_path: Path,
|
||||
) -> Path:
|
||||
session_root = review_root / session_id
|
||||
write_json(
|
||||
session_root / "review_state" / "session.json",
|
||||
{
|
||||
"id": session_id,
|
||||
"source_name": epub_path.name,
|
||||
"source_epub": str(epub_path),
|
||||
},
|
||||
)
|
||||
write_json(
|
||||
session_root / "review_state" / "state.json",
|
||||
{
|
||||
"source_epub": str(epub_path),
|
||||
"edits": {},
|
||||
},
|
||||
)
|
||||
write_json(
|
||||
session_root / "review_state" / "gpt_config.json",
|
||||
{
|
||||
"base_url": "https://session.example.test/v1",
|
||||
"model": "session-model",
|
||||
"glossary_path": str(glossary_path),
|
||||
"translation_prompt": "session translation prompt",
|
||||
"format_prompt": "session format prompt",
|
||||
"character_prompt": "session character prompt",
|
||||
},
|
||||
)
|
||||
return session_root
|
||||
|
||||
def test_inline_retranslate_uses_request_session_config_and_glossary(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
review_root = root / "review-root"
|
||||
global_glossary = root / "global-glossary.json"
|
||||
session_glossary = root / "session-glossary.json"
|
||||
write_json(global_glossary, {"別語": "全局译名"})
|
||||
write_json(session_glossary, {"ファルシオン": "大砍刀"})
|
||||
write_json(
|
||||
review_root / "gpt_config.json",
|
||||
{
|
||||
"api_key": "test-key",
|
||||
"base_url": "https://global.example.test/v1",
|
||||
"model": "global-model",
|
||||
"glossary_path": str(global_glossary),
|
||||
},
|
||||
)
|
||||
|
||||
epub_path = root / "book.epub"
|
||||
epub_path.write_bytes(b"not a real epub")
|
||||
session_root = self.make_session(review_root, "session-a", epub_path, session_glossary)
|
||||
write_json(
|
||||
session_root / "review_state" / "rows_meta.json",
|
||||
{"parser_version": server.ROWS_PARSER_VERSION},
|
||||
)
|
||||
write_json(
|
||||
session_root / "review_state" / "rows.json",
|
||||
[
|
||||
{
|
||||
"id": "R00001",
|
||||
"file": "chapter.xhtml",
|
||||
"file_label": "chapter.xhtml",
|
||||
"document_title": "chapter",
|
||||
"ja_p_index": 0,
|
||||
"cn_p_index": 1,
|
||||
"jp_html": "<ruby>ファルシオン<rt>Falchion</rt></ruby>",
|
||||
"jp_text": "ファルシオン",
|
||||
"cn_html": "旧译文",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
captured = {}
|
||||
original_call = server.call_openai_compatible_chat
|
||||
|
||||
def fake_call(config, messages, temperature=0.2, timeout=90):
|
||||
captured["config"] = config
|
||||
captured["messages"] = messages
|
||||
return "<span>候选译文</span>"
|
||||
|
||||
server.call_openai_compatible_chat = fake_call
|
||||
try:
|
||||
app = self.make_app(review_root)
|
||||
response = self.client(app).post(
|
||||
"/api/row/R00001/retranslate?session_id=session-a",
|
||||
json={"instruction": ""},
|
||||
)
|
||||
finally:
|
||||
server.call_openai_compatible_chat = original_call
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.get_data(as_text=True))
|
||||
payload = response.get_json()
|
||||
self.assertEqual(payload["model"], "session-model")
|
||||
self.assertEqual(payload["glossary_path"], str(session_glossary))
|
||||
self.assertEqual(payload["glossary_matches"], ["ファルシオン => 大砍刀"])
|
||||
self.assertEqual(captured["config"]["base_url"], "https://session.example.test/v1")
|
||||
self.assertEqual(captured["config"]["translation_prompt"], "session translation prompt")
|
||||
prompt_text = "\n".join(message["content"] for message in captured["messages"])
|
||||
self.assertIn("ファルシオン => 大砍刀", prompt_text)
|
||||
self.assertNotIn("全局译名", prompt_text)
|
||||
|
||||
def test_scoped_gpt_config_saves_secret_globally_and_public_response_hides_it(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
review_root = root / "review-root"
|
||||
glossary = root / "glossary.json"
|
||||
write_json(glossary, {})
|
||||
write_json(review_root / "gpt_config.json", {"glossary_path": str(glossary)})
|
||||
epub_path = root / "book.epub"
|
||||
epub_path.write_bytes(b"not a real epub")
|
||||
self.make_session(review_root, "session-a", epub_path, glossary)
|
||||
|
||||
app = self.make_app(review_root)
|
||||
response = self.client(app).post(
|
||||
"/api/gpt/config?session_id=session-a",
|
||||
json={
|
||||
"api_key": "session-secret",
|
||||
"base_url": "https://session.example.test/v1",
|
||||
"model": "session-model",
|
||||
"glossary_path": str(glossary),
|
||||
"translation_prompt": "session translation prompt",
|
||||
"format_prompt": "session format prompt",
|
||||
"character_prompt": "session character prompt",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.get_data(as_text=True))
|
||||
payload = response.get_json()
|
||||
self.assertTrue(payload["configured"])
|
||||
self.assertNotIn("api_key", payload)
|
||||
self.assertEqual(payload["base_url"], "https://session.example.test/v1")
|
||||
self.assertEqual(
|
||||
json.loads((review_root / "gpt_config.json").read_text(encoding="utf-8"))["api_key"],
|
||||
"session-secret",
|
||||
)
|
||||
scoped = json.loads(
|
||||
(review_root / "session-a" / "review_state" / "gpt_config.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
self.assertNotIn("api_key", scoped)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,7 @@
|
||||
version = "2.0.0"
|
||||
|
||||
VERSION_RULES = {
|
||||
"PATCH": "修复 bug 或兼容性小修",
|
||||
"MINOR": "新增向后兼容能力、API 字段或可选配置",
|
||||
"MAJOR": "破坏兼容的 API、配置或行为变更",
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0open-readest-latest.ps1" %*
|
||||
@@ -0,0 +1,202 @@
|
||||
param(
|
||||
[string]$Remote = "akai-tools",
|
||||
[string]$Branch = "codex/readest-inline-review-mode",
|
||||
[switch]$SkipPull,
|
||||
[switch]$CheckOnly,
|
||||
[switch]$KeepRunning
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Invoke-Checked {
|
||||
param(
|
||||
[string]$Command,
|
||||
[string[]]$Arguments,
|
||||
[string]$WorkingDirectory = $RepoRoot
|
||||
)
|
||||
|
||||
Push-Location $WorkingDirectory
|
||||
try {
|
||||
& $Command @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Command $($Arguments -join ' ') failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
function Add-PathIfExists {
|
||||
param([string]$PathToAdd)
|
||||
if ($PathToAdd -and (Test-Path -LiteralPath $PathToAdd)) {
|
||||
$script:PathParts += $PathToAdd
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-OldReadestDev {
|
||||
if ($KeepRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
Write-Step "Stopping old Readest desktop/dev processes"
|
||||
Get-Process -Name "Readest" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$repoLower = $RepoRoot.ToLowerInvariant()
|
||||
$currentPid = $PID
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$_.ProcessId -ne $currentPid -and
|
||||
$_.CommandLine -and
|
||||
$_.CommandLine.ToLowerInvariant().Contains($repoLower) -and
|
||||
($_.CommandLine -match "tauri\s+dev|next\s+dev|@readest/readest-app")
|
||||
} |
|
||||
ForEach-Object {
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$portConnection = Get-NetTCPConnection -State Listen -LocalPort 3000 -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if (-not $portConnection) {
|
||||
return
|
||||
}
|
||||
|
||||
$ownerPid = $portConnection.OwningProcess
|
||||
$processLineage = @()
|
||||
$lineagePid = $ownerPid
|
||||
while ($lineagePid -and $processLineage.Count -lt 8) {
|
||||
$lineageProcess = Get-CimInstance Win32_Process -Filter "ProcessId = $lineagePid" -ErrorAction SilentlyContinue
|
||||
if (-not $lineageProcess) {
|
||||
break
|
||||
}
|
||||
$processLineage += $lineageProcess
|
||||
$lineagePid = $lineageProcess.ParentProcessId
|
||||
}
|
||||
|
||||
$isReadestDev = $processLineage | Where-Object {
|
||||
$_.Name -ieq "Readest.exe" -or
|
||||
($_.CommandLine -and
|
||||
$_.CommandLine -match "(?i)readest" -and
|
||||
$_.CommandLine -match "(?i)next(.+?)dev|start-server\.js|@readest/readest-app|tauri(.+?)dev")
|
||||
}
|
||||
if (-not $isReadestDev) {
|
||||
$owner = $processLineage | Select-Object -First 1
|
||||
$ownerDescription = if ($owner.CommandLine) { $owner.CommandLine } else { "$($owner.Name) (PID $ownerPid)" }
|
||||
throw "Port 3000 is occupied by an unrelated process: $ownerDescription. Stop it or launch Readest after freeing port 3000."
|
||||
}
|
||||
|
||||
Write-Host "Stopping old Readest/Next listener on port 3000 (PID $ownerPid)." -ForegroundColor Yellow
|
||||
Stop-Process -Id $ownerPid -Force -ErrorAction SilentlyContinue
|
||||
for ($attempt = 0; $attempt -lt 20; $attempt++) {
|
||||
Start-Sleep -Milliseconds 250
|
||||
if (-not (Get-NetTCPConnection -State Listen -LocalPort 3000 -ErrorAction SilentlyContinue)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
throw "Port 3000 is still occupied after stopping the old Readest/Next process. Close the old development terminal and try again."
|
||||
}
|
||||
|
||||
function Assert-CleanWorktree {
|
||||
$status = @(& git -C $RepoRoot status --porcelain --untracked-files=no --ignore-submodules=dirty)
|
||||
if ($status) {
|
||||
Write-Host ""
|
||||
Write-Host "Local changes were found. The launcher will not pull or overwrite them:" -ForegroundColor Yellow
|
||||
$status | ForEach-Object { Write-Host $_ }
|
||||
throw "Commit, stash, or discard local changes before launching the latest organization version."
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$PathParts = @()
|
||||
$runtimeRoot = Join-Path $env:USERPROFILE ".cache\codex-runtimes\codex-primary-runtime\dependencies"
|
||||
Add-PathIfExists "C:\w64devkit\bin"
|
||||
Add-PathIfExists (Join-Path $runtimeRoot "bin")
|
||||
Add-PathIfExists (Join-Path $runtimeRoot "node\bin")
|
||||
Add-PathIfExists (Join-Path $env:USERPROFILE ".cargo\bin")
|
||||
$env:Path = (($PathParts + ($env:Path -split ";" | Where-Object { $_ })) -join ";")
|
||||
$env:RUSTUP_TOOLCHAIN = "stable-x86_64-pc-windows-gnu"
|
||||
|
||||
foreach ($command in @("git", "pnpm", "cargo")) {
|
||||
if (-not (Get-Command $command -ErrorAction SilentlyContinue)) {
|
||||
throw "Required command not found: $command"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "Readest latest launcher"
|
||||
Write-Host "Repository: $RepoRoot"
|
||||
Write-Host "Remote: $Remote"
|
||||
Write-Host "Branch: $Branch"
|
||||
|
||||
$beforeHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
|
||||
|
||||
if (-not $SkipPull) {
|
||||
Assert-CleanWorktree
|
||||
|
||||
Write-Step "Fetching latest code from organization remote"
|
||||
Invoke-Checked "git" @("-C", $RepoRoot, "fetch", "--prune", $Remote)
|
||||
|
||||
$currentBranch = (& git -C $RepoRoot branch --show-current).Trim()
|
||||
if ($currentBranch -ne $Branch) {
|
||||
Write-Step "Switching to $Branch"
|
||||
& git -C $RepoRoot show-ref --verify --quiet "refs/heads/$Branch"
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Invoke-Checked "git" @("-C", $RepoRoot, "switch", $Branch)
|
||||
} else {
|
||||
Invoke-Checked "git" @("-C", $RepoRoot, "switch", "--track", "-c", $Branch, "$Remote/$Branch")
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "Fast-forwarding local branch"
|
||||
Invoke-Checked "git" @("-C", $RepoRoot, "pull", "--ff-only", $Remote, $Branch)
|
||||
} else {
|
||||
Write-Step "Skipping git pull by request"
|
||||
}
|
||||
|
||||
$afterHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
|
||||
|
||||
Write-Step "Updating submodules"
|
||||
Invoke-Checked "git" @("-C", $RepoRoot, "submodule", "update", "--init", "--recursive")
|
||||
|
||||
$changedFiles = @()
|
||||
if ($beforeHead -ne $afterHead) {
|
||||
$changedFiles = (& git -C $RepoRoot diff --name-only $beforeHead $afterHead)
|
||||
}
|
||||
|
||||
$needsInstall =
|
||||
-not (Test-Path -LiteralPath (Join-Path $RepoRoot "node_modules")) -or
|
||||
($changedFiles -contains "pnpm-lock.yaml") -or
|
||||
($changedFiles -contains "package.json") -or
|
||||
($changedFiles -contains "apps/readest-app/package.json")
|
||||
|
||||
if ($needsInstall) {
|
||||
Write-Step "Installing/updating dependencies"
|
||||
Invoke-Checked "pnpm" @("install", "--frozen-lockfile")
|
||||
}
|
||||
|
||||
if ($CheckOnly) {
|
||||
Write-Step "Launcher check complete"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Stop-OldReadestDev
|
||||
|
||||
Write-Step "Starting Readest desktop with the latest code"
|
||||
Write-Host "Close the Readest window or press Ctrl+C here to stop the dev server."
|
||||
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "tauri", "dev")
|
||||
} catch {
|
||||
Write-Host ""
|
||||
Write-Host "Readest launcher failed:" -ForegroundColor Red
|
||||
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "Press Enter to close this window."
|
||||
[void][Console]::ReadLine()
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptPath = Join-Path $PSScriptRoot "open-readest-latest.ps1"
|
||||
$ScriptText = Get-Content -LiteralPath $ScriptPath -Raw
|
||||
|
||||
function Assert-Match {
|
||||
param(
|
||||
[string]$Pattern,
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
if ($ScriptText -notmatch $Pattern) {
|
||||
throw $Message
|
||||
}
|
||||
}
|
||||
|
||||
Assert-Match '\[string\]\$Remote\s*=\s*"akai-tools"' "Launcher must pull from akai-tools by default."
|
||||
Assert-Match 'status\s+--porcelain\s+--untracked-files=no\s+--ignore-submodules=dirty' "Clean check must ignore runtime logs and dirty submodule contents."
|
||||
Assert-Match 'Get-NetTCPConnection[^\r\n]*-LocalPort\s+3000' "Launcher must inspect the Tauri dev port before starting."
|
||||
Assert-Match 'Stop-Process[^\r\n]*-Id\s+\$ownerPid' "Launcher must stop an old Readest/Next process that owns port 3000."
|
||||
Assert-Match 'Port 3000 is occupied' "Launcher must report an actionable error for an unrelated port owner."
|
||||
Assert-Match 'Port 3000 is still occupied' "Launcher must wait for the previous dev server to release the port."
|
||||
|
||||
Write-Host "open-readest-latest launcher checks passed."
|
||||
Reference in New Issue
Block a user