Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4136c53c7 | |||
| 03c6969ce6 | |||
| c3a3a1d035 | |||
| acb7dd4c93 | |||
| 0d0732e579 | |||
| da756eed47 | |||
| 3938c9c8bd | |||
| 64508fd336 | |||
| 48383b3db6 | |||
| a06b6183d7 | |||
| 43ce37c61b | |||
| 545c56966f | |||
| 2d97de18c1 |
@@ -15,3 +15,5 @@ export const SAMPLE_EPUB = path.join(
|
|||||||
fixturesDir,
|
fixturesDir,
|
||||||
'../../src/__tests__/fixtures/data/sample-alice.epub',
|
'../../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,62 @@
|
|||||||
|
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 });
|
||||||
|
await panel.getByRole('tab', { name: '整书翻译' }).click();
|
||||||
|
await expect(panel.getByRole('heading', { name: '整书翻译' })).toBeVisible();
|
||||||
|
await expect(panel.getByText(/个正文块/)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await panel.getByRole('tab', { name: '逐段审校' }).click();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,8 +51,8 @@
|
|||||||
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.min.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
|
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.min.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
|
||||||
"copy-pdfjs-wasm": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/wasm/*\" ./public/vendor/pdfjs",
|
"copy-pdfjs-wasm": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/wasm/*\" ./public/vendor/pdfjs",
|
||||||
"copy-pdfjs-fonts": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs",
|
"copy-pdfjs-fonts": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs",
|
||||||
"copy-flatten-pdfjs-annotation-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
|
"copy-flatten-pdfjs-annotation-layer-css": "pnpm exec postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
|
||||||
"copy-flatten-pdfjs-text-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
|
"copy-flatten-pdfjs-text-layer-css": "pnpm exec postcss \"../../packages/foliate-js/vendor/pdfjs/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
|
||||||
"copy-flatten-pdfjs-css": "pnpm copy-flatten-pdfjs-annotation-layer-css && pnpm copy-flatten-pdfjs-text-layer-css",
|
"copy-flatten-pdfjs-css": "pnpm copy-flatten-pdfjs-annotation-layer-css && pnpm copy-flatten-pdfjs-text-layer-css",
|
||||||
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-wasm && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css",
|
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-wasm && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css",
|
||||||
"copy-simplecc": "cpx \"../../packages/simplecc-wasm/dist/web/*\" ./public/vendor/simplecc",
|
"copy-simplecc": "cpx \"../../packages/simplecc-wasm/dist/web/*\" ./public/vendor/simplecc",
|
||||||
|
|||||||
@@ -112,9 +112,9 @@ sentry = { version = "0.42", default-features = false, features = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
] }
|
] }
|
||||||
tauri-plugin-sentry = "0.5"
|
tauri-plugin-sentry = "0.5"
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
[target."cfg(target_os = \"macos\")".dependencies]
|
[target."cfg(target_os = \"macos\")".dependencies]
|
||||||
rand = "0.8"
|
|
||||||
cocoa = "0.25"
|
cocoa = "0.25"
|
||||||
objc = "0.2.7"
|
objc = "0.2.7"
|
||||||
objc-foundation = "0.1.1"
|
objc-foundation = "0.1.1"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
use rand::RngCore;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
|
fs::OpenOptions,
|
||||||
io::{Read, Write},
|
io::{Read, Write},
|
||||||
net::{SocketAddr, TcpStream},
|
net::{SocketAddr, TcpStream},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
@@ -24,6 +26,7 @@ pub struct ReviewEditorLaunchResponse {
|
|||||||
review_root: String,
|
review_root: String,
|
||||||
version: String,
|
version: String,
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
|
access_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CommandOutput {
|
struct CommandOutput {
|
||||||
@@ -76,10 +79,12 @@ fn launch_epub_review_editor_sync(
|
|||||||
review_root.to_string_lossy()
|
review_root.to_string_lossy()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
let access_token = ensure_access_token(&review_root)?;
|
||||||
|
|
||||||
let expected_version = read_expected_version(&tool_root);
|
let expected_version = read_expected_version(&tool_root);
|
||||||
if let Some(url) = find_running_editor(expected_version.as_deref(), &review_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())?;
|
{
|
||||||
|
let session_id = ensure_session_from_path(&url, epub_path.as_deref(), &access_token)?;
|
||||||
return Ok(ReviewEditorLaunchResponse {
|
return Ok(ReviewEditorLaunchResponse {
|
||||||
ok: true,
|
ok: true,
|
||||||
url,
|
url,
|
||||||
@@ -87,6 +92,7 @@ fn launch_epub_review_editor_sync(
|
|||||||
review_root: review_root.to_string_lossy().to_string(),
|
review_root: review_root.to_string_lossy().to_string(),
|
||||||
version: expected_version.unwrap_or_default(),
|
version: expected_version.unwrap_or_default(),
|
||||||
session_id,
|
session_id,
|
||||||
|
access_token,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,13 +117,15 @@ fn launch_epub_review_editor_sync(
|
|||||||
"127.0.0.1".to_string(),
|
"127.0.0.1".to_string(),
|
||||||
"--port".to_string(),
|
"--port".to_string(),
|
||||||
DEFAULT_PORT.to_string(),
|
DEFAULT_PORT.to_string(),
|
||||||
|
"--access-token".to_string(),
|
||||||
|
access_token.clone(),
|
||||||
"--daemon".to_string(),
|
"--daemon".to_string(),
|
||||||
],
|
],
|
||||||
Some(&tool_root),
|
Some(&tool_root),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let launched_url = extract_url(&output.stdout)
|
let launched_url = extract_url(&output.stdout)
|
||||||
.or_else(|| find_running_editor(expected_version.as_deref(), &review_root))
|
.or_else(|| find_running_editor(expected_version.as_deref(), &review_root, &access_token))
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
"审校器已启动但没有返回访问地址。\nstdout:\n{}\nstderr:\n{}",
|
"审校器已启动但没有返回访问地址。\nstdout:\n{}\nstderr:\n{}",
|
||||||
@@ -126,7 +134,7 @@ fn launch_epub_review_editor_sync(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let launched_url = launched_url.replace("http://localhost:", "http://127.0.0.1:");
|
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())?;
|
let session_id = ensure_session_from_path(&launched_url, epub_path.as_deref(), &access_token)?;
|
||||||
|
|
||||||
Ok(ReviewEditorLaunchResponse {
|
Ok(ReviewEditorLaunchResponse {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -135,9 +143,57 @@ fn launch_epub_review_editor_sync(
|
|||||||
review_root: review_root.to_string_lossy().to_string(),
|
review_root: review_root.to_string_lossy().to_string(),
|
||||||
version: expected_version.unwrap_or_default(),
|
version: expected_version.unwrap_or_default(),
|
||||||
session_id,
|
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> {
|
fn resolve_tool_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
if let Ok(raw) = std::env::var("READEST_REVIEW_EDITOR_TOOL_ROOT") {
|
if let Ok(raw) = std::env::var("READEST_REVIEW_EDITOR_TOOL_ROOT") {
|
||||||
let path = PathBuf::from(raw);
|
let path = PathBuf::from(raw);
|
||||||
@@ -292,12 +348,16 @@ fn read_expected_version(tool_root: &Path) -> Option<String> {
|
|||||||
Some(rest[..rest.find('"')?].to_string())
|
Some(rest[..rest.find('"')?].to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_running_editor(expected_version: Option<&str>, review_root: &Path) -> Option<String> {
|
fn find_running_editor(
|
||||||
|
expected_version: Option<&str>,
|
||||||
|
review_root: &Path,
|
||||||
|
access_token: &str,
|
||||||
|
) -> Option<String> {
|
||||||
let expected_root = review_root
|
let expected_root = review_root
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.unwrap_or_else(|_| review_root.to_path_buf());
|
.unwrap_or_else(|_| review_root.to_path_buf());
|
||||||
for port in DEFAULT_PORT..(DEFAULT_PORT + PORT_SCAN_LIMIT) {
|
for port in DEFAULT_PORT..(DEFAULT_PORT + PORT_SCAN_LIMIT) {
|
||||||
let Some(payload) = request_json(port, "/api/version") else {
|
let Some(payload) = request_json(port, "/api/version", access_token) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(version) = payload.get("version").and_then(Value::as_str) else {
|
let Some(version) = payload.get("version").and_then(Value::as_str) else {
|
||||||
@@ -322,17 +382,19 @@ fn find_running_editor(expected_version: Option<&str>, review_root: &Path) -> Op
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_json(port: u16, path: &str) -> Option<Value> {
|
fn request_json(port: u16, path: &str, access_token: &str) -> Option<Value> {
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||||
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(250)).ok()?;
|
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_read_timeout(Some(Duration::from_millis(800)));
|
||||||
let _ = stream.set_write_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\nConnection: close\r\n\r\n");
|
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()?;
|
stream.write_all(request.as_bytes()).ok()?;
|
||||||
read_http_json_response(stream)
|
read_http_json_response(stream)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn post_json(url: &str, path: &str, body: &str) -> Result<Value, String> {
|
fn post_json(url: &str, path: &str, body: &str, access_token: &str) -> Result<Value, String> {
|
||||||
let port = parse_loopback_port(url)?;
|
let port = parse_loopback_port(url)?;
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||||
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(3))
|
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(3))
|
||||||
@@ -340,7 +402,7 @@ fn post_json(url: &str, path: &str, body: &str) -> Result<Value, String> {
|
|||||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
|
let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
|
||||||
let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
|
let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
|
||||||
let request = format!(
|
let request = format!(
|
||||||
"POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
"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.len(),
|
||||||
body
|
body
|
||||||
);
|
);
|
||||||
@@ -392,12 +454,16 @@ fn parse_loopback_port(url: &str) -> Result<u16, String> {
|
|||||||
.map_err(|err| format!("无法解析审校器端口:{port_text} ({err})"))
|
.map_err(|err| format!("无法解析审校器端口:{port_text} ({err})"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_session_from_path(url: &str, epub_path: Option<&str>) -> Result<Option<String>, String> {
|
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 {
|
let Some(raw_path) = epub_path.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let body = serde_json::json!({ "epub_path": raw_path, "activate": false }).to_string();
|
let body = serde_json::json!({ "epub_path": raw_path, "activate": false }).to_string();
|
||||||
let payload = post_json(url, "/api/session/from-path", &body)?;
|
let payload = post_json(url, "/api/session/from-path", &body, access_token)?;
|
||||||
Ok(payload
|
Ok(payload
|
||||||
.get("id")
|
.get("id")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -416,13 +482,29 @@ fn run_command(
|
|||||||
Err(format!(
|
Err(format!(
|
||||||
"命令执行失败:{} {}\nstdout:\n{}\nstderr:\n{}",
|
"命令执行失败:{} {}\nstdout:\n{}\nstderr:\n{}",
|
||||||
program,
|
program,
|
||||||
args.join(" "),
|
redact_command_args(args).join(" "),
|
||||||
output.stdout,
|
output.stdout,
|
||||||
output.stderr
|
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(
|
fn run_command_allow_failure(
|
||||||
program: &str,
|
program: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@/context/EnvContext', () => ({
|
||||||
|
useEnv: () => ({ appService: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
import { __testing } from '@/app/reader/components/ReviewModeController';
|
import { __testing } from '@/app/reader/components/ReviewModeController';
|
||||||
import type { BookDoc } from '@/libs/document';
|
import type { BookDoc } from '@/libs/document';
|
||||||
@@ -144,4 +148,29 @@ describe('ReviewModeController marks', () => {
|
|||||||
expect(target.classList.contains('readest-review-active')).toBe(false);
|
expect(target.classList.contains('readest-review-active')).toBe(false);
|
||||||
expect(doc.querySelectorAll('p')[3]?.classList.contains('readest-review-active')).toBe(true);
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import type { ReviewRow } from '@/services/reviewEditorService';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
canLaunchInlineReviewEditor,
|
||||||
createReviewSessionFromPath,
|
createReviewSessionFromPath,
|
||||||
|
downloadReviewedEpub,
|
||||||
exportReviewedEpub,
|
exportReviewedEpub,
|
||||||
generateReviewFeedback,
|
generateReviewFeedback,
|
||||||
|
isReviewEditDirty,
|
||||||
|
launchInlineReviewEditor,
|
||||||
|
listTranslationJobs,
|
||||||
|
loadTranslationPlan,
|
||||||
|
controlTranslationJob,
|
||||||
loadInlineReviewData,
|
loadInlineReviewData,
|
||||||
loadReviewGlossary,
|
loadReviewGlossary,
|
||||||
saveReviewGptConfig,
|
saveReviewGptConfig,
|
||||||
@@ -38,14 +46,165 @@ const firstFetchUrl = (fetchMock: ReturnType<typeof vi.fn>) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('reviewEditorService', () => {
|
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 () => {
|
test('adds session_id to sidecar API calls without mutating endpoint paths', async () => {
|
||||||
const fetchMock = stubJsonFetch();
|
const fetchMock = stubJsonFetch();
|
||||||
|
|
||||||
await sidecarApi('http://127.0.0.1:5177', '/api/rows', {}, 'session-a');
|
await sidecarApi('http://127.0.0.1:5177', '/api/rows', {}, 'session-a', 'sidecar-token');
|
||||||
|
|
||||||
const url = new URL(firstFetchUrl(fetchMock));
|
const url = new URL(firstFetchUrl(fetchMock));
|
||||||
expect(url.pathname).toBe('/api/rows');
|
expect(url.pathname).toBe('/api/rows');
|
||||||
expect(url.searchParams.get('session_id')).toBe('session-a');
|
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 () => {
|
test('row operations are scoped to the current review session', async () => {
|
||||||
@@ -118,4 +277,28 @@ describe('reviewEditorService', () => {
|
|||||||
expect(url.searchParams.get('session_id')).toBe('session-a');
|
expect(url.searchParams.get('session_id')).toBe('session-a');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('translation APIs keep bearer auth and current-book session scope', async () => {
|
||||||
|
const fetchMock = stubJsonFetch();
|
||||||
|
|
||||||
|
await loadTranslationPlan('http://127.0.0.1:5177', 'session-a', 24, 'sidecar-token');
|
||||||
|
await listTranslationJobs('http://127.0.0.1:5177', 'session-a', 'sidecar-token');
|
||||||
|
await controlTranslationJob(
|
||||||
|
'http://127.0.0.1:5177',
|
||||||
|
'session-a',
|
||||||
|
'tr_1',
|
||||||
|
'pause',
|
||||||
|
'sidecar-token',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
for (const call of fetchMock.mock.calls as unknown as [
|
||||||
|
string | URL,
|
||||||
|
RequestInit | undefined,
|
||||||
|
][]) {
|
||||||
|
const url = new URL(call[0].toString());
|
||||||
|
expect(url.searchParams.get('session_id')).toBe('session-a');
|
||||||
|
expect(call[1]?.headers).toMatchObject({ Authorization: 'Bearer sidecar-token' });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ describe('reviewModeStore', () => {
|
|||||||
expect(state.books['book-a']?.selectedRowId).toBe('R00001');
|
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', () => {
|
test('updates one review row after save', () => {
|
||||||
useReviewModeStore.getState().setBookData('book-a', {
|
useReviewModeStore.getState().setBookData('book-a', {
|
||||||
rows: [
|
rows: [
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -27,6 +28,7 @@ type LaunchResponse = {
|
|||||||
reused?: boolean;
|
reused?: boolean;
|
||||||
reviewRoot?: string;
|
reviewRoot?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
accessToken?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,6 +41,28 @@ const appRoot = () => process.cwd();
|
|||||||
const toolRoot = () => path.join(appRoot(), 'tools', 'epub-review-editor');
|
const toolRoot = () => path.join(appRoot(), 'tools', 'epub-review-editor');
|
||||||
const reviewRoot = () =>
|
const reviewRoot = () =>
|
||||||
path.resolve(process.env['READEST_REVIEW_ROOT'] || path.join(appRoot(), 'epub_review_sessions'));
|
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 venvRoot = () => path.join(toolRoot(), '.venv');
|
||||||
const venvPython = () =>
|
const venvPython = () =>
|
||||||
@@ -91,7 +115,11 @@ const isLoopbackHost = (host: string | null) => {
|
|||||||
|
|
||||||
const normalizeLoopbackUrl = (url: string) => url.replace('http://localhost:', 'http://127.0.0.1:');
|
const normalizeLoopbackUrl = (url: string) => url.replace('http://localhost:', 'http://127.0.0.1:');
|
||||||
|
|
||||||
const requestJson = (port: number, pathname: string): Promise<Record<string, unknown> | null> =>
|
const requestJson = (
|
||||||
|
port: number,
|
||||||
|
pathname: string,
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<Record<string, unknown> | null> =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
const req = http.get(
|
const req = http.get(
|
||||||
{
|
{
|
||||||
@@ -99,6 +127,7 @@ const requestJson = (port: number, pathname: string): Promise<Record<string, unk
|
|||||||
port,
|
port,
|
||||||
path: pathname,
|
path: pathname,
|
||||||
timeout: 800,
|
timeout: 800,
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
},
|
},
|
||||||
(res) => {
|
(res) => {
|
||||||
let body = '';
|
let body = '';
|
||||||
@@ -126,12 +155,18 @@ const requestJson = (port: number, pathname: string): Promise<Record<string, unk
|
|||||||
req.on('error', () => resolve(null));
|
req.on('error', () => resolve(null));
|
||||||
});
|
});
|
||||||
|
|
||||||
const findRunningEditor = async () => {
|
const findRunningEditor = async (accessToken: string) => {
|
||||||
const expectedVersion = readExpectedVersion();
|
const expectedVersion = readExpectedVersion();
|
||||||
|
const expectedReviewRoot = path.resolve(reviewRoot());
|
||||||
for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SCAN_LIMIT; port++) {
|
for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SCAN_LIMIT; port++) {
|
||||||
const payload = await requestJson(port, '/api/version');
|
const payload = await requestJson(port, '/api/version', accessToken);
|
||||||
if (!payload) continue;
|
if (!payload) continue;
|
||||||
if (!expectedVersion || payload['version'] === expectedVersion) {
|
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 `http://127.0.0.1:${port}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,7 +272,8 @@ async function launchEditor(): Promise<LaunchResponse> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const runningUrl = await findRunningEditor();
|
const accessToken = ensureAccessToken();
|
||||||
|
const runningUrl = await findRunningEditor(accessToken);
|
||||||
if (runningUrl) {
|
if (runningUrl) {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -245,6 +281,7 @@ async function launchEditor(): Promise<LaunchResponse> {
|
|||||||
reused: true,
|
reused: true,
|
||||||
reviewRoot: reviewRoot(),
|
reviewRoot: reviewRoot(),
|
||||||
version: readExpectedVersion(),
|
version: readExpectedVersion(),
|
||||||
|
accessToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +300,8 @@ async function launchEditor(): Promise<LaunchResponse> {
|
|||||||
'127.0.0.1',
|
'127.0.0.1',
|
||||||
'--port',
|
'--port',
|
||||||
String(DEFAULT_PORT),
|
String(DEFAULT_PORT),
|
||||||
|
'--access-token',
|
||||||
|
accessToken,
|
||||||
'--daemon',
|
'--daemon',
|
||||||
],
|
],
|
||||||
{ cwd: bundledToolRoot, timeout: 45_000 },
|
{ cwd: bundledToolRoot, timeout: 45_000 },
|
||||||
@@ -272,7 +311,7 @@ async function launchEditor(): Promise<LaunchResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const launchedUrl = normalizeLoopbackUrl(
|
const launchedUrl = normalizeLoopbackUrl(
|
||||||
/https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor()),
|
/https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor(accessToken)),
|
||||||
);
|
);
|
||||||
if (!launchedUrl) {
|
if (!launchedUrl) {
|
||||||
throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`);
|
throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`);
|
||||||
@@ -284,6 +323,7 @@ async function launchEditor(): Promise<LaunchResponse> {
|
|||||||
reused: false,
|
reused: false,
|
||||||
reviewRoot: reviewRoot(),
|
reviewRoot: reviewRoot(),
|
||||||
version: readExpectedVersion(),
|
version: readExpectedVersion(),
|
||||||
|
accessToken,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import { Download, Pause, Play, RefreshCw, Square, Sparkles } from 'lucide-react';
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
controlTranslationJob,
|
||||||
|
downloadReviewedEpub,
|
||||||
|
listTranslationJobs,
|
||||||
|
loadTranslationDefaults,
|
||||||
|
loadTranslationJob,
|
||||||
|
loadTranslationPlan,
|
||||||
|
loadTranslationSource,
|
||||||
|
startFullBookTranslation,
|
||||||
|
type TranslationJobSummary,
|
||||||
|
type TranslationPlanPayload,
|
||||||
|
type TranslationSourcePayload,
|
||||||
|
} from '@/services/reviewEditorService';
|
||||||
|
|
||||||
|
type TranslationSettings = {
|
||||||
|
output_mode: 'bilingual' | 'translated';
|
||||||
|
range_mode: 'all' | 'limit';
|
||||||
|
limit: number;
|
||||||
|
temperature: number;
|
||||||
|
chunk_target: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultSettings: TranslationSettings = {
|
||||||
|
output_mode: 'bilingual',
|
||||||
|
range_mode: 'limit',
|
||||||
|
limit: 20,
|
||||||
|
temperature: 0.2,
|
||||||
|
chunk_target: 24,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRunningJob = (job: TranslationJobSummary | null) =>
|
||||||
|
job ? ['pending', 'running'].includes(job.status) : false;
|
||||||
|
|
||||||
|
const blocksNewJob = (job: TranslationJobSummary | null) =>
|
||||||
|
job ? ['pending', 'running', 'paused'].includes(job.status) : false;
|
||||||
|
|
||||||
|
const ActionButton = ({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='btn btn-ghost eink-bordered h-9 min-h-9 rounded-md px-3'
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FullBookTranslationPanel = ({
|
||||||
|
baseUrl,
|
||||||
|
sessionId,
|
||||||
|
accessToken,
|
||||||
|
}: {
|
||||||
|
baseUrl: string;
|
||||||
|
sessionId: string;
|
||||||
|
accessToken: string;
|
||||||
|
}) => {
|
||||||
|
const [settings, setSettings] = useState(defaultSettings);
|
||||||
|
const [source, setSource] = useState<TranslationSourcePayload | null>(null);
|
||||||
|
const [plan, setPlan] = useState<TranslationPlanPayload | null>(null);
|
||||||
|
const [job, setJob] = useState<TranslationJobSummary | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const pollRef = useRef<number | null>(null);
|
||||||
|
const defaultsAppliedRef = useRef(false);
|
||||||
|
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
if (pollRef.current !== null) window.clearInterval(pollRef.current);
|
||||||
|
pollRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshJob = useCallback(
|
||||||
|
async (jobId: string) => {
|
||||||
|
const payload = await loadTranslationJob(baseUrl, sessionId, jobId, accessToken);
|
||||||
|
setJob(payload.job);
|
||||||
|
if (!isRunningJob(payload.job)) stopPolling();
|
||||||
|
},
|
||||||
|
[accessToken, baseUrl, sessionId, stopPolling],
|
||||||
|
);
|
||||||
|
|
||||||
|
const startPolling = useCallback(
|
||||||
|
(jobId: string) => {
|
||||||
|
stopPolling();
|
||||||
|
pollRef.current = window.setInterval(() => {
|
||||||
|
void refreshJob(jobId).catch((error) => {
|
||||||
|
stopPolling();
|
||||||
|
setMessage(error instanceof Error ? error.message : String(error));
|
||||||
|
});
|
||||||
|
}, 1200);
|
||||||
|
},
|
||||||
|
[refreshJob, stopPolling],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadOverview = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
const [sourcePayload, defaultsPayload, planPayload, jobsPayload] = await Promise.all([
|
||||||
|
loadTranslationSource(baseUrl, sessionId, accessToken),
|
||||||
|
loadTranslationDefaults(baseUrl, sessionId, accessToken),
|
||||||
|
loadTranslationPlan(baseUrl, sessionId, settings.chunk_target, accessToken),
|
||||||
|
listTranslationJobs(baseUrl, sessionId, accessToken),
|
||||||
|
]);
|
||||||
|
setSource(sourcePayload);
|
||||||
|
setPlan(planPayload);
|
||||||
|
if (defaultsPayload.defaults && !defaultsAppliedRef.current) {
|
||||||
|
defaultsAppliedRef.current = true;
|
||||||
|
setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
output_mode: defaultsPayload.defaults?.output_mode || current.output_mode,
|
||||||
|
range_mode: defaultsPayload.defaults?.range_mode || current.range_mode,
|
||||||
|
limit: defaultsPayload.defaults?.limit || current.limit,
|
||||||
|
temperature: defaultsPayload.defaults?.temperature ?? current.temperature,
|
||||||
|
chunk_target: defaultsPayload.defaults?.chunk_target || current.chunk_target,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const latest = jobsPayload.jobs[0] || null;
|
||||||
|
setJob(latest);
|
||||||
|
if (latest && isRunningJob(latest)) startPolling(latest.id);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [accessToken, baseUrl, sessionId, settings.chunk_target, startPolling]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadOverview();
|
||||||
|
return stopPolling;
|
||||||
|
}, [loadOverview, stopPolling]);
|
||||||
|
|
||||||
|
const refreshPlan = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
setPlan(await loadTranslationPlan(baseUrl, sessionId, settings.chunk_target, accessToken));
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
if (
|
||||||
|
settings.range_mode === 'all' &&
|
||||||
|
!window.confirm('整书翻译会连续调用已配置的 GPT API。确认开始吗?')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
const payload = await startFullBookTranslation(baseUrl, sessionId, settings, accessToken);
|
||||||
|
setJob(payload.job);
|
||||||
|
startPolling(payload.job.id);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const control = async (action: 'pause' | 'resume' | 'cancel') => {
|
||||||
|
if (!job) return;
|
||||||
|
setBusy(true);
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
const payload = await controlTranslationJob(baseUrl, sessionId, job.id, action, accessToken);
|
||||||
|
setJob(payload.job);
|
||||||
|
startPolling(job.id);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const outputUrl = job?.output_download_url
|
||||||
|
? new URL(job.output_download_url, baseUrl).toString()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='grid gap-3'>
|
||||||
|
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
|
||||||
|
<div className='flex items-center justify-between gap-2'>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<h3 className='text-sm font-semibold'>整书翻译</h3>
|
||||||
|
<p className='text-base-content/60 text-xs'>
|
||||||
|
{source ? `${source.source_count} 个正文块` : '正在读取翻译源'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ActionButton label='刷新翻译状态' onClick={() => void loadOverview()} disabled={busy}>
|
||||||
|
<RefreshCw className='h-4 w-4' />
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-2 gap-2'>
|
||||||
|
<label className='grid gap-1 text-xs font-medium'>
|
||||||
|
输出格式
|
||||||
|
<select
|
||||||
|
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
|
||||||
|
value={settings.output_mode}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
output_mode: event.target.value as TranslationSettings['output_mode'],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value='bilingual'>中日双语</option>
|
||||||
|
<option value='translated'>纯中文</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className='grid gap-1 text-xs font-medium'>
|
||||||
|
翻译范围
|
||||||
|
<select
|
||||||
|
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
|
||||||
|
value={settings.range_mode}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
range_mode: event.target.value as TranslationSettings['range_mode'],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value='limit'>试译</option>
|
||||||
|
<option value='all'>全书</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{settings.range_mode === 'limit' ? (
|
||||||
|
<label className='grid gap-1 text-xs font-medium'>
|
||||||
|
试译段数
|
||||||
|
<input
|
||||||
|
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
|
||||||
|
type='number'
|
||||||
|
min={1}
|
||||||
|
max={5000}
|
||||||
|
value={settings.limit}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings((current) => ({ ...current, limit: Number(event.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label className='grid gap-1 text-xs font-medium'>
|
||||||
|
计划分组大小
|
||||||
|
<input
|
||||||
|
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
|
||||||
|
type='number'
|
||||||
|
min={1}
|
||||||
|
max={120}
|
||||||
|
value={settings.chunk_target}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
chunk_target: Number(event.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onBlur={() => void refreshPlan()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plan ? (
|
||||||
|
<p className='text-base-content/70 text-xs'>
|
||||||
|
{plan.chunk_count} 个进度分组 · 约 {plan.estimated_input_tokens.toLocaleString()} 输入
|
||||||
|
token · 不跨 XHTML 文件
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='btn btn-primary h-auto min-h-9 rounded-md px-3 py-2 text-sm'
|
||||||
|
onClick={() => void start()}
|
||||||
|
disabled={busy || blocksNewJob(job) || !source?.source_count}
|
||||||
|
>
|
||||||
|
<Sparkles className='h-4 w-4' />
|
||||||
|
{settings.range_mode === 'all' ? '开始整书翻译' : '开始试译'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{job ? (
|
||||||
|
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
|
||||||
|
<div className='flex items-center justify-between gap-2'>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<h3 className='text-sm font-semibold'>任务 {job.id}</h3>
|
||||||
|
<p className='text-base-content/60 text-xs break-words'>
|
||||||
|
{job.status} · {job.progress?.current || 0}/{job.progress?.total || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className='text-sm font-semibold'>{job.progress?.percent || 0}%</span>
|
||||||
|
</div>
|
||||||
|
<progress
|
||||||
|
className='progress progress-primary h-2 w-full'
|
||||||
|
value={job.progress?.percent || 0}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
{job.progress?.message ? (
|
||||||
|
<p className='text-base-content/70 text-xs break-words'>{job.progress.message}</p>
|
||||||
|
) : null}
|
||||||
|
{job.error ? <p className='text-error text-xs break-words'>{job.error}</p> : null}
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
{job.status === 'running' || job.status === 'pending' ? (
|
||||||
|
<ActionButton label='暂停翻译' onClick={() => void control('pause')} disabled={busy}>
|
||||||
|
<Pause className='h-4 w-4' />
|
||||||
|
</ActionButton>
|
||||||
|
) : null}
|
||||||
|
{['paused', 'failed', 'cancelled'].includes(job.status) ? (
|
||||||
|
<ActionButton
|
||||||
|
label='从断点恢复'
|
||||||
|
onClick={() => void control('resume')}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Play className='h-4 w-4' />
|
||||||
|
</ActionButton>
|
||||||
|
) : null}
|
||||||
|
{!['completed', 'failed', 'cancelled'].includes(job.status) ? (
|
||||||
|
<ActionButton label='取消翻译' onClick={() => void control('cancel')} disabled={busy}>
|
||||||
|
<Square className='h-4 w-4' />
|
||||||
|
</ActionButton>
|
||||||
|
) : null}
|
||||||
|
{outputUrl ? (
|
||||||
|
<ActionButton
|
||||||
|
label='下载翻译 EPUB'
|
||||||
|
onClick={() => void downloadReviewedEpub(outputUrl, accessToken)}
|
||||||
|
>
|
||||||
|
<Download className='h-4 w-4' />
|
||||||
|
</ActionButton>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{message ? (
|
||||||
|
<div
|
||||||
|
className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'
|
||||||
|
role='status'
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FullBookTranslationPanel;
|
||||||
@@ -212,7 +212,17 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
|||||||
navigateBackToLibrary();
|
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);
|
return handleCloseBooks(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -230,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:
|
// SPA navigation in the main window (or on web) keeps the webview alive:
|
||||||
// TTS may continue headless. Non-main Tauri windows close their webview
|
// TTS may continue headless. Non-main Tauri windows close their webview
|
||||||
// below, but their per-window TTS dies with the window either way.
|
// 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);
|
handleCloseBooks(true);
|
||||||
if (isTauriAppPlatform()) {
|
if (isTauriAppPlatform()) {
|
||||||
const currentWindow = getCurrentWindow();
|
const currentWindow = getCurrentWindow();
|
||||||
@@ -250,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.
|
// Header X / pane close: an SPA-side close on web and the main window.
|
||||||
// The Tauri reader-window branches below destroy their webview, which
|
// The Tauri reader-window branches below destroy their webview, which
|
||||||
// takes the per-window TTS with it either way.
|
// 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);
|
saveConfigAndCloseBook(bookKey, true);
|
||||||
if (sideBarBookKey === bookKey) {
|
if (sideBarBookKey === bookKey) {
|
||||||
setSideBarBookKey(getNextBookKey(sideBarBookKey));
|
setSideBarBookKey(getNextBookKey(sideBarBookKey));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { useEnv } from '@/context/EnvContext';
|
||||||
import type { BookDoc } from '@/libs/document';
|
import type { BookDoc } from '@/libs/document';
|
||||||
import type { ReviewRow } from '@/services/reviewEditorService';
|
import type { ReviewRow } from '@/services/reviewEditorService';
|
||||||
import { useReaderStore } from '@/store/readerStore';
|
import { useReaderStore } from '@/store/readerStore';
|
||||||
@@ -147,7 +148,7 @@ const markParagraph = (
|
|||||||
htmlElement.setAttribute(ROLE_ATTR, role);
|
htmlElement.setAttribute(ROLE_ATTR, role);
|
||||||
htmlElement.classList.add(role === 'source' ? 'readest-review-source' : 'readest-review-target');
|
htmlElement.classList.add(role === 'source' ? 'readest-review-source' : 'readest-review-target');
|
||||||
htmlElement.classList.toggle('readest-review-active', active);
|
htmlElement.classList.toggle('readest-review-active', active);
|
||||||
if (role === 'target' && row.current_html) {
|
if (role === 'target') {
|
||||||
if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) {
|
if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) {
|
||||||
htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML);
|
htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML);
|
||||||
}
|
}
|
||||||
@@ -194,6 +195,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
|
|||||||
bookKey,
|
bookKey,
|
||||||
bookDoc,
|
bookDoc,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { appService } = useEnv();
|
||||||
const view = useReaderStore((state) => state.viewStates[bookKey]?.view);
|
const view = useReaderStore((state) => state.viewStates[bookKey]?.view);
|
||||||
const enabled = useReviewModeStore((state) => !!state.books[bookKey]?.enabled);
|
const enabled = useReviewModeStore((state) => !!state.books[bookKey]?.enabled);
|
||||||
const rows = useReviewModeStore((state) => state.books[bookKey]?.rows ?? emptyReviewRows);
|
const rows = useReviewModeStore((state) => state.books[bookKey]?.rows ?? emptyReviewRows);
|
||||||
@@ -205,6 +207,24 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
|
|||||||
selectedRowIdRef.current = selectedRowId;
|
selectedRowIdRef.current = selectedRowId;
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
|
|
||||||
@@ -235,7 +255,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
|
|||||||
if ('stopImmediatePropagation' in event) {
|
if ('stopImmediatePropagation' in event) {
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
}
|
}
|
||||||
selectRow(bookKey, rowId);
|
void selectReviewRow(rowId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectionEnd = (event: Event) => {
|
const handleSelectionEnd = (event: Event) => {
|
||||||
@@ -244,7 +264,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
|
|||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const target = selectedReviewTarget(doc);
|
const target = selectedReviewTarget(doc);
|
||||||
const rowId = target?.getAttribute(ROW_ATTR);
|
const rowId = target?.getAttribute(ROW_ATTR);
|
||||||
if (rowId) selectRow(bookKey, rowId);
|
if (rowId) void selectReviewRow(rowId);
|
||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -292,7 +312,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
|
|||||||
clearReviewMarks(doc);
|
clearReviewMarks(doc);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [bookDoc, bookKey, enabled, rows, selectRow, view]);
|
}, [appService, bookDoc, bookKey, enabled, rows, selectRow, view]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!view || !enabled || !rows.length) return;
|
if (!view || !enabled || !rows.length) return;
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { useBookDataStore } from '@/store/bookDataStore';
|
|||||||
import { useReaderStore } from '@/store/readerStore';
|
import { useReaderStore } from '@/store/readerStore';
|
||||||
import { useReviewModeStore } from '@/store/reviewModeStore';
|
import { useReviewModeStore } from '@/store/reviewModeStore';
|
||||||
import { eventDispatcher } from '@/utils/event';
|
import { eventDispatcher } from '@/utils/event';
|
||||||
import { launchInlineReviewEditor, loadInlineReviewData } from '@/services/reviewEditorService';
|
import {
|
||||||
|
canLaunchInlineReviewEditor,
|
||||||
|
launchInlineReviewEditor,
|
||||||
|
loadInlineReviewData,
|
||||||
|
} from '@/services/reviewEditorService';
|
||||||
|
|
||||||
interface ReviewModeTogglerProps {
|
interface ReviewModeTogglerProps {
|
||||||
bookKey: string;
|
bookKey: string;
|
||||||
@@ -32,7 +36,7 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
|
|||||||
const enabled = !!reviewState?.enabled;
|
const enabled = !!reviewState?.enabled;
|
||||||
const loading = !!reviewState?.loading;
|
const loading = !!reviewState?.loading;
|
||||||
|
|
||||||
if (!appService?.isDesktopApp || bookData?.book?.format !== 'EPUB') return null;
|
if (!canLaunchInlineReviewEditor(appService) || bookData?.book?.format !== 'EPUB') return null;
|
||||||
|
|
||||||
const handleToggleReviewMode = async () => {
|
const handleToggleReviewMode = async () => {
|
||||||
if (appService?.isMobile) {
|
if (appService?.isMobile) {
|
||||||
@@ -40,6 +44,13 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
|
if (
|
||||||
|
reviewState?.hasUnsavedEdit &&
|
||||||
|
appService &&
|
||||||
|
!(await appService.ask('当前段落有未保存的修改,确定关闭审校模式吗?'))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBookEnabled(bookKey, false);
|
setBookEnabled(bookKey, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -49,15 +60,34 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
|
|||||||
return;
|
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);
|
setActiveBookKey(bookKey);
|
||||||
setPanelVisible(true);
|
setPanelVisible(true);
|
||||||
setBookLoading(bookKey, true);
|
setBookLoading(bookKey, true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const launch = await launchInlineReviewEditor(appService, bookData.book);
|
const launch = await launchInlineReviewEditor(appService, bookData.book);
|
||||||
const data = await loadInlineReviewData(launch.url, launch.sessionId);
|
const data = await loadInlineReviewData(
|
||||||
|
launch.url,
|
||||||
|
launch.sessionId,
|
||||||
|
launch.accessToken,
|
||||||
|
);
|
||||||
setBookData(bookKey, {
|
setBookData(bookKey, {
|
||||||
baseUrl: launch.url,
|
baseUrl: launch.url,
|
||||||
|
accessToken: launch.accessToken,
|
||||||
sessionId: launch.sessionId,
|
sessionId: launch.sessionId,
|
||||||
reviewRoot: launch.reviewRoot,
|
reviewRoot: launch.reviewRoot,
|
||||||
version: launch.version,
|
version: launch.version,
|
||||||
@@ -91,7 +121,7 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
|
|||||||
onClick={handleToggleReviewMode}
|
onClick={handleToggleReviewMode}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
label={enabled ? _('Disable Review Mode') : _('Review Mode')}
|
label={enabled ? _('Disable Review Mode') : _('Review Mode')}
|
||||||
className={clsx(enabled && 'bg-base-300/50', loading && 'animate-pulse')}
|
className={clsx(enabled && 'bg-base-300/50')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ import { useReaderStore } from '@/store/readerStore';
|
|||||||
import { useReviewModeStore } from '@/store/reviewModeStore';
|
import { useReviewModeStore } from '@/store/reviewModeStore';
|
||||||
import { useThemeStore } from '@/store/themeStore';
|
import { useThemeStore } from '@/store/themeStore';
|
||||||
import { getPanelTopInset } from '@/utils/insets';
|
import { getPanelTopInset } from '@/utils/insets';
|
||||||
import { openExternalUrl } from '@/utils/open';
|
|
||||||
import { useReviewPanelDrag } from '../hooks/useReviewPanelDrag';
|
import { useReviewPanelDrag } from '../hooks/useReviewPanelDrag';
|
||||||
import { useReviewPanelFloatingResize } from '../hooks/useReviewPanelFloatingResize';
|
import { useReviewPanelFloatingResize } from '../hooks/useReviewPanelFloatingResize';
|
||||||
|
import FullBookTranslationPanel from './FullBookTranslationPanel';
|
||||||
import {
|
import {
|
||||||
|
downloadReviewedEpub,
|
||||||
exportReviewedEpub,
|
exportReviewedEpub,
|
||||||
generateReviewFeedback,
|
generateReviewFeedback,
|
||||||
|
isReviewEditDirty,
|
||||||
loadReviewGlossary,
|
loadReviewGlossary,
|
||||||
retranslateReviewRow,
|
retranslateReviewRow,
|
||||||
saveReviewGlossary,
|
saveReviewGlossary,
|
||||||
@@ -488,6 +490,7 @@ const ReviewPanel: React.FC = () => {
|
|||||||
const togglePanelPin = useReviewModeStore((state) => state.togglePanelPin);
|
const togglePanelPin = useReviewModeStore((state) => state.togglePanelPin);
|
||||||
const setPanelWidth = useReviewModeStore((state) => state.setPanelWidth);
|
const setPanelWidth = useReviewModeStore((state) => state.setPanelWidth);
|
||||||
const setPanelHeight = useReviewModeStore((state) => state.setPanelHeight);
|
const setPanelHeight = useReviewModeStore((state) => state.setPanelHeight);
|
||||||
|
const setBookUnsavedEdit = useReviewModeStore((state) => state.setBookUnsavedEdit);
|
||||||
const updateRow = useReviewModeStore((state) => state.updateRow);
|
const updateRow = useReviewModeStore((state) => state.updateRow);
|
||||||
const setBookData = useReviewModeStore((state) => state.setBookData);
|
const setBookData = useReviewModeStore((state) => state.setBookData);
|
||||||
const bookState = useReviewModeStore((state) =>
|
const bookState = useReviewModeStore((state) =>
|
||||||
@@ -508,7 +511,9 @@ const ReviewPanel: React.FC = () => {
|
|||||||
const [exportInfo, setExportInfo] = useState('');
|
const [exportInfo, setExportInfo] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [retranslating, setRetranslating] = useState(false);
|
const [retranslating, setRetranslating] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
const [panelMode, setPanelMode] = useState<'review' | 'translate'>('review');
|
||||||
const isMobile = useIsMobileViewport();
|
const isMobile = useIsMobileViewport();
|
||||||
const panelTopInset = getPanelTopInset({
|
const panelTopInset = getPanelTopInset({
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -527,6 +532,19 @@ const ReviewPanel: React.FC = () => {
|
|||||||
[bookState?.rows, bookState?.selectedRowId],
|
[bookState?.rows, bookState?.selectedRowId],
|
||||||
);
|
);
|
||||||
const canUseRowActions = Boolean(selectedRow && bookState?.baseUrl);
|
const canUseRowActions = Boolean(selectedRow && bookState?.baseUrl);
|
||||||
|
const hasUnsavedEdit = Boolean(selectedRow && isReviewEditDirty(selectedRow, edit));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeBookKey) return;
|
||||||
|
setBookUnsavedEdit(activeBookKey, hasUnsavedEdit);
|
||||||
|
}, [activeBookKey, hasUnsavedEdit, setBookUnsavedEdit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasUnsavedEdit) return;
|
||||||
|
const warnBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault();
|
||||||
|
window.addEventListener('beforeunload', warnBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
|
||||||
|
}, [hasUnsavedEdit]);
|
||||||
|
|
||||||
const bookTitle = activeBookKey
|
const bookTitle = activeBookKey
|
||||||
? getBookData(activeBookKey)?.book?.title || selectedRow?.document_title || '审校'
|
? getBookData(activeBookKey)?.book?.title || selectedRow?.document_title || '审校'
|
||||||
@@ -544,7 +562,7 @@ const ReviewPanel: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEdit({
|
setEdit({
|
||||||
current_html: selectedRow.current_html || selectedRow.cn_html || '',
|
current_html: selectedRow.current_html ?? selectedRow.cn_html ?? '',
|
||||||
marked: Boolean(selectedRow.marked),
|
marked: Boolean(selectedRow.marked),
|
||||||
issue_type: selectedRow.issue_type || '',
|
issue_type: selectedRow.issue_type || '',
|
||||||
severity: selectedRow.severity || '',
|
severity: selectedRow.severity || '',
|
||||||
@@ -574,6 +592,17 @@ const ReviewPanel: React.FC = () => {
|
|||||||
togglePanelPin();
|
togglePanelPin();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closePanel = async () => {
|
||||||
|
if (
|
||||||
|
hasUnsavedEdit &&
|
||||||
|
appService &&
|
||||||
|
!(await appService.ask('当前段落有未保存的修改,确定关闭审校面板吗?'))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPanelVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
const floatingPanelEnabled = !isPanelPinned && !isMobile;
|
const floatingPanelEnabled = !isPanelPinned && !isMobile;
|
||||||
const {
|
const {
|
||||||
panelPosition,
|
panelPosition,
|
||||||
@@ -666,7 +695,7 @@ const ReviewPanel: React.FC = () => {
|
|||||||
setMessage('');
|
setMessage('');
|
||||||
try {
|
try {
|
||||||
const payload = await saveReviewRow(baseUrl, sessionId, selectedRow.id, edit);
|
const payload = await saveReviewRow(baseUrl, sessionId, selectedRow.id, edit);
|
||||||
const savedHtml = payload.current_html || sanitizeInlineHtml(edit.current_html);
|
const savedHtml = payload.current_html ?? sanitizeInlineHtml(edit.current_html);
|
||||||
const nextRow = {
|
const nextRow = {
|
||||||
...selectedRow,
|
...selectedRow,
|
||||||
...edit,
|
...edit,
|
||||||
@@ -725,6 +754,7 @@ const ReviewPanel: React.FC = () => {
|
|||||||
|
|
||||||
const exportEpub = async () => {
|
const exportEpub = async () => {
|
||||||
if (!baseUrl) return;
|
if (!baseUrl) return;
|
||||||
|
setExporting(true);
|
||||||
setExportInfo('');
|
setExportInfo('');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
try {
|
try {
|
||||||
@@ -733,9 +763,14 @@ const ReviewPanel: React.FC = () => {
|
|||||||
? new URL(payload.download_url, baseUrl).toString()
|
? new URL(payload.download_url, baseUrl).toString()
|
||||||
: '';
|
: '';
|
||||||
setExportInfo(downloadUrl || payload.output || '');
|
setExportInfo(downloadUrl || payload.output || '');
|
||||||
|
if (downloadUrl) {
|
||||||
|
await downloadReviewedEpub(downloadUrl, bookState.accessToken);
|
||||||
|
}
|
||||||
setMessage(`已导出:${payload.output || downloadUrl}`);
|
setMessage(`已导出:${payload.output || downloadUrl}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMessage(error instanceof Error ? error.message : String(error));
|
setMessage(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -806,7 +841,7 @@ const ReviewPanel: React.FC = () => {
|
|||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Overlay
|
<Overlay
|
||||||
className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/50 sm:bg-black/20')}
|
className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/50 sm:bg-black/20')}
|
||||||
onDismiss={() => setPanelVisible(false)}
|
onDismiss={() => void closePanel()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<aside
|
<aside
|
||||||
@@ -893,7 +928,7 @@ const ReviewPanel: React.FC = () => {
|
|||||||
type='button'
|
type='button'
|
||||||
className='btn btn-ghost h-8 min-h-8 w-8 shrink-0 rounded-full p-0'
|
className='btn btn-ghost h-8 min-h-8 w-8 shrink-0 rounded-full p-0'
|
||||||
title={_('Close')}
|
title={_('Close')}
|
||||||
onClick={() => setPanelVisible(false)}
|
onClick={() => void closePanel()}
|
||||||
>
|
>
|
||||||
<X className='h-4 w-4' />
|
<X className='h-4 w-4' />
|
||||||
</button>
|
</button>
|
||||||
@@ -901,265 +936,316 @@ const ReviewPanel: React.FC = () => {
|
|||||||
|
|
||||||
<div className='min-h-0 flex-1 overflow-auto p-3'>
|
<div className='min-h-0 flex-1 overflow-auto p-3'>
|
||||||
<div className='grid gap-3'>
|
<div className='grid gap-3'>
|
||||||
|
<div className='join grid grid-cols-2' role='tablist' aria-label='审校器功能'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
role='tab'
|
||||||
|
aria-selected={panelMode === 'review'}
|
||||||
|
className={`btn join-item h-9 min-h-9 rounded-md ${panelMode === 'review' ? 'btn-primary' : 'btn-ghost eink-bordered'}`}
|
||||||
|
onClick={() => setPanelMode('review')}
|
||||||
|
>
|
||||||
|
逐段审校
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
role='tab'
|
||||||
|
aria-selected={panelMode === 'translate'}
|
||||||
|
className={`btn join-item h-9 min-h-9 rounded-md ${panelMode === 'translate' ? 'btn-primary' : 'btn-ghost eink-bordered'}`}
|
||||||
|
disabled={hasUnsavedEdit}
|
||||||
|
title={hasUnsavedEdit ? '请先保存当前段落' : '切换到整书翻译'}
|
||||||
|
onClick={() => setPanelMode('translate')}
|
||||||
|
>
|
||||||
|
整书翻译
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{bookState.loading ? (
|
{bookState.loading ? (
|
||||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
|
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
|
||||||
正在启动审校器并读取当前 EPUB……
|
正在启动审校器并读取当前 EPUB……
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{bookState.error ? (
|
{bookState.error ? (
|
||||||
<div className='eink-bordered border-error bg-base-100 text-error rounded-md border p-3 text-sm'>
|
<div
|
||||||
|
className='eink-bordered border-error bg-base-100 text-error rounded-md border p-3 text-sm'
|
||||||
|
role='alert'
|
||||||
|
>
|
||||||
{bookState.error}
|
{bookState.error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className='flex flex-wrap gap-2'>
|
{panelMode === 'translate' && baseUrl && sessionId ? (
|
||||||
<PanelButton
|
<div role='tabpanel' aria-label='整书翻译'>
|
||||||
onClick={generateFeedback}
|
<FullBookTranslationPanel
|
||||||
icon={<FileText className='h-4 w-4' />}
|
baseUrl={baseUrl}
|
||||||
disabled={!baseUrl}
|
sessionId={sessionId}
|
||||||
>
|
accessToken={bookState.accessToken}
|
||||||
生成反馈
|
/>
|
||||||
</PanelButton>
|
|
||||||
<PanelButton
|
|
||||||
onClick={exportEpub}
|
|
||||||
icon={<Download className='h-4 w-4' />}
|
|
||||||
disabled={!baseUrl}
|
|
||||||
variant='primary'
|
|
||||||
>
|
|
||||||
导出 EPUB
|
|
||||||
</PanelButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message ? (
|
|
||||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
|
|
||||||
{message}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{exportInfo ? (
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className='eink-bordered border-base-300 bg-base-100 hover:bg-base-200 rounded-md border p-3 text-start text-sm'
|
|
||||||
onClick={() => openExternalUrl(exportInfo)}
|
|
||||||
>
|
|
||||||
<span className='font-semibold'>打开导出文件</span>
|
|
||||||
<span className='text-base-content/60 mt-1 block break-all text-xs'>
|
|
||||||
{exportInfo}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!selectedRow ? (
|
{panelMode === 'review' ? (
|
||||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-5 text-center text-sm'>
|
<div className='contents' role='tabpanel' aria-label='逐段审校'>
|
||||||
{rows.length
|
<div className='flex flex-wrap gap-2'>
|
||||||
? '请选择一段开始审校。'
|
|
||||||
: '当前 EPUB 没有识别到可审校的中日双语段落。'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
|
||||||
<div className='text-base-content/60 mb-2 flex flex-wrap items-center justify-between gap-2 text-xs'>
|
|
||||||
<span className='min-w-0 break-words'>
|
|
||||||
{selectedRow.id} · {rowTitle(selectedRow)}
|
|
||||||
</span>
|
|
||||||
<span className='shrink-0 break-words'>
|
|
||||||
JP P{selectedRow.ja_p_index} / CN P{selectedRow.cn_p_index}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className='prose prose-sm max-w-none select-text leading-8 break-words'
|
|
||||||
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(selectedRow.jp_html) }}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className='grid gap-3'>
|
|
||||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
|
||||||
<h3 className='text-sm font-semibold'>修改中文译文</h3>
|
|
||||||
<div className='flex flex-wrap justify-end gap-2'>
|
|
||||||
<PanelButton
|
|
||||||
onClick={insertRuby}
|
|
||||||
disabled={!canUseRowActions}
|
|
||||||
icon={<Languages className='h-4 w-4' />}
|
|
||||||
>
|
|
||||||
注音
|
|
||||||
</PanelButton>
|
|
||||||
<PanelButton
|
|
||||||
onClick={insertAnnotation}
|
|
||||||
disabled={!canUseRowActions}
|
|
||||||
icon={<MessageSquarePlus className='h-4 w-4' />}
|
|
||||||
>
|
|
||||||
注释
|
|
||||||
</PanelButton>
|
|
||||||
<PanelButton
|
|
||||||
onClick={saveCurrentRow}
|
|
||||||
disabled={!canUseRowActions || saving}
|
|
||||||
icon={<Save className='h-4 w-4' />}
|
|
||||||
variant='primary'
|
|
||||||
>
|
|
||||||
{saving ? '保存中' : '保存'}
|
|
||||||
</PanelButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
ref={cnEditorRef}
|
|
||||||
className='textarea textarea-bordered eink-bordered min-h-44 min-w-0 rounded-md font-mono text-sm'
|
|
||||||
value={edit.current_html}
|
|
||||||
onChange={(event) =>
|
|
||||||
setEdit((current) => ({ ...current, current_html: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className='flex w-full items-center justify-between gap-2 text-start'
|
|
||||||
onClick={() => setPreviewOpen((value) => !value)}
|
|
||||||
>
|
|
||||||
<span className='text-base-content/60 text-xs'>译文预览</span>
|
|
||||||
<span className='text-base-content/60 text-xs'>
|
|
||||||
{previewOpen ? '收起' : '展开'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{previewOpen ? (
|
|
||||||
<div
|
|
||||||
className='prose prose-sm mt-2 max-w-none select-text leading-7 break-words'
|
|
||||||
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(edit.current_html) }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
|
|
||||||
<div className='flex items-start justify-between gap-3'>
|
|
||||||
<div className='min-w-0'>
|
|
||||||
<h4 className='text-sm font-semibold'>问题记录</h4>
|
|
||||||
<p className='text-base-content/60 mt-1 text-xs'>
|
|
||||||
标签用于归类,备注只写本段,长期规则建议只写可复用口径。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<label className='flex shrink-0 items-center gap-2 text-xs'>
|
|
||||||
<input
|
|
||||||
type='checkbox'
|
|
||||||
className='checkbox checkbox-sm'
|
|
||||||
checked={edit.marked}
|
|
||||||
onChange={(event) =>
|
|
||||||
setEdit((current) => ({ ...current, marked: event.target.checked }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
纳入反馈
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className='grid gap-2 sm:grid-cols-2'>
|
|
||||||
<label className='grid gap-1 text-xs font-medium'>
|
|
||||||
问题类型
|
|
||||||
<select
|
|
||||||
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
|
|
||||||
value={edit.issue_type}
|
|
||||||
onChange={(event) =>
|
|
||||||
setEdit((current) => ({
|
|
||||||
...current,
|
|
||||||
issue_type: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value=''>未选择</option>
|
|
||||||
<option value='误译'>误译</option>
|
|
||||||
<option value='漏译'>漏译</option>
|
|
||||||
<option value='术语不一致'>术语不一致</option>
|
|
||||||
<option value='人名/地名问题'>人名/地名问题</option>
|
|
||||||
<option value='翻译腔'>翻译腔</option>
|
|
||||||
<option value='角色口吻不对'>角色口吻不对</option>
|
|
||||||
<option value='语序不顺'>语序不顺</option>
|
|
||||||
<option value='标点/格式'>标点/格式</option>
|
|
||||||
<option value='其他'>其他</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className='grid gap-1 text-xs font-medium'>
|
|
||||||
严重程度
|
|
||||||
<select
|
|
||||||
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
|
|
||||||
value={edit.severity}
|
|
||||||
onChange={(event) =>
|
|
||||||
setEdit((current) => ({ ...current, severity: event.target.value }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value=''>未选择</option>
|
|
||||||
<option value='轻微'>轻微</option>
|
|
||||||
<option value='中等'>中等</option>
|
|
||||||
<option value='严重'>严重</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className='grid gap-1 text-xs font-medium'>
|
|
||||||
标签
|
|
||||||
<input
|
|
||||||
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
|
|
||||||
value={edit.tags}
|
|
||||||
onChange={(event) =>
|
|
||||||
setEdit((current) => ({ ...current, tags: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder='检索归类:术语、口吻、格式,可用逗号分隔'
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className='grid gap-1 text-xs font-medium'>
|
|
||||||
本段备注
|
|
||||||
<textarea
|
|
||||||
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
|
||||||
value={edit.comment}
|
|
||||||
onChange={(event) =>
|
|
||||||
setEdit((current) => ({ ...current, comment: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder='只记录当前段哪里有问题、为什么这样改'
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className='grid gap-1 text-xs font-medium'>
|
|
||||||
长期规则建议
|
|
||||||
<textarea
|
|
||||||
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
|
||||||
value={edit.learn_note}
|
|
||||||
onChange={(event) =>
|
|
||||||
setEdit((current) => ({ ...current, learn_note: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder='只写后续段落也应遵守的规则或口吻偏好'
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className='border-base-300 grid gap-3 border-t pt-3'>
|
|
||||||
<h3 className='text-sm font-semibold'>API 重翻</h3>
|
|
||||||
<textarea
|
|
||||||
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
|
||||||
value={retranslateInstruction}
|
|
||||||
onChange={(event) => setRetranslateInstruction(event.target.value)}
|
|
||||||
placeholder='额外要求,可留空'
|
|
||||||
/>
|
|
||||||
<PanelButton
|
<PanelButton
|
||||||
onClick={retranslateCurrentRow}
|
onClick={generateFeedback}
|
||||||
disabled={!canUseRowActions || retranslating}
|
icon={<FileText className='h-4 w-4' />}
|
||||||
icon={<Sparkles className='h-4 w-4' />}
|
disabled={!baseUrl || hasUnsavedEdit}
|
||||||
>
|
>
|
||||||
{retranslating ? '重翻中' : '重翻本段'}
|
生成反馈
|
||||||
</PanelButton>
|
</PanelButton>
|
||||||
{candidateHtml ? (
|
<PanelButton
|
||||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
onClick={exportEpub}
|
||||||
<div
|
icon={<Download className='h-4 w-4' />}
|
||||||
className='prose prose-sm max-w-none select-text leading-7 break-words'
|
disabled={!baseUrl || exporting || hasUnsavedEdit}
|
||||||
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(candidateHtml) }}
|
variant='primary'
|
||||||
/>
|
>
|
||||||
<div className='mt-3'>
|
{exporting ? '导出中' : '导出 EPUB'}
|
||||||
<PanelButton
|
</PanelButton>
|
||||||
onClick={() =>
|
</div>
|
||||||
setEdit((current) => ({ ...current, current_html: candidateHtml }))
|
{hasUnsavedEdit ? (
|
||||||
}
|
<p className='text-base-content/70 text-xs'>
|
||||||
icon={<Check className='h-4 w-4' />}
|
请先保存当前段落,再生成反馈或导出。
|
||||||
variant='primary'
|
</p>
|
||||||
>
|
) : null}
|
||||||
应用候选到编辑框
|
|
||||||
</PanelButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
{message ? (
|
||||||
|
<div
|
||||||
|
className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'
|
||||||
|
role='status'
|
||||||
|
aria-live='polite'
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{exportInfo ? (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='eink-bordered border-base-300 bg-base-100 hover:bg-base-200 rounded-md border p-3 text-start text-sm'
|
||||||
|
onClick={() => void downloadReviewedEpub(exportInfo, bookState.accessToken)}
|
||||||
|
>
|
||||||
|
<span className='font-semibold'>重新下载导出文件</span>
|
||||||
|
<span className='text-base-content/60 mt-1 block break-all text-xs'>
|
||||||
|
{exportInfo}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!selectedRow ? (
|
||||||
|
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-5 text-center text-sm'>
|
||||||
|
{rows.length
|
||||||
|
? '请选择一段开始审校。'
|
||||||
|
: '当前 EPUB 没有识别到可审校的中日双语段落。'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
||||||
|
<div className='text-base-content/60 mb-2 flex flex-wrap items-center justify-between gap-2 text-xs'>
|
||||||
|
<span className='min-w-0 break-words'>
|
||||||
|
{selectedRow.id} · {rowTitle(selectedRow)}
|
||||||
|
</span>
|
||||||
|
<span className='shrink-0 break-words'>
|
||||||
|
JP P{selectedRow.ja_p_index} / CN P{selectedRow.cn_p_index}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className='prose prose-sm max-w-none select-text leading-8 break-words'
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: sanitizeInlineHtml(selectedRow.jp_html),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className='grid gap-3'>
|
||||||
|
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||||
|
<h3 className='text-sm font-semibold'>修改中文译文</h3>
|
||||||
|
<div className='flex flex-wrap justify-end gap-2'>
|
||||||
|
<PanelButton
|
||||||
|
onClick={insertRuby}
|
||||||
|
disabled={!canUseRowActions}
|
||||||
|
icon={<Languages className='h-4 w-4' />}
|
||||||
|
>
|
||||||
|
注音
|
||||||
|
</PanelButton>
|
||||||
|
<PanelButton
|
||||||
|
onClick={insertAnnotation}
|
||||||
|
disabled={!canUseRowActions}
|
||||||
|
icon={<MessageSquarePlus className='h-4 w-4' />}
|
||||||
|
>
|
||||||
|
注释
|
||||||
|
</PanelButton>
|
||||||
|
<PanelButton
|
||||||
|
onClick={saveCurrentRow}
|
||||||
|
disabled={!canUseRowActions || saving}
|
||||||
|
icon={<Save className='h-4 w-4' />}
|
||||||
|
variant='primary'
|
||||||
|
>
|
||||||
|
{saving ? '保存中' : '保存'}
|
||||||
|
</PanelButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={cnEditorRef}
|
||||||
|
className='textarea textarea-bordered eink-bordered min-h-44 min-w-0 rounded-md font-mono text-sm'
|
||||||
|
value={edit.current_html}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEdit((current) => ({ ...current, current_html: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='flex w-full items-center justify-between gap-2 text-start'
|
||||||
|
onClick={() => setPreviewOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
<span className='text-base-content/60 text-xs'>译文预览</span>
|
||||||
|
<span className='text-base-content/60 text-xs'>
|
||||||
|
{previewOpen ? '收起' : '展开'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{previewOpen ? (
|
||||||
|
<div
|
||||||
|
className='prose prose-sm mt-2 max-w-none select-text leading-7 break-words'
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: sanitizeInlineHtml(edit.current_html),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<h4 className='text-sm font-semibold'>问题记录</h4>
|
||||||
|
<p className='text-base-content/60 mt-1 text-xs'>
|
||||||
|
标签用于归类,备注只写本段,长期规则建议只写可复用口径。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className='flex shrink-0 items-center gap-2 text-xs'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
className='checkbox checkbox-sm'
|
||||||
|
checked={edit.marked}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEdit((current) => ({ ...current, marked: event.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
纳入反馈
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2 sm:grid-cols-2'>
|
||||||
|
<label className='grid gap-1 text-xs font-medium'>
|
||||||
|
问题类型
|
||||||
|
<select
|
||||||
|
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
|
||||||
|
value={edit.issue_type}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEdit((current) => ({
|
||||||
|
...current,
|
||||||
|
issue_type: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>未选择</option>
|
||||||
|
<option value='误译'>误译</option>
|
||||||
|
<option value='漏译'>漏译</option>
|
||||||
|
<option value='术语不一致'>术语不一致</option>
|
||||||
|
<option value='人名/地名问题'>人名/地名问题</option>
|
||||||
|
<option value='翻译腔'>翻译腔</option>
|
||||||
|
<option value='角色口吻不对'>角色口吻不对</option>
|
||||||
|
<option value='语序不顺'>语序不顺</option>
|
||||||
|
<option value='标点/格式'>标点/格式</option>
|
||||||
|
<option value='其他'>其他</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className='grid gap-1 text-xs font-medium'>
|
||||||
|
严重程度
|
||||||
|
<select
|
||||||
|
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
|
||||||
|
value={edit.severity}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEdit((current) => ({ ...current, severity: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>未选择</option>
|
||||||
|
<option value='轻微'>轻微</option>
|
||||||
|
<option value='中等'>中等</option>
|
||||||
|
<option value='严重'>严重</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className='grid gap-1 text-xs font-medium'>
|
||||||
|
标签
|
||||||
|
<input
|
||||||
|
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
|
||||||
|
value={edit.tags}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEdit((current) => ({ ...current, tags: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder='检索归类:术语、口吻、格式,可用逗号分隔'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className='grid gap-1 text-xs font-medium'>
|
||||||
|
本段备注
|
||||||
|
<textarea
|
||||||
|
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
||||||
|
value={edit.comment}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEdit((current) => ({ ...current, comment: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder='只记录当前段哪里有问题、为什么这样改'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className='grid gap-1 text-xs font-medium'>
|
||||||
|
长期规则建议
|
||||||
|
<textarea
|
||||||
|
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
||||||
|
value={edit.learn_note}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEdit((current) => ({ ...current, learn_note: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder='只写后续段落也应遵守的规则或口吻偏好'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className='border-base-300 grid gap-3 border-t pt-3'>
|
||||||
|
<h3 className='text-sm font-semibold'>API 重翻</h3>
|
||||||
|
<textarea
|
||||||
|
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
|
||||||
|
value={retranslateInstruction}
|
||||||
|
onChange={(event) => setRetranslateInstruction(event.target.value)}
|
||||||
|
placeholder='额外要求,可留空'
|
||||||
|
/>
|
||||||
|
<PanelButton
|
||||||
|
onClick={retranslateCurrentRow}
|
||||||
|
disabled={!canUseRowActions || retranslating}
|
||||||
|
icon={<Sparkles className='h-4 w-4' />}
|
||||||
|
>
|
||||||
|
{retranslating ? '重翻中' : '重翻本段'}
|
||||||
|
</PanelButton>
|
||||||
|
{candidateHtml ? (
|
||||||
|
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
|
||||||
|
<div
|
||||||
|
className='prose prose-sm max-w-none select-text leading-7 break-words'
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(candidateHtml) }}
|
||||||
|
/>
|
||||||
|
<div className='mt-3'>
|
||||||
|
<PanelButton
|
||||||
|
onClick={() =>
|
||||||
|
setEdit((current) => ({ ...current, current_html: candidateHtml }))
|
||||||
|
}
|
||||||
|
icon={<Check className='h-4 w-4' />}
|
||||||
|
variant='primary'
|
||||||
|
>
|
||||||
|
应用候选到编辑框
|
||||||
|
</PanelButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<GptConfigPanel
|
<GptConfigPanel
|
||||||
baseUrl={baseUrl}
|
baseUrl={baseUrl}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
|
|
||||||
import type { Book } from '@/types/book';
|
import type { Book } from '@/types/book';
|
||||||
import type { AppService } from '@/types/system';
|
import type { AppService } from '@/types/system';
|
||||||
import { isTauriAppPlatform } from '@/services/environment';
|
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||||
|
|
||||||
export type ReviewEditorLaunchResponse = {
|
export type ReviewEditorLaunchResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -10,6 +10,7 @@ export type ReviewEditorLaunchResponse = {
|
|||||||
reused?: boolean;
|
reused?: boolean;
|
||||||
reviewRoot?: string;
|
reviewRoot?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
accessToken?: string;
|
||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
@@ -97,6 +98,15 @@ export type ReviewEditPayload = {
|
|||||||
learn_note: 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 = {
|
export type ReviewSaveRowPayload = {
|
||||||
status?: string;
|
status?: string;
|
||||||
row_id?: string;
|
row_id?: string;
|
||||||
@@ -110,6 +120,62 @@ export type ReviewGlossaryEntry = {
|
|||||||
clean_target?: string;
|
clean_target?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TranslationChunkPlan = {
|
||||||
|
id: string;
|
||||||
|
file: string;
|
||||||
|
title?: string;
|
||||||
|
first_item_id: string;
|
||||||
|
last_item_id: string;
|
||||||
|
item_count: number;
|
||||||
|
source_characters: number;
|
||||||
|
estimated_input_tokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranslationPlanPayload = {
|
||||||
|
session_id: string;
|
||||||
|
source_count: number;
|
||||||
|
chunk_target: number;
|
||||||
|
chunk_count: number;
|
||||||
|
estimated_input_tokens: number;
|
||||||
|
chunks: TranslationChunkPlan[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranslationJobSummary = {
|
||||||
|
id: string;
|
||||||
|
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
source_session_id: string;
|
||||||
|
source_name?: string;
|
||||||
|
progress?: {
|
||||||
|
current?: number;
|
||||||
|
total?: number;
|
||||||
|
percent?: number;
|
||||||
|
failed?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
output_epub?: string;
|
||||||
|
output_download_url?: string;
|
||||||
|
output_session_id?: string;
|
||||||
|
error?: string;
|
||||||
|
logs?: { ts?: string; level?: string; message?: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranslationDefaultsPayload = {
|
||||||
|
gpt?: ReviewGptConfig;
|
||||||
|
defaults?: {
|
||||||
|
output_mode?: 'bilingual' | 'translated';
|
||||||
|
range_mode?: 'all' | 'limit';
|
||||||
|
limit?: number;
|
||||||
|
temperature?: number;
|
||||||
|
chunk_target?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranslationSourcePayload = {
|
||||||
|
source_count: number;
|
||||||
|
files?: { file: string; count: number }[];
|
||||||
|
stats?: { note?: string };
|
||||||
|
};
|
||||||
|
|
||||||
export type ReviewGlossaryPayload = {
|
export type ReviewGlossaryPayload = {
|
||||||
path?: string;
|
path?: string;
|
||||||
exists?: boolean;
|
exists?: boolean;
|
||||||
@@ -134,23 +200,35 @@ const ensureBaseUrl = (baseUrl: string) => {
|
|||||||
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
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>(
|
export async function sidecarApi<T>(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {},
|
||||||
sessionId?: string | null,
|
sessionId?: string | null,
|
||||||
|
accessToken?: string,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const url = new URL(path, ensureBaseUrl(baseUrl));
|
const url = new URL(path, ensureBaseUrl(baseUrl));
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
url.searchParams.set('session_id', sessionId);
|
url.searchParams.set('session_id', sessionId);
|
||||||
}
|
}
|
||||||
const headers =
|
const resolvedAccessToken = accessToken || sidecarAccessTokens.get(ensureBaseUrl(baseUrl));
|
||||||
options.body === undefined
|
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
|
||||||
? options.headers
|
const headers = {
|
||||||
: {
|
...(options.body === undefined || isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
'Content-Type': 'application/json',
|
...(resolvedAccessToken ? { Authorization: `Bearer ${resolvedAccessToken}` } : {}),
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
};
|
};
|
||||||
const response = await fetch(url.toString(), {
|
const response = await fetch(url.toString(), {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
@@ -166,31 +244,62 @@ export async function createReviewSessionFromPath(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
epubPath: string,
|
epubPath: string,
|
||||||
options: ReviewSessionFromPathOptions = {},
|
options: ReviewSessionFromPathOptions = {},
|
||||||
|
accessToken?: string,
|
||||||
): Promise<ReviewSessionSummary> {
|
): Promise<ReviewSessionSummary> {
|
||||||
return sidecarApi(baseUrl, '/api/session/from-path', {
|
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',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: formData,
|
||||||
epub_path: epubPath,
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
activate: options.activate ?? false,
|
|
||||||
reset: options.reset ?? false,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
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(
|
export async function launchInlineReviewEditor(
|
||||||
appService: AppService | null | undefined,
|
appService: AppService | null | undefined,
|
||||||
book: Book,
|
book: Book,
|
||||||
): Promise<ReviewEditorLaunchResponse & { url: string; sessionId: string | null }> {
|
tauriPlatform = isTauriAppPlatform(),
|
||||||
|
): Promise<
|
||||||
|
ReviewEditorLaunchResponse & { url: string; sessionId: string | null; accessToken: string }
|
||||||
|
> {
|
||||||
if (!appService) throw new Error('Readest 服务尚未初始化');
|
if (!appService) throw new Error('Readest 服务尚未初始化');
|
||||||
if (book.format !== 'EPUB') throw new Error('审校模式目前只支持 EPUB 书籍');
|
if (book.format !== 'EPUB') throw new Error('审校模式目前只支持 EPUB 书籍');
|
||||||
|
|
||||||
const epubPath = await appService.resolveNativeBookFilePath(book);
|
const epubPath = tauriPlatform ? await appService.resolveNativeBookFilePath(book) : null;
|
||||||
if (!epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径');
|
if (tauriPlatform && !epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径');
|
||||||
|
|
||||||
const launch = isTauriAppPlatform()
|
const launch = tauriPlatform
|
||||||
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', {
|
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', { epubPath })
|
||||||
epubPath,
|
|
||||||
})
|
|
||||||
: await fetch('/api/review-editor/launch', {
|
: await fetch('/api/review-editor/launch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -201,25 +310,30 @@ export async function launchInlineReviewEditor(
|
|||||||
if (!launch.ok || !launch.url) {
|
if (!launch.ok || !launch.url) {
|
||||||
throw new Error(launch.error || '审校器启动失败');
|
throw new Error(launch.error || '审校器启动失败');
|
||||||
}
|
}
|
||||||
|
if (!launch.accessToken) throw new Error('审校器启动响应缺少访问令牌');
|
||||||
|
rememberSidecarAccessToken(launch.url, launch.accessToken);
|
||||||
|
|
||||||
let sessionId = launch.sessionId || null;
|
let sessionId = launch.sessionId || null;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
const createdSession = await createReviewSessionFromPath(launch.url, epubPath);
|
const createdSession = epubPath
|
||||||
|
? await createReviewSessionFromPath(launch.url, epubPath, {}, launch.accessToken)
|
||||||
|
: await createReviewSessionFromBook(launch.url, appService, book, launch.accessToken);
|
||||||
sessionId = createdSession.id || null;
|
sessionId = createdSession.id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...launch, url: launch.url, sessionId };
|
return { ...launch, url: launch.url, sessionId, accessToken: launch.accessToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadInlineReviewData(
|
export async function loadInlineReviewData(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
sessionId: string | null | undefined,
|
sessionId: string | null | undefined,
|
||||||
|
accessToken?: string,
|
||||||
): Promise<ReviewBootstrapPayload> {
|
): Promise<ReviewBootstrapPayload> {
|
||||||
if (!sessionId) throw new Error('审校器没有返回当前书籍会话');
|
if (!sessionId) throw new Error('审校器没有返回当前书籍会话');
|
||||||
const [session, rowsPayload, gptConfig] = await Promise.all([
|
const [session, rowsPayload, gptConfig] = await Promise.all([
|
||||||
sidecarApi<ReviewSessionPayload>(baseUrl, '/api/session', {}, sessionId),
|
sidecarApi<ReviewSessionPayload>(baseUrl, '/api/session', {}, sessionId, accessToken),
|
||||||
sidecarApi<ReviewRowsPayload>(baseUrl, '/api/rows', {}, sessionId),
|
sidecarApi<ReviewRowsPayload>(baseUrl, '/api/rows', {}, sessionId, accessToken),
|
||||||
sidecarApi<ReviewGptConfig>(baseUrl, '/api/gpt/config', {}, sessionId),
|
sidecarApi<ReviewGptConfig>(baseUrl, '/api/gpt/config', {}, sessionId, accessToken),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
@@ -354,3 +468,131 @@ export async function exportReviewedEpub(
|
|||||||
sessionId,
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTranslationSource(
|
||||||
|
baseUrl: string,
|
||||||
|
sessionId: string,
|
||||||
|
accessToken?: string,
|
||||||
|
): Promise<TranslationSourcePayload> {
|
||||||
|
return sidecarApi(
|
||||||
|
baseUrl,
|
||||||
|
`/api/session/${encodeURIComponent(sessionId)}/translation-source`,
|
||||||
|
{},
|
||||||
|
sessionId,
|
||||||
|
accessToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTranslationDefaults(
|
||||||
|
baseUrl: string,
|
||||||
|
sessionId: string,
|
||||||
|
accessToken?: string,
|
||||||
|
): Promise<TranslationDefaultsPayload> {
|
||||||
|
return sidecarApi(baseUrl, '/api/translation/defaults', {}, sessionId, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTranslationPlan(
|
||||||
|
baseUrl: string,
|
||||||
|
sessionId: string,
|
||||||
|
chunkTarget: number,
|
||||||
|
accessToken?: string,
|
||||||
|
): Promise<TranslationPlanPayload> {
|
||||||
|
return sidecarApi(
|
||||||
|
baseUrl,
|
||||||
|
`/api/session/${encodeURIComponent(sessionId)}/translation-plan`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ chunk_target: chunkTarget }) },
|
||||||
|
sessionId,
|
||||||
|
accessToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startFullBookTranslation(
|
||||||
|
baseUrl: string,
|
||||||
|
sessionId: string,
|
||||||
|
settings: {
|
||||||
|
output_mode: 'bilingual' | 'translated';
|
||||||
|
range_mode: 'all' | 'limit';
|
||||||
|
limit: number;
|
||||||
|
temperature: number;
|
||||||
|
chunk_target: number;
|
||||||
|
},
|
||||||
|
accessToken?: string,
|
||||||
|
): Promise<{ job: TranslationJobSummary }> {
|
||||||
|
return sidecarApi(
|
||||||
|
baseUrl,
|
||||||
|
'/api/translation/start',
|
||||||
|
{ method: 'POST', body: JSON.stringify({ session_id: sessionId, ...settings }) },
|
||||||
|
sessionId,
|
||||||
|
accessToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTranslationJobs(
|
||||||
|
baseUrl: string,
|
||||||
|
sessionId: string,
|
||||||
|
accessToken?: string,
|
||||||
|
): Promise<{ jobs: TranslationJobSummary[]; count: number }> {
|
||||||
|
return sidecarApi(baseUrl, '/api/translation/jobs', {}, sessionId, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTranslationJob(
|
||||||
|
baseUrl: string,
|
||||||
|
sessionId: string,
|
||||||
|
jobId: string,
|
||||||
|
accessToken?: string,
|
||||||
|
): Promise<{ job: TranslationJobSummary }> {
|
||||||
|
return sidecarApi(
|
||||||
|
baseUrl,
|
||||||
|
`/api/translation/jobs/${encodeURIComponent(jobId)}`,
|
||||||
|
{},
|
||||||
|
sessionId,
|
||||||
|
accessToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function controlTranslationJob(
|
||||||
|
baseUrl: string,
|
||||||
|
sessionId: string,
|
||||||
|
jobId: string,
|
||||||
|
action: 'pause' | 'resume' | 'cancel',
|
||||||
|
accessToken?: string,
|
||||||
|
): Promise<{ job: TranslationJobSummary }> {
|
||||||
|
return sidecarApi(
|
||||||
|
baseUrl,
|
||||||
|
`/api/translation/jobs/${encodeURIComponent(jobId)}/${action}`,
|
||||||
|
{ method: 'POST', body: '{}' },
|
||||||
|
sessionId,
|
||||||
|
accessToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeTranslationModels(
|
||||||
|
baseUrl: string,
|
||||||
|
sessionId: string,
|
||||||
|
accessToken?: string,
|
||||||
|
): Promise<{
|
||||||
|
models: string[];
|
||||||
|
configured_model: string;
|
||||||
|
configured_model_available: boolean;
|
||||||
|
}> {
|
||||||
|
return sidecarApi(baseUrl, '/api/gpt/models', {}, sessionId, accessToken);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type ReviewBookState = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
accessToken: string;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
reviewRoot?: string;
|
reviewRoot?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
@@ -19,6 +20,7 @@ export type ReviewBookState = {
|
|||||||
session: ReviewSessionPayload | null;
|
session: ReviewSessionPayload | null;
|
||||||
gptConfig: ReviewGptConfig | null;
|
gptConfig: ReviewGptConfig | null;
|
||||||
selectedRowId: string;
|
selectedRowId: string;
|
||||||
|
hasUnsavedEdit: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PANEL_WIDTH = '32%';
|
const DEFAULT_PANEL_WIDTH = '32%';
|
||||||
@@ -49,6 +51,7 @@ type ReviewModeStore = {
|
|||||||
data: Partial<Omit<ReviewBookState, 'enabled' | 'loading' | 'error'>>,
|
data: Partial<Omit<ReviewBookState, 'enabled' | 'loading' | 'error'>>,
|
||||||
) => void;
|
) => void;
|
||||||
selectRow: (bookKey: string, rowId: string) => void;
|
selectRow: (bookKey: string, rowId: string) => void;
|
||||||
|
setBookUnsavedEdit: (bookKey: string, hasUnsavedEdit: boolean) => void;
|
||||||
updateRow: (bookKey: string, row: ReviewRow) => void;
|
updateRow: (bookKey: string, row: ReviewRow) => void;
|
||||||
clearBook: (bookKey: string) => void;
|
clearBook: (bookKey: string) => void;
|
||||||
};
|
};
|
||||||
@@ -58,11 +61,13 @@ const emptyBookState = (): ReviewBookState => ({
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: '',
|
error: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
|
accessToken: '',
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
rows: [],
|
rows: [],
|
||||||
session: null,
|
session: null,
|
||||||
gptConfig: null,
|
gptConfig: null,
|
||||||
selectedRowId: '',
|
selectedRowId: '',
|
||||||
|
hasUnsavedEdit: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatSizedValue = (value: number, unit: '%' | 'vh') =>
|
const formatSizedValue = (value: number, unit: '%' | 'vh') =>
|
||||||
@@ -153,7 +158,10 @@ export const useReviewModeStore = create<ReviewModeStore>()(
|
|||||||
: state.activeBookKey === bookKey
|
: state.activeBookKey === bookKey
|
||||||
? false
|
? false
|
||||||
: state.isPanelVisible,
|
: state.isPanelVisible,
|
||||||
books: withBookState(state.books, bookKey, { enabled }),
|
books: withBookState(state.books, bookKey, {
|
||||||
|
enabled,
|
||||||
|
...(!enabled ? { hasUnsavedEdit: false } : {}),
|
||||||
|
}),
|
||||||
})),
|
})),
|
||||||
setBookData: (bookKey, data) =>
|
setBookData: (bookKey, data) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -164,12 +172,21 @@ export const useReviewModeStore = create<ReviewModeStore>()(
|
|||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
selectRow: (bookKey, rowId) =>
|
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) => ({
|
set((state) => ({
|
||||||
activeBookKey: bookKey,
|
books: withBookState(state.books, bookKey, { hasUnsavedEdit }),
|
||||||
isPanelVisible: true,
|
|
||||||
books: withBookState(state.books, bookKey, {
|
|
||||||
selectedRowId: rowId,
|
|
||||||
}),
|
|
||||||
})),
|
})),
|
||||||
updateRow: (bookKey, row) =>
|
updateRow: (bookKey, row) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
- `tools/epub-review-editor/static/` 独立网页
|
- `tools/epub-review-editor/static/` 独立网页
|
||||||
- `open_editor.ps1` 或 `epub-reviewer:dev` 独立启动链路
|
- `open_editor.ps1` 或 `epub-reviewer:dev` 独立启动链路
|
||||||
|
|
||||||
整书翻译、独立审校书架、独立目录/检索页面等功能不属于内嵌审校模式,不应迁入阅读页。
|
整书翻译是阅读页内嵌审校面板的第二个页签,可复用当前书籍 session、GPT 配置和术语表。独立审校书架、独立目录/检索页面、旧 static 网页和 `/review-editor` 仍不属于内嵌模式,不得恢复。
|
||||||
|
|
||||||
## 必须保留的链路
|
## 必须保留的链路
|
||||||
|
|
||||||
@@ -37,6 +37,8 @@ Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧
|
|||||||
|
|
||||||
所有审校 API 调用必须携带当前书的 `session_id`,不能依赖 sidecar 的全局 active session。多窗口或多书同时审校时,不得发生串读、串写。
|
所有审校 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:
|
典型 session:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -68,6 +70,10 @@ Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧
|
|||||||
|
|
||||||
- `GET /api/version`
|
- `GET /api/version`
|
||||||
- `POST /api/session/from-path`
|
- `POST /api/session/from-path`
|
||||||
|
- `POST /api/upload`(本地 `dev-web` 使用 multipart 上传,内嵌调用必须传 `activate=false` 与稳定 `book_key`)
|
||||||
|
- `/api/session/<session_id>/translation-source` 与 `/translation-plan`
|
||||||
|
- `/api/translation/defaults`、`/start`、`/jobs` 及任务控制端点
|
||||||
|
- `/api/gpt/models` 与 `/downloads/translation/...`
|
||||||
- `GET /api/session?session_id=...`
|
- `GET /api/session?session_id=...`
|
||||||
- `GET /api/rows?session_id=...`
|
- `GET /api/rows?session_id=...`
|
||||||
- `POST /api/row/<row_id>?session_id=...`
|
- `POST /api/row/<row_id>?session_id=...`
|
||||||
@@ -80,7 +86,9 @@ Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧
|
|||||||
|
|
||||||
中文 HTML 清洗必须允许必要的 `ruby/rb/rt/rp/mark/span` 内联标签并移除脚本。保存响应应返回清洗后的 `current_html`,前端只能用清洗结果更新 store 和正文预览。
|
中文 HTML 清洗必须允许必要的 `ruby/rb/rt/rp/mark/span` 内联标签并移除脚本。保存响应应返回清洗后的 `current_html`,前端只能用清洗结果更新 store 和正文预览。
|
||||||
|
|
||||||
sidecar 只能监听本机 loopback。Readest/Tauri 本机来源可获得受限 CORS;不得使用 `Access-Control-Allow-Origin: *`。Cross-Origin Resource Policy 必须允许 Readest 页面直接读取 API 与下载资源。
|
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/文件系统链路,当前不在支持范围内。
|
||||||
|
|
||||||
## 前端验收标准
|
## 前端验收标准
|
||||||
|
|
||||||
@@ -105,7 +113,7 @@ sidecar 只能监听本机 loopback。Readest/Tauri 本机来源可获得受限
|
|||||||
- MINOR:向后兼容的能力或 API 扩展
|
- MINOR:向后兼容的能力或 API 扩展
|
||||||
- MAJOR:删除入口、破坏 API 或改变持久化行为
|
- MAJOR:删除入口、破坏 API 或改变持久化行为
|
||||||
|
|
||||||
发版时同步 `version.py`、`README.md` 和本文件。`1.0.0` 删除了旧独立 UI 和启动方式,是破坏兼容的边界收敛。
|
发版时同步 `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。`2.1.0` 在当前安全契约上新增内嵌整书翻译、计划、任务控制、断点续跑和安全输出下载,翻译状态为 `untranslated`、`translating`、`translated` 或 `failed`。
|
||||||
|
|
||||||
## 回归清单
|
## 回归清单
|
||||||
|
|
||||||
@@ -114,8 +122,9 @@ sidecar 只能监听本机 loopback。Readest/Tauri 本机来源可获得受限
|
|||||||
```powershell
|
```powershell
|
||||||
python -B -m py_compile tools/epub-review-editor/server.py tools/epub-review-editor/version.py
|
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
|
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
|
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
|
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 启动器时继续执行:
|
涉及 Tauri 配置或 Rust 启动器时继续执行:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Readest 内嵌 EPUB 审校后端
|
# Readest 内嵌 EPUB 审校后端
|
||||||
|
|
||||||
当前版本:`1.0.0`
|
当前版本:`2.1.0`
|
||||||
|
|
||||||
本目录只为 Readest 阅读页内的审校模式提供本地 sidecar API。打开 EPUB 后,点击阅读页顶栏的“校”按钮即可启动或复用服务,并在原阅读界面中选择中日文段落进行审校。
|
本目录只为 Readest 阅读页内的审校模式提供本地 sidecar API。打开 EPUB 后,点击阅读页顶栏的“校”按钮即可启动或复用服务,并在原阅读界面中选择中日文段落进行审校。
|
||||||
|
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
`server.py`、`version.py` 和 `requirements.txt` 仍由桌面端打包,因为内嵌审校模式依赖它们完成 EPUB 解析、会话保存、重翻、术语表、反馈和导出。`src/app/api/review-editor/launch` 也仍用于本地 `dev-web` 启动 sidecar,不是旧独立页面。
|
`server.py`、`version.py` 和 `requirements.txt` 仍由桌面端打包,因为内嵌审校模式依赖它们完成 EPUB 解析、会话保存、重翻、术语表、反馈和导出。`src/app/api/review-editor/launch` 也仍用于本地 `dev-web` 启动 sidecar,不是旧独立页面。
|
||||||
|
|
||||||
|
阅读页右侧面板提供“逐段审校 / 整书翻译”两个页签。整书翻译支持保持 XHTML 文件边界的计划预览、试译或全书任务、断点、暂停、恢复、取消和带令牌的输出下载;旧独立网页、独立书架和 `/review-editor` 不会恢复。
|
||||||
|
|
||||||
## 使用方式
|
## 使用方式
|
||||||
|
|
||||||
桌面端:
|
桌面端:
|
||||||
@@ -29,7 +31,11 @@ Web 开发端:
|
|||||||
pnpm --filter @readest/readest-app dev-web
|
pnpm --filter @readest/readest-app dev-web
|
||||||
```
|
```
|
||||||
|
|
||||||
在 Readest 中打开 EPUB,再点击阅读页顶栏“校”。桌面端通过 Tauri command `launch_epub_review_editor` 启动 sidecar;本地 Web 开发端通过 `POST /api/review-editor/launch` 启动。两者都会注册当前 EPUB 的 session,前端后续请求必须携带该 `session_id`。
|
在 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,并验证开启、保存、重开续审和导出下载。
|
||||||
|
|
||||||
## 审校能力
|
## 审校能力
|
||||||
|
|
||||||
@@ -64,10 +70,22 @@ pnpm --filter @readest/readest-app dev-web
|
|||||||
|
|
||||||
API Key 只写入本地运行配置,接口不得回传密钥,也不得写入 EPUB、反馈或导出文件。
|
API Key 只写入本地运行配置,接口不得回传密钥,也不得写入 EPUB、反馈或导出文件。
|
||||||
|
|
||||||
|
sidecar 的所有 API 和下载请求都必须携带启动器返回的访问令牌,`/api/version` 健康探测也不例外。除创建 session 的 `/api/upload`、`/api/session/from-path` 等入口外,书籍审校接口缺少显式 `session_id` 时直接返回 `400`,不再回退全局 active session。
|
||||||
|
|
||||||
## 主要 API
|
## 主要 API
|
||||||
|
|
||||||
- `GET /api/version`
|
- `GET /api/version`
|
||||||
- `POST /api/session/from-path`
|
- `POST /api/session/from-path`
|
||||||
|
- `POST /api/upload`(本地 `dev-web`,multipart,`activate=false`,携带稳定 `book_key` 复用会话)
|
||||||
|
- `GET /api/session/<session_id>/translation-source?session_id=...`
|
||||||
|
- `POST /api/session/<session_id>/translation-plan?session_id=...`
|
||||||
|
- `GET /api/translation/defaults?session_id=...`
|
||||||
|
- `POST /api/translation/start?session_id=...`
|
||||||
|
- `GET /api/translation/jobs?session_id=...`
|
||||||
|
- `GET|POST /api/translation/jobs/<job_id>[/<action>]?session_id=...`
|
||||||
|
- `GET /api/gpt/models?session_id=...`
|
||||||
|
|
||||||
|
翻译状态为 `untranslated`、`translating`、`translated` 或 `failed`。输出下载必须继续携带 Bearer 令牌和源书 `session_id`,公开响应不返回 API Key。
|
||||||
- `GET /api/session?session_id=...`
|
- `GET /api/session?session_id=...`
|
||||||
- `GET /api/rows?session_id=...`
|
- `GET /api/rows?session_id=...`
|
||||||
- `POST /api/row/<row_id>?session_id=...`
|
- `POST /api/row/<row_id>?session_id=...`
|
||||||
@@ -84,3 +102,6 @@ API Key 只写入本地运行配置,接口不得回传密钥,也不得写入
|
|||||||
- `0.16.0`:新增 Readest 阅读页内嵌审校模式。
|
- `0.16.0`:新增 Readest 阅读页内嵌审校模式。
|
||||||
- `0.16.1` 至 `0.16.5`:完善停靠/浮动布局、宽高调整、注音注释快捷操作和响应式工程结构。
|
- `0.16.1` 至 `0.16.5`:完善停靠/浮动布局、宽高调整、注音注释快捷操作和响应式工程结构。
|
||||||
- `1.0.0`:删除旧独立审校页面、书库入口、静态浏览器前端及独立启动链路,只保留内嵌审校所需的共享 sidecar API。
|
- `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 的隐式回退。
|
||||||
|
- `2.1.0`:在 Readest 阅读页内嵌整书翻译页签,新增翻译计划、任务列表、断点续跑、暂停/恢复/取消、模型探测和安全输出下载;默认开发基座改为本地 `dev-web`,保留 Tauri 兼容。
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import argparse
|
|||||||
import copy
|
import copy
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -35,7 +36,7 @@ except ImportError:
|
|||||||
"MINOR": "新增向后兼容能力、API 字段或可选配置",
|
"MINOR": "新增向后兼容能力、API 字段或可选配置",
|
||||||
"MAJOR": "破坏兼容的 API、配置或行为变更",
|
"MAJOR": "破坏兼容的 API、配置或行为变更",
|
||||||
}
|
}
|
||||||
version = "1.0.0"
|
version = "2.0.0"
|
||||||
|
|
||||||
|
|
||||||
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
|
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
|
||||||
@@ -103,6 +104,8 @@ TRANSLATION_LOG_LIMIT = 80
|
|||||||
DEFAULT_TRANSLATION_LIMIT = 20
|
DEFAULT_TRANSLATION_LIMIT = 20
|
||||||
TRANSLATION_RANGE_LIMIT_MAX = 5000
|
TRANSLATION_RANGE_LIMIT_MAX = 5000
|
||||||
TRANSLATION_CONTEXT_RADIUS = 1
|
TRANSLATION_CONTEXT_RADIUS = 1
|
||||||
|
DEFAULT_TRANSLATION_CHUNK_TARGET = 24
|
||||||
|
TRANSLATION_CHUNK_TARGET_MAX = 120
|
||||||
LIBRARY_DB_NAME = "library.json"
|
LIBRARY_DB_NAME = "library.json"
|
||||||
ROWS_PARSER_VERSION = "0.16.0-inline-review-source-opacity"
|
ROWS_PARSER_VERSION = "0.16.0-inline-review-source-opacity"
|
||||||
|
|
||||||
@@ -139,7 +142,21 @@ def find_free_port(start: int, host: str = "127.0.0.1") -> int:
|
|||||||
|
|
||||||
|
|
||||||
def display_host(host: str) -> str:
|
def display_host(host: str) -> str:
|
||||||
return "localhost" if host in {"127.0.0.1", "0.0.0.0", "::"} else host
|
return "localhost" if host in {"127.0.0.1", "::1"} else host
|
||||||
|
|
||||||
|
|
||||||
|
def is_loopback_host(host: str) -> bool:
|
||||||
|
return host.strip().lower() in {"127.0.0.1", "localhost", "::1"}
|
||||||
|
|
||||||
|
|
||||||
|
def is_allowed_local_origin(origin: str) -> bool:
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(origin)
|
||||||
|
return parsed.scheme in {"http", "https", "tauri"} and is_loopback_host(
|
||||||
|
parsed.hostname or ""
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def read_json(path: Path, default: Any) -> Any:
|
def read_json(path: Path, default: Any) -> Any:
|
||||||
@@ -1121,6 +1138,32 @@ def call_openai_compatible_chat(
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def list_openai_compatible_models(config: dict[str, Any]) -> list[str]:
|
||||||
|
api_key = str(config.get("api_key") or "").strip()
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("尚未配置 GPT API Key")
|
||||||
|
base_url = normalize_gpt_base_url(str(config.get("base_url") or DEFAULT_GPT_BASE_URL))
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{base_url}/models",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8", errors="replace"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = redact_secret(exc.read().decode("utf-8", errors="replace"), api_key)[:1200]
|
||||||
|
raise RuntimeError(f"模型列表请求失败:HTTP {exc.code} {detail}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise RuntimeError(f"模型列表请求失败:{redact_secret(exc.reason, api_key)}") from exc
|
||||||
|
rows = result.get("data") if isinstance(result, dict) else []
|
||||||
|
return sorted(
|
||||||
|
str(row.get("id"))
|
||||||
|
for row in (rows or [])
|
||||||
|
if isinstance(row, dict) and str(row.get("id") or "").strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def session_root_for(review_root: Path, epub_path: Path) -> Path:
|
def session_root_for(review_root: Path, epub_path: Path) -> Path:
|
||||||
digest = hashlib.sha1(str(epub_path.resolve()).encode("utf-8")).hexdigest()[:10]
|
digest = hashlib.sha1(str(epub_path.resolve()).encode("utf-8")).hexdigest()[:10]
|
||||||
return review_root / f"{safe_slug(epub_path.stem)}_{digest}"
|
return review_root / f"{safe_slug(epub_path.stem)}_{digest}"
|
||||||
@@ -1146,6 +1189,10 @@ def library_db_path(review_root: Path) -> Path:
|
|||||||
return review_root / LIBRARY_DB_NAME
|
return review_root / LIBRARY_DB_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def library_overrides_path(review_root: Path) -> Path:
|
||||||
|
return review_root / "library_overrides.json"
|
||||||
|
|
||||||
|
|
||||||
def series_config_path(review_root: Path, series_id: str) -> Path:
|
def series_config_path(review_root: Path, series_id: str) -> Path:
|
||||||
return review_root / "series_configs" / f"{safe_slug(series_id, max_len=120)}.json"
|
return review_root / "series_configs" / f"{safe_slug(series_id, max_len=120)}.json"
|
||||||
|
|
||||||
@@ -1288,7 +1335,7 @@ def summarize_session(session_root: Path) -> dict[str, Any]:
|
|||||||
updated_at = state.get("updated_at") or manifest.get("updated_at") or ""
|
updated_at = state.get("updated_at") or manifest.get("updated_at") or ""
|
||||||
session_id = session_public_id(session_root)
|
session_id = session_public_id(session_root)
|
||||||
cover_asset_path = str(metadata.get("cover_asset_path") or "")
|
cover_asset_path = str(metadata.get("cover_asset_path") or "")
|
||||||
return {
|
summary = {
|
||||||
"id": session_id,
|
"id": session_id,
|
||||||
"version": version,
|
"version": version,
|
||||||
"source_name": source_name,
|
"source_name": source_name,
|
||||||
@@ -1315,6 +1362,13 @@ def summarize_session(session_root: Path) -> dict[str, Any]:
|
|||||||
"latest_export": latest_export,
|
"latest_export": latest_export,
|
||||||
"reading_position": normalize_reading_position(state.get("reading_position", {})),
|
"reading_position": normalize_reading_position(state.get("reading_position", {})),
|
||||||
}
|
}
|
||||||
|
review_root = session_root.parent
|
||||||
|
overrides = read_json(library_overrides_path(review_root), {})
|
||||||
|
assignment = overrides.get("assignments", {}).get(session_id, {}) if isinstance(overrides, dict) else {}
|
||||||
|
if isinstance(assignment, dict) and assignment.get("series_id"):
|
||||||
|
summary["series_id"] = str(assignment["series_id"])
|
||||||
|
summary["series"] = str(assignment.get("series") or summary["series"])
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
def rowTouched_py(row: dict[str, Any]) -> bool:
|
def rowTouched_py(row: dict[str, Any]) -> bool:
|
||||||
@@ -1350,6 +1404,25 @@ def list_review_sessions(review_root: Path) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
def build_library_index(review_root: Path) -> dict[str, Any]:
|
def build_library_index(review_root: Path) -> dict[str, Any]:
|
||||||
sessions = list_review_sessions(review_root)
|
sessions = list_review_sessions(review_root)
|
||||||
|
jobs_by_session: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for job in list_translation_jobs(review_root):
|
||||||
|
source_session_id = str(job.get("source_session_id") or "")
|
||||||
|
if source_session_id:
|
||||||
|
jobs_by_session.setdefault(source_session_id, []).append(job)
|
||||||
|
for session in sessions:
|
||||||
|
session_jobs = jobs_by_session.get(str(session.get("id") or ""), [])
|
||||||
|
latest = session_jobs[0] if session_jobs else None
|
||||||
|
latest_status = str(latest.get("status") or "") if latest else ""
|
||||||
|
session["translation_status"] = (
|
||||||
|
"translated"
|
||||||
|
if latest_status == "completed"
|
||||||
|
else "translating"
|
||||||
|
if latest_status in {"pending", "running", "paused"}
|
||||||
|
else "failed"
|
||||||
|
if latest_status in {"failed", "cancelled"}
|
||||||
|
else "untranslated"
|
||||||
|
)
|
||||||
|
session["translation_job"] = latest
|
||||||
series_map: dict[str, dict[str, Any]] = {}
|
series_map: dict[str, dict[str, Any]] = {}
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
series_id = str(session.get("series_id") or "uncategorized")
|
series_id = str(session.get("series_id") or "uncategorized")
|
||||||
@@ -1374,6 +1447,22 @@ def build_library_index(review_root: Path) -> dict[str, Any]:
|
|||||||
series["publishers"].add(session["publisher"])
|
series["publishers"].add(session["publisher"])
|
||||||
if session.get("language"):
|
if session.get("language"):
|
||||||
series["languages"].add(session["language"])
|
series["languages"].add(session["language"])
|
||||||
|
overrides = read_json(library_overrides_path(review_root), {})
|
||||||
|
manual_series = overrides.get("series", {}) if isinstance(overrides, dict) else {}
|
||||||
|
if isinstance(manual_series, dict):
|
||||||
|
for series_id, title in manual_series.items():
|
||||||
|
series_map.setdefault(
|
||||||
|
str(series_id),
|
||||||
|
{
|
||||||
|
"id": str(series_id),
|
||||||
|
"title": str(title),
|
||||||
|
"count": 0,
|
||||||
|
"books": [],
|
||||||
|
"authors": set(),
|
||||||
|
"publishers": set(),
|
||||||
|
"languages": set(),
|
||||||
|
},
|
||||||
|
)
|
||||||
series_list = []
|
series_list = []
|
||||||
for item in series_map.values():
|
for item in series_map.values():
|
||||||
series_list.append(
|
series_list.append(
|
||||||
@@ -1397,6 +1486,44 @@ def build_library_index(review_root: Path) -> dict[str, Any]:
|
|||||||
return library
|
return library
|
||||||
|
|
||||||
|
|
||||||
|
def save_library_series_overrides(review_root: Path, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
current = read_json(library_overrides_path(review_root), {"series": {}, "assignments": {}})
|
||||||
|
current.setdefault("series", {})
|
||||||
|
current.setdefault("assignments", {})
|
||||||
|
action = str(data.get("action") or "").strip()
|
||||||
|
if action in {"create", "rename"}:
|
||||||
|
title = str(data.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("系列名称不能为空")
|
||||||
|
series_id = safe_slug(str(data.get("series_id") or title), max_len=120)
|
||||||
|
current["series"][series_id] = title
|
||||||
|
elif action == "assign":
|
||||||
|
session_id = str(data.get("session_id") or "").strip()
|
||||||
|
series_id = safe_slug(str(data.get("series_id") or ""), max_len=120)
|
||||||
|
if not session_id or not series_id:
|
||||||
|
raise ValueError("缺少 session_id 或 series_id")
|
||||||
|
session_root_from_id(review_root, session_id)
|
||||||
|
title = str(current["series"].get(series_id) or data.get("title") or series_id)
|
||||||
|
current["series"][series_id] = title
|
||||||
|
current["assignments"][session_id] = {"series_id": series_id, "series": title}
|
||||||
|
elif action == "delete":
|
||||||
|
series_id = safe_slug(str(data.get("series_id") or ""), max_len=120)
|
||||||
|
if not series_id:
|
||||||
|
raise ValueError("缺少 series_id")
|
||||||
|
if any(
|
||||||
|
assignment.get("series_id") == series_id
|
||||||
|
for assignment in current["assignments"].values()
|
||||||
|
if isinstance(assignment, dict)
|
||||||
|
):
|
||||||
|
raise ValueError("系列仍有书籍,不能删除")
|
||||||
|
current["series"].pop(series_id, None)
|
||||||
|
else:
|
||||||
|
raise ValueError("不支持的系列操作")
|
||||||
|
current["updated_at"] = now_iso()
|
||||||
|
write_json_atomic(library_overrides_path(review_root), current)
|
||||||
|
return build_library_index(review_root)
|
||||||
|
|
||||||
|
|
||||||
def read_series_config(review_root: Path, series_id: str) -> dict[str, Any]:
|
def read_series_config(review_root: Path, series_id: str) -> dict[str, Any]:
|
||||||
path = series_config_path(review_root, series_id)
|
path = series_config_path(review_root, series_id)
|
||||||
data = read_json(path, {})
|
data = read_json(path, {})
|
||||||
@@ -1444,14 +1571,20 @@ def validate_epub_file(path: Path) -> None:
|
|||||||
raise ValueError("文件不是有效的 EPUB/ZIP 包")
|
raise ValueError("文件不是有效的 EPUB/ZIP 包")
|
||||||
|
|
||||||
|
|
||||||
def copy_upload_to_review_root(file_storage: Any, review_root: Path) -> Path:
|
def copy_upload_to_review_root(
|
||||||
|
file_storage: Any, review_root: Path, book_key: str = ""
|
||||||
|
) -> Path:
|
||||||
filename = safe_slug(Path(file_storage.filename or "uploaded.epub").name, max_len=140)
|
filename = safe_slug(Path(file_storage.filename or "uploaded.epub").name, max_len=140)
|
||||||
if not filename.lower().endswith(".epub"):
|
if not filename.lower().endswith(".epub"):
|
||||||
filename = f"{filename}.epub"
|
filename = f"{filename}.epub"
|
||||||
upload_dir = review_root / UPLOAD_DIR_NAME
|
upload_dir = review_root / UPLOAD_DIR_NAME
|
||||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
stem = safe_slug(Path(filename).stem, max_len=100)
|
stem = safe_slug(Path(filename).stem, max_len=100)
|
||||||
target = upload_dir / f"{dt.datetime.now().strftime('%Y%m%d_%H%M%S')}_{stem}.epub"
|
if book_key:
|
||||||
|
safe_book_key = safe_slug(book_key, max_len=120)
|
||||||
|
target = upload_dir / f"inline_{safe_book_key}.epub"
|
||||||
|
else:
|
||||||
|
target = upload_dir / f"{dt.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}_{stem}.epub"
|
||||||
try:
|
try:
|
||||||
file_storage.save(target)
|
file_storage.save(target)
|
||||||
validate_epub_file(target)
|
validate_epub_file(target)
|
||||||
@@ -2636,9 +2769,11 @@ def write_feedback_summary(session_root: Path) -> Path:
|
|||||||
|
|
||||||
def apply_edits_to_xhtml(session_root: Path) -> list[str]:
|
def apply_edits_to_xhtml(session_root: Path) -> list[str]:
|
||||||
rows = merge_rows(session_root)
|
rows = merge_rows(session_root)
|
||||||
edited_rows = [row for row in rows if row["edited"]]
|
state = read_json(session_root / "review_state" / "state.json", {"edits": {}})
|
||||||
|
saved_row_ids = set(state.get("edits", {})) if isinstance(state.get("edits"), dict) else set()
|
||||||
|
saved_rows = [row for row in rows if row["id"] in saved_row_ids]
|
||||||
by_file: dict[str, list[dict[str, Any]]] = {}
|
by_file: dict[str, list[dict[str, Any]]] = {}
|
||||||
for row in edited_rows:
|
for row in saved_rows:
|
||||||
by_file.setdefault(row["file"], []).append(row)
|
by_file.setdefault(row["file"], []).append(row)
|
||||||
modified = []
|
modified = []
|
||||||
for entry_name, items in by_file.items():
|
for entry_name, items in by_file.items():
|
||||||
@@ -2745,6 +2880,14 @@ def add_translation_job_log(job: dict[str, Any], message: str, level: str = "inf
|
|||||||
|
|
||||||
def public_translation_job(job: dict[str, Any]) -> dict[str, Any]:
|
def public_translation_job(job: dict[str, Any]) -> dict[str, Any]:
|
||||||
settings = job.get("settings") if isinstance(job.get("settings"), dict) else {}
|
settings = job.get("settings") if isinstance(job.get("settings"), dict) else {}
|
||||||
|
output_epub = Path(str(job.get("output_epub") or ""))
|
||||||
|
output_download_url = ""
|
||||||
|
if output_epub.name and job.get("status") == "completed":
|
||||||
|
output_download_url = (
|
||||||
|
f"/downloads/translation/{urllib.parse.quote(str(job.get('id') or ''))}/"
|
||||||
|
f"{urllib.parse.quote(output_epub.name)}"
|
||||||
|
f"?session_id={urllib.parse.quote(str(job.get('source_session_id') or ''))}"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"id": job.get("id", ""),
|
"id": job.get("id", ""),
|
||||||
"status": job.get("status", ""),
|
"status": job.get("status", ""),
|
||||||
@@ -2758,6 +2901,7 @@ def public_translation_job(job: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"error": job.get("error", ""),
|
"error": job.get("error", ""),
|
||||||
"logs": job.get("logs", []),
|
"logs": job.get("logs", []),
|
||||||
"output_epub": job.get("output_epub", ""),
|
"output_epub": job.get("output_epub", ""),
|
||||||
|
"output_download_url": output_download_url,
|
||||||
"output_session_id": job.get("output_session_id", ""),
|
"output_session_id": job.get("output_session_id", ""),
|
||||||
"settings_summary": {
|
"settings_summary": {
|
||||||
"output_mode": settings.get("output_mode", "bilingual"),
|
"output_mode": settings.get("output_mode", "bilingual"),
|
||||||
@@ -2765,10 +2909,29 @@ def public_translation_job(job: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"limit": settings.get("limit", DEFAULT_TRANSLATION_LIMIT),
|
"limit": settings.get("limit", DEFAULT_TRANSLATION_LIMIT),
|
||||||
"glossary_path": settings.get("glossary_path", ""),
|
"glossary_path": settings.get("glossary_path", ""),
|
||||||
"temperature": settings.get("temperature", 0.2),
|
"temperature": settings.get("temperature", 0.2),
|
||||||
|
"chunk_target": settings.get("chunk_target", DEFAULT_TRANSLATION_CHUNK_TARGET),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_translation_jobs(review_root: Path) -> list[dict[str, Any]]:
|
||||||
|
jobs: list[dict[str, Any]] = []
|
||||||
|
root = translation_jobs_root(review_root)
|
||||||
|
if not root.exists():
|
||||||
|
return jobs
|
||||||
|
for child in root.iterdir():
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
job = read_translation_job(review_root, child.name)
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
if job:
|
||||||
|
jobs.append(public_translation_job(job))
|
||||||
|
jobs.sort(key=lambda item: str(item.get("updated_at") or item.get("created_at") or ""), reverse=True)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
|
def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
|
||||||
output_mode = str(data.get("output_mode") or "bilingual").strip().lower()
|
output_mode = str(data.get("output_mode") or "bilingual").strip().lower()
|
||||||
if output_mode not in {"bilingual", "translated"}:
|
if output_mode not in {"bilingual", "translated"}:
|
||||||
@@ -2786,6 +2949,11 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A
|
|||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise ValueError("temperature 必须是数字") from exc
|
raise ValueError("temperature 必须是数字") from exc
|
||||||
temperature = max(0.0, min(1.0, temperature))
|
temperature = max(0.0, min(1.0, temperature))
|
||||||
|
try:
|
||||||
|
chunk_target = int(data.get("chunk_target") or DEFAULT_TRANSLATION_CHUNK_TARGET)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("每个 chunk 的目标正文块数必须是数字") from exc
|
||||||
|
chunk_target = max(1, min(chunk_target, TRANSLATION_CHUNK_TARGET_MAX))
|
||||||
def prompt_value(name: str) -> Any:
|
def prompt_value(name: str) -> Any:
|
||||||
if data.get(f"use_series_{name}"):
|
if data.get(f"use_series_{name}"):
|
||||||
return None
|
return None
|
||||||
@@ -2797,6 +2965,7 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A
|
|||||||
"range_mode": range_mode,
|
"range_mode": range_mode,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
|
"chunk_target": chunk_target,
|
||||||
"glossary_path": normalize_glossary_path_value(glossary_value) if glossary_value else "",
|
"glossary_path": normalize_glossary_path_value(glossary_value) if glossary_value else "",
|
||||||
"translation_prompt": "" if data.get("use_series_translation_prompt") else normalize_gpt_prompt("translation_prompt", prompt_value("translation_prompt")),
|
"translation_prompt": "" if data.get("use_series_translation_prompt") else normalize_gpt_prompt("translation_prompt", prompt_value("translation_prompt")),
|
||||||
"format_prompt": "" if data.get("use_series_format_prompt") else normalize_gpt_prompt("format_prompt", prompt_value("format_prompt")),
|
"format_prompt": "" if data.get("use_series_format_prompt") else normalize_gpt_prompt("format_prompt", prompt_value("format_prompt")),
|
||||||
@@ -2804,6 +2973,44 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_translation_plan(
|
||||||
|
items: list[dict[str, Any]], chunk_target: int = DEFAULT_TRANSLATION_CHUNK_TARGET
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Group source blocks without crossing XHTML files, preserving stable source order."""
|
||||||
|
chunk_target = max(1, min(int(chunk_target), TRANSLATION_CHUNK_TARGET_MAX))
|
||||||
|
chunks: list[dict[str, Any]] = []
|
||||||
|
current: list[dict[str, Any]] = []
|
||||||
|
current_file = ""
|
||||||
|
|
||||||
|
def flush() -> None:
|
||||||
|
nonlocal current
|
||||||
|
if not current:
|
||||||
|
return
|
||||||
|
text_chars = sum(len(str(item.get("source_text") or "")) for item in current)
|
||||||
|
chunks.append(
|
||||||
|
{
|
||||||
|
"id": f"C{len(chunks) + 1:04d}",
|
||||||
|
"file": current[0].get("file", ""),
|
||||||
|
"title": current[0].get("document_title", ""),
|
||||||
|
"first_item_id": current[0].get("id", ""),
|
||||||
|
"last_item_id": current[-1].get("id", ""),
|
||||||
|
"item_count": len(current),
|
||||||
|
"source_characters": text_chars,
|
||||||
|
"estimated_input_tokens": max(1, round(text_chars / 1.7)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
current = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
item_file = str(item.get("file") or "")
|
||||||
|
if current and (item_file != current_file or len(current) >= chunk_target):
|
||||||
|
flush()
|
||||||
|
current_file = item_file
|
||||||
|
current.append(item)
|
||||||
|
flush()
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
def html_looks_like_navigation(entry_name: str, text: str) -> bool:
|
def html_looks_like_navigation(entry_name: str, text: str) -> bool:
|
||||||
basename = posixpath.basename(entry_name).lower()
|
basename = posixpath.basename(entry_name).lower()
|
||||||
if basename in {"nav.xhtml", "toc.xhtml", "toc.html", "toc.htm"} or "toc" in basename:
|
if basename in {"nav.xhtml", "toc.xhtml", "toc.html", "toc.htm"} or "toc" in basename:
|
||||||
@@ -3120,6 +3327,22 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
|
|||||||
if not selected_items:
|
if not selected_items:
|
||||||
raise ValueError("没有找到可翻译的日文正文块")
|
raise ValueError("没有找到可翻译的日文正文块")
|
||||||
|
|
||||||
|
checkpoint_path = translation_job_root(review_root, job_id) / "translations.json"
|
||||||
|
checkpoint = read_json(checkpoint_path, {})
|
||||||
|
if isinstance(checkpoint, dict):
|
||||||
|
saved_translations = checkpoint.get("translations")
|
||||||
|
if isinstance(saved_translations, dict):
|
||||||
|
translations.update(
|
||||||
|
{
|
||||||
|
str(key): str(value)
|
||||||
|
for key, value in saved_translations.items()
|
||||||
|
if key and isinstance(value, str)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
completed_ids = {item["id"] for item in selected_items if item["id"] in translations}
|
||||||
|
if completed_ids:
|
||||||
|
add_translation_job_log(job, f"已从断点恢复 {len(completed_ids)} 个正文块。")
|
||||||
|
|
||||||
job["progress"].update(
|
job["progress"].update(
|
||||||
{
|
{
|
||||||
"total": len(selected_items),
|
"total": len(selected_items),
|
||||||
@@ -3133,6 +3356,41 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
|
|||||||
failed_count = 0
|
failed_count = 0
|
||||||
errors: list[dict[str, Any]] = []
|
errors: list[dict[str, Any]] = []
|
||||||
for index, item in enumerate(selected_items):
|
for index, item in enumerate(selected_items):
|
||||||
|
job = read_translation_job(review_root, job_id) or job
|
||||||
|
control = str(job.get("control") or "")
|
||||||
|
if control == "cancel":
|
||||||
|
job["status"] = "cancelled"
|
||||||
|
job["finished_at"] = now_iso()
|
||||||
|
job["progress"]["message"] = "任务已取消,断点已保留。"
|
||||||
|
add_translation_job_log(job, job["progress"]["message"])
|
||||||
|
write_translation_job(review_root, job)
|
||||||
|
return
|
||||||
|
if control == "pause":
|
||||||
|
job["status"] = "paused"
|
||||||
|
job["progress"]["message"] = "任务已暂停,断点已保留。"
|
||||||
|
add_translation_job_log(job, job["progress"]["message"])
|
||||||
|
write_translation_job(review_root, job)
|
||||||
|
return
|
||||||
|
if control == "cancel":
|
||||||
|
job["status"] = "cancelled"
|
||||||
|
job["finished_at"] = now_iso()
|
||||||
|
job["progress"]["message"] = "任务已取消,断点已保留。"
|
||||||
|
write_translation_job(review_root, job)
|
||||||
|
return
|
||||||
|
if job.get("status") == "paused":
|
||||||
|
job["status"] = "running"
|
||||||
|
job["control"] = ""
|
||||||
|
add_translation_job_log(job, "任务已恢复。")
|
||||||
|
if item["id"] in translations:
|
||||||
|
job["progress"].update(
|
||||||
|
{
|
||||||
|
"current": index + 1,
|
||||||
|
"percent": round((index + 1) / max(1, len(selected_items)) * 100, 1),
|
||||||
|
"message": f"已从断点跳过 {item['id']}({index + 1}/{len(selected_items)})",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
write_translation_job(review_root, job)
|
||||||
|
continue
|
||||||
job["progress"].update(
|
job["progress"].update(
|
||||||
{
|
{
|
||||||
"current": index,
|
"current": index,
|
||||||
@@ -3161,13 +3419,11 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
|
|||||||
failed_count += 1
|
failed_count += 1
|
||||||
error_text = str(exc)[:900]
|
error_text = str(exc)[:900]
|
||||||
errors.append({"item_id": item["id"], "file": item.get("file", ""), "p_index": item.get("p_index"), "error": error_text})
|
errors.append({"item_id": item["id"], "file": item.get("file", ""), "p_index": item.get("p_index"), "error": error_text})
|
||||||
translations[item["id"]] = f"【本段 AI 翻译失败:{html.escape(error_text)}】"
|
|
||||||
add_translation_job_log(job, f"{item['id']} 翻译失败:{error_text}", level="warning")
|
add_translation_job_log(job, f"{item['id']} 翻译失败:{error_text}", level="warning")
|
||||||
if index == 0 or (index + 1) % 5 == 0 or failed_count:
|
write_json_atomic(
|
||||||
write_json_atomic(
|
checkpoint_path,
|
||||||
translation_job_root(review_root, job_id) / "translations.json",
|
{"items": selected_items, "translations": translations, "errors": errors},
|
||||||
{"items": selected_items, "translations": translations, "errors": errors},
|
)
|
||||||
)
|
|
||||||
|
|
||||||
job["progress"].update(
|
job["progress"].update(
|
||||||
{
|
{
|
||||||
@@ -3227,7 +3483,14 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
|
|||||||
write_translation_job(review_root, job)
|
write_translation_job(review_root, job)
|
||||||
|
|
||||||
|
|
||||||
def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: bool = False) -> Flask:
|
def create_app(
|
||||||
|
review_root: Path,
|
||||||
|
initial_epub_path: Path | None = None,
|
||||||
|
reset: bool = False,
|
||||||
|
access_token: str = "",
|
||||||
|
) -> Flask:
|
||||||
|
if not access_token:
|
||||||
|
raise ValueError("Readest inline review API requires an access token.")
|
||||||
app = Flask(__name__, root_path=str(Path(__file__).resolve().parent), static_folder=None)
|
app = Flask(__name__, root_path=str(Path(__file__).resolve().parent), static_folder=None)
|
||||||
review_root.mkdir(parents=True, exist_ok=True)
|
review_root.mkdir(parents=True, exist_ok=True)
|
||||||
translation_jobs_root(review_root).mkdir(parents=True, exist_ok=True)
|
translation_jobs_root(review_root).mkdir(parents=True, exist_ok=True)
|
||||||
@@ -3237,6 +3500,53 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
app.config["EPUB_PATH"] = None
|
app.config["EPUB_PATH"] = None
|
||||||
app.config["LOCK"] = threading.Lock()
|
app.config["LOCK"] = threading.Lock()
|
||||||
app.config["TRANSLATION_THREADS"] = {}
|
app.config["TRANSLATION_THREADS"] = {}
|
||||||
|
app.config["ACCESS_TOKEN"] = access_token
|
||||||
|
|
||||||
|
def requires_explicit_session_id(path: str) -> bool:
|
||||||
|
exact_paths = {
|
||||||
|
"/api/session",
|
||||||
|
"/api/rows",
|
||||||
|
"/api/structure",
|
||||||
|
"/api/reading-position",
|
||||||
|
"/api/gpt/config",
|
||||||
|
"/api/gpt/models",
|
||||||
|
"/api/glossary",
|
||||||
|
"/api/glossary/entry",
|
||||||
|
"/api/glossary/search",
|
||||||
|
"/api/apply",
|
||||||
|
"/api/export",
|
||||||
|
"/api/feedback",
|
||||||
|
"/api/translation/defaults",
|
||||||
|
"/api/translation/start",
|
||||||
|
"/api/translation/jobs",
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
path in exact_paths
|
||||||
|
or path.startswith("/api/row/")
|
||||||
|
or (path.startswith("/api/session/") and "/translation-" in path)
|
||||||
|
or path.startswith("/api/translation/jobs/")
|
||||||
|
or path.startswith("/api/asset/")
|
||||||
|
or path.startswith("/downloads/")
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def authorize_local_client():
|
||||||
|
if not (request.path.startswith("/api/") or request.path.startswith("/downloads/")):
|
||||||
|
return None
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return None
|
||||||
|
authorization = request.headers.get("Authorization", "")
|
||||||
|
expected = f"Bearer {app.config['ACCESS_TOKEN']}"
|
||||||
|
if not hmac.compare_digest(authorization, expected):
|
||||||
|
response = jsonify({"error": "Unauthorized sidecar request"})
|
||||||
|
response.status_code = 401
|
||||||
|
response.headers["WWW-Authenticate"] = "Bearer"
|
||||||
|
return response
|
||||||
|
if requires_explicit_session_id(request.path):
|
||||||
|
session_id = str(request.args.get("session_id") or "").strip()
|
||||||
|
if not session_id:
|
||||||
|
return jsonify({"error": "缺少 session_id"}), 400
|
||||||
|
return None
|
||||||
|
|
||||||
def set_active_session(session_root: Path, epub_path: Path | None = None) -> None:
|
def set_active_session(session_root: Path, epub_path: Path | None = None) -> None:
|
||||||
app.config["SESSION_ROOT"] = session_root
|
app.config["SESSION_ROOT"] = session_root
|
||||||
@@ -3267,7 +3577,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
def session_from_request() -> tuple[Path | None, Path | None]:
|
def session_from_request() -> tuple[Path | None, Path | None]:
|
||||||
session_id = str(request.args.get("session_id") or "").strip()
|
session_id = str(request.args.get("session_id") or "").strip()
|
||||||
if not session_id:
|
if not session_id:
|
||||||
return active_session()
|
return None, None
|
||||||
try:
|
try:
|
||||||
session_root = session_root_from_id(review_root, session_id)
|
session_root = session_root_from_id(review_root, session_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -3296,20 +3606,11 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
def add_local_embedding_headers(response):
|
def add_local_embedding_headers(response):
|
||||||
# The Readest clients call the loopback API directly. Keep CORS limited
|
# The Readest clients call the loopback API directly. Keep CORS limited
|
||||||
# to known local app origins instead of opening the sidecar to the web.
|
# to known local app origins instead of opening the sidecar to the web.
|
||||||
allowed_origins = {
|
|
||||||
"http://localhost:3000",
|
|
||||||
"http://127.0.0.1:3000",
|
|
||||||
"http://localhost:3001",
|
|
||||||
"http://127.0.0.1:3001",
|
|
||||||
"http://tauri.localhost",
|
|
||||||
"https://tauri.localhost",
|
|
||||||
"tauri://localhost",
|
|
||||||
}
|
|
||||||
origin = request.headers.get("Origin", "")
|
origin = request.headers.get("Origin", "")
|
||||||
if origin in allowed_origins:
|
if is_allowed_local_origin(origin):
|
||||||
response.headers["Access-Control-Allow-Origin"] = origin
|
response.headers["Access-Control-Allow-Origin"] = origin
|
||||||
response.headers["Access-Control-Allow-Credentials"] = "false"
|
response.headers["Access-Control-Allow-Credentials"] = "false"
|
||||||
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
|
||||||
response.headers["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS"
|
response.headers["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS"
|
||||||
response.headers["Vary"] = "Origin"
|
response.headers["Vary"] = "Origin"
|
||||||
response.headers.setdefault("Cross-Origin-Embedder-Policy", "require-corp")
|
response.headers.setdefault("Cross-Origin-Embedder-Policy", "require-corp")
|
||||||
@@ -3380,6 +3681,14 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
def api_library():
|
def api_library():
|
||||||
return jsonify(build_library_index(review_root))
|
return jsonify(build_library_index(review_root))
|
||||||
|
|
||||||
|
@app.route("/api/library/series", methods=["POST"])
|
||||||
|
def api_library_series():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
return jsonify(save_library_series_overrides(review_root, data))
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
@app.route("/api/series/<series_id>/config", methods=["GET", "POST"])
|
@app.route("/api/series/<series_id>/config", methods=["GET", "POST"])
|
||||||
def api_series_config(series_id: str):
|
def api_series_config(series_id: str):
|
||||||
series_id = safe_slug(series_id, max_len=120)
|
series_id = safe_slug(series_id, max_len=120)
|
||||||
@@ -3397,15 +3706,23 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
if file_storage is None or not file_storage.filename:
|
if file_storage is None or not file_storage.filename:
|
||||||
return jsonify({"error": "请选择一个 .epub 文件"}), 400
|
return jsonify({"error": "请选择一个 .epub 文件"}), 400
|
||||||
reset_upload = str(request.form.get("reset", "")).lower() in {"1", "true", "yes", "on"}
|
reset_upload = str(request.form.get("reset", "")).lower() in {"1", "true", "yes", "on"}
|
||||||
|
book_key = str(request.form.get("book_key", "")).strip()
|
||||||
|
activate_upload = str(request.form.get("activate", "true")).lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
epub_path = copy_upload_to_review_root(file_storage, review_root)
|
epub_path = copy_upload_to_review_root(file_storage, review_root, book_key)
|
||||||
session_root = session_root_for(review_root, epub_path)
|
session_root = session_root_for(review_root, epub_path)
|
||||||
with app.config["LOCK"]:
|
with app.config["LOCK"]:
|
||||||
if reset_upload and session_root.exists():
|
if reset_upload and session_root.exists():
|
||||||
shutil.rmtree(session_root)
|
shutil.rmtree(session_root)
|
||||||
ensure_state(session_root, epub_path, reset=reset_upload)
|
ensure_state(session_root, epub_path, reset=reset_upload)
|
||||||
write_session_manifest(session_root, epub_path, source_name=file_storage.filename)
|
write_session_manifest(session_root, epub_path, source_name=file_storage.filename)
|
||||||
set_active_session(session_root, epub_path)
|
if activate_upload:
|
||||||
|
set_active_session(session_root, epub_path)
|
||||||
summary = summarize_session(session_root)
|
summary = summarize_session(session_root)
|
||||||
summary["has_session"] = True
|
summary["has_session"] = True
|
||||||
return jsonify(summary)
|
return jsonify(summary)
|
||||||
@@ -3586,6 +3903,25 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
@app.route("/api/gpt/models")
|
||||||
|
def api_gpt_models():
|
||||||
|
session_root, _epub_path = session_from_request()
|
||||||
|
try:
|
||||||
|
config = read_scoped_gpt_config(review_root, session_root)
|
||||||
|
models = list_openai_compatible_models(config)
|
||||||
|
configured_model = str(config.get("model") or DEFAULT_GPT_MODEL)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"models": models,
|
||||||
|
"configured_model": configured_model,
|
||||||
|
"configured_model_available": configured_model in models,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 502
|
||||||
|
|
||||||
@app.route("/api/glossary", methods=["GET", "POST"])
|
@app.route("/api/glossary", methods=["GET", "POST"])
|
||||||
def api_glossary():
|
def api_glossary():
|
||||||
session_root, _epub_path = session_from_request()
|
session_root, _epub_path = session_from_request()
|
||||||
@@ -3658,7 +3994,8 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
|
|
||||||
@app.route("/api/translation/defaults")
|
@app.route("/api/translation/defaults")
|
||||||
def api_translation_defaults():
|
def api_translation_defaults():
|
||||||
config = public_gpt_config(review_root)
|
session_root, _epub_path = session_from_request()
|
||||||
|
config = public_scoped_gpt_config(review_root, session_root)
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"version": version,
|
"version": version,
|
||||||
@@ -3668,6 +4005,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
"range_mode": "limit",
|
"range_mode": "limit",
|
||||||
"limit": DEFAULT_TRANSLATION_LIMIT,
|
"limit": DEFAULT_TRANSLATION_LIMIT,
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
|
"chunk_target": DEFAULT_TRANSLATION_CHUNK_TARGET,
|
||||||
"translation_prompt": config.get("translation_prompt") or DEFAULT_TRANSLATION_PROMPT,
|
"translation_prompt": config.get("translation_prompt") or DEFAULT_TRANSLATION_PROMPT,
|
||||||
"format_prompt": config.get("format_prompt") or DEFAULT_FORMAT_PROMPT,
|
"format_prompt": config.get("format_prompt") or DEFAULT_FORMAT_PROMPT,
|
||||||
"character_prompt": config.get("character_prompt") or DEFAULT_CHARACTER_PROMPT,
|
"character_prompt": config.get("character_prompt") or DEFAULT_CHARACTER_PROMPT,
|
||||||
@@ -3722,12 +4060,42 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({"error": f"读取翻译源失败:{exc}"}), 500
|
return jsonify({"error": f"读取翻译源失败:{exc}"}), 500
|
||||||
|
|
||||||
|
@app.route("/api/session/<session_id>/translation-plan", methods=["GET", "POST"])
|
||||||
|
def api_translation_plan(session_id: str):
|
||||||
|
try:
|
||||||
|
source_root = session_root_from_id(review_root, session_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
if not (source_root / "review_state" / "state.json").exists() or session_is_hidden(source_root):
|
||||||
|
return jsonify({"error": "会话不存在或已移除"}), 404
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
raw_target = data.get("chunk_target") or request.args.get("chunk_target") or DEFAULT_TRANSLATION_CHUNK_TARGET
|
||||||
|
try:
|
||||||
|
chunk_target = max(1, min(int(raw_target), TRANSLATION_CHUNK_TARGET_MAX))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"error": "chunk_target 必须是数字"}), 400
|
||||||
|
items = extract_translation_source_items(source_root)
|
||||||
|
chunks = build_translation_plan(items, chunk_target)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"session_id": session_id,
|
||||||
|
"source_count": len(items),
|
||||||
|
"chunk_target": chunk_target,
|
||||||
|
"chunk_count": len(chunks),
|
||||||
|
"estimated_input_tokens": sum(chunk["estimated_input_tokens"] for chunk in chunks),
|
||||||
|
"chunks": chunks,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@app.route("/api/translation/start", methods=["POST"])
|
@app.route("/api/translation/start", methods=["POST"])
|
||||||
def api_translation_start():
|
def api_translation_start():
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
session_id = str(data.get("session_id") or "").strip()
|
session_id = str(request.args.get("session_id") or "").strip()
|
||||||
if not session_id:
|
if not session_id:
|
||||||
return jsonify({"error": "缺少 session_id"}), 400
|
return jsonify({"error": "缺少 session_id"}), 400
|
||||||
|
body_session_id = str(data.get("session_id") or "").strip()
|
||||||
|
if body_session_id and body_session_id != session_id:
|
||||||
|
return jsonify({"error": "请求中的 session_id 不一致"}), 400
|
||||||
try:
|
try:
|
||||||
source_root = session_root_from_id(review_root, session_id)
|
source_root = session_root_from_id(review_root, session_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -3739,7 +4107,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
try:
|
try:
|
||||||
requested_base_url = str(data.get("base_url") or "").strip()
|
requested_base_url = str(data.get("base_url") or "").strip()
|
||||||
requested_model = str(data.get("model") or "").strip()
|
requested_model = str(data.get("model") or "").strip()
|
||||||
config = read_gpt_config(review_root)
|
config = read_scoped_gpt_config(review_root, source_root)
|
||||||
if requested_base_url and normalize_gpt_base_url(requested_base_url) != config["base_url"]:
|
if requested_base_url and normalize_gpt_base_url(requested_base_url) != config["base_url"]:
|
||||||
return jsonify({"error": "整书翻译不能临时覆盖 Base URL。请先保存 API 设置。"}), 400
|
return jsonify({"error": "整书翻译不能临时覆盖 Base URL。请先保存 API 设置。"}), 400
|
||||||
if requested_model and requested_model != config["model"]:
|
if requested_model and requested_model != config["model"]:
|
||||||
@@ -3790,6 +4158,14 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({"error": f"创建翻译任务失败:{exc}"}), 500
|
return jsonify({"error": f"创建翻译任务失败:{exc}"}), 500
|
||||||
|
|
||||||
|
@app.route("/api/translation/jobs")
|
||||||
|
def api_translation_jobs():
|
||||||
|
jobs = list_translation_jobs(review_root)
|
||||||
|
session_id = str(request.args.get("session_id") or "").strip()
|
||||||
|
if session_id:
|
||||||
|
jobs = [job for job in jobs if job.get("source_session_id") == session_id]
|
||||||
|
return jsonify({"jobs": jobs, "count": len(jobs)})
|
||||||
|
|
||||||
@app.route("/api/translation/jobs/<job_id>")
|
@app.route("/api/translation/jobs/<job_id>")
|
||||||
def api_translation_job(job_id: str):
|
def api_translation_job(job_id: str):
|
||||||
try:
|
try:
|
||||||
@@ -3798,6 +4174,50 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
if not job:
|
if not job:
|
||||||
return jsonify({"error": "翻译任务不存在"}), 404
|
return jsonify({"error": "翻译任务不存在"}), 404
|
||||||
|
if job.get("source_session_id") != request.args.get("session_id"):
|
||||||
|
return jsonify({"error": "翻译任务不属于当前书籍会话"}), 404
|
||||||
|
return jsonify({"job": public_translation_job(job)})
|
||||||
|
|
||||||
|
@app.route("/api/translation/jobs/<job_id>/<action>", methods=["POST"])
|
||||||
|
def api_translation_job_action(job_id: str, action: str):
|
||||||
|
if action not in {"pause", "resume", "cancel"}:
|
||||||
|
return jsonify({"error": "不支持的任务操作"}), 404
|
||||||
|
try:
|
||||||
|
job = read_translation_job(review_root, job_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
if not job:
|
||||||
|
return jsonify({"error": "翻译任务不存在"}), 404
|
||||||
|
if job.get("source_session_id") != request.args.get("session_id"):
|
||||||
|
return jsonify({"error": "翻译任务不属于当前书籍会话"}), 404
|
||||||
|
status = str(job.get("status") or "")
|
||||||
|
if action == "pause":
|
||||||
|
if status not in {"pending", "running"}:
|
||||||
|
return jsonify({"error": "当前任务不能暂停"}), 409
|
||||||
|
job["control"] = "pause"
|
||||||
|
add_translation_job_log(job, "已请求暂停,当前 API 调用完成后生效。")
|
||||||
|
elif action == "cancel":
|
||||||
|
if status in {"completed", "failed", "cancelled"}:
|
||||||
|
return jsonify({"error": "当前任务不能取消"}), 409
|
||||||
|
job["control"] = "cancel"
|
||||||
|
add_translation_job_log(job, "已请求取消,当前 API 调用完成后生效。")
|
||||||
|
else:
|
||||||
|
if status not in {"paused", "failed", "cancelled"}:
|
||||||
|
return jsonify({"error": "当前任务不能恢复"}), 409
|
||||||
|
running_thread = app.config["TRANSLATION_THREADS"].get(job_id)
|
||||||
|
if running_thread and running_thread.is_alive():
|
||||||
|
return jsonify({"error": "翻译任务仍在停止中,请稍后重试"}), 409
|
||||||
|
job["control"] = ""
|
||||||
|
job["status"] = "pending"
|
||||||
|
job["finished_at"] = ""
|
||||||
|
job["error"] = ""
|
||||||
|
add_translation_job_log(job, "已请求从断点恢复。")
|
||||||
|
write_translation_job(review_root, job)
|
||||||
|
thread = threading.Thread(target=run_translation_job, args=(review_root, job_id), daemon=True)
|
||||||
|
app.config["TRANSLATION_THREADS"][job_id] = thread
|
||||||
|
thread.start()
|
||||||
|
return jsonify({"job": public_translation_job(job)})
|
||||||
|
write_translation_job(review_root, job)
|
||||||
return jsonify({"job": public_translation_job(job)})
|
return jsonify({"job": public_translation_job(job)})
|
||||||
|
|
||||||
@app.route("/api/row/<row_id>/retranslate", methods=["POST"])
|
@app.route("/api/row/<row_id>/retranslate", methods=["POST"])
|
||||||
@@ -3977,6 +4397,24 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
return no_active_response()
|
return no_active_response()
|
||||||
return send_from_directory(session_root / "exports", filename, as_attachment=True)
|
return send_from_directory(session_root / "exports", filename, as_attachment=True)
|
||||||
|
|
||||||
|
@app.route("/downloads/translation/<job_id>/<path:filename>")
|
||||||
|
def download_translation(job_id: str, filename: str):
|
||||||
|
try:
|
||||||
|
job = read_translation_job(review_root, job_id)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "翻译任务不存在"}), 404
|
||||||
|
if not job or job.get("source_session_id") != request.args.get("session_id"):
|
||||||
|
return jsonify({"error": "翻译任务不存在"}), 404
|
||||||
|
output_path = Path(str(job.get("output_epub") or ""))
|
||||||
|
output_root = (review_root / TRANSLATION_OUTPUT_DIR_NAME).resolve()
|
||||||
|
try:
|
||||||
|
output_path.resolve().relative_to(output_root)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "翻译输出路径无效"}), 400
|
||||||
|
if filename != output_path.name or not output_path.is_file():
|
||||||
|
return jsonify({"error": "翻译输出不存在"}), 404
|
||||||
|
return send_from_directory(output_path.parent, output_path.name, as_attachment=True)
|
||||||
|
|
||||||
@app.route("/downloads/feedback/<path:filename>")
|
@app.route("/downloads/feedback/<path:filename>")
|
||||||
def download_feedback(filename: str):
|
def download_feedback(filename: str):
|
||||||
session_root, _epub_path = session_from_request()
|
session_root, _epub_path = session_from_request()
|
||||||
@@ -3998,13 +4436,17 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def wait_until_ready(url: str, proc: subprocess.Popen, timeout: int = 15) -> bool:
|
def wait_until_ready(url: str, proc: subprocess.Popen, access_token: str, timeout: int = 15) -> bool:
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + timeout
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
if proc.poll() is not None:
|
if proc.poll() is not None:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(f"{url}/api/session", timeout=1) as response:
|
version_request = urllib.request.Request(
|
||||||
|
f"{url}/api/version",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(version_request, timeout=1) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
return True
|
return True
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -4043,6 +4485,8 @@ def run_daemon(args: argparse.Namespace) -> int:
|
|||||||
args.host,
|
args.host,
|
||||||
"--port",
|
"--port",
|
||||||
str(port),
|
str(port),
|
||||||
|
"--access-token",
|
||||||
|
args.access_token,
|
||||||
]
|
]
|
||||||
if epub_path is not None:
|
if epub_path is not None:
|
||||||
cmd.insert(2, str(epub_path))
|
cmd.insert(2, str(epub_path))
|
||||||
@@ -4051,7 +4495,11 @@ def run_daemon(args: argparse.Namespace) -> int:
|
|||||||
creationflags = 0
|
creationflags = 0
|
||||||
popen_kwargs: dict[str, Any] = {}
|
popen_kwargs: dict[str, Any] = {}
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
creationflags = (
|
||||||
|
subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
|
| subprocess.DETACHED_PROCESS
|
||||||
|
| subprocess.CREATE_NO_WINDOW
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
popen_kwargs["start_new_session"] = True
|
popen_kwargs["start_new_session"] = True
|
||||||
with log_path.open("a", encoding="utf-8") as log:
|
with log_path.open("a", encoding="utf-8") as log:
|
||||||
@@ -4064,7 +4512,7 @@ def run_daemon(args: argparse.Namespace) -> int:
|
|||||||
**popen_kwargs,
|
**popen_kwargs,
|
||||||
)
|
)
|
||||||
url = f"http://{display_host(args.host)}:{port}"
|
url = f"http://{display_host(args.host)}:{port}"
|
||||||
if not wait_until_ready(url, proc):
|
if not wait_until_ready(url, proc, args.access_token):
|
||||||
print(f"review editor failed to start; see {log_path}", file=sys.stderr)
|
print(f"review editor failed to start; see {log_path}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
print(url)
|
print(url)
|
||||||
@@ -4080,8 +4528,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
parser = argparse.ArgumentParser(description="Run the local Readest inline review API.")
|
parser = argparse.ArgumentParser(description="Run the local Readest inline review API.")
|
||||||
parser.add_argument("epub", nargs="?", help="Optional EPUB path used to initialize a review session.")
|
parser.add_argument("epub", nargs="?", help="Optional EPUB path used to initialize a review session.")
|
||||||
parser.add_argument("--review-root", default=DEFAULT_REVIEW_ROOT, help="Directory for review sessions.")
|
parser.add_argument("--review-root", default=DEFAULT_REVIEW_ROOT, help="Directory for review sessions.")
|
||||||
parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Use 0.0.0.0 for trusted LAN/server access.")
|
parser.add_argument("--host", default="127.0.0.1", help="Loopback host to bind.")
|
||||||
parser.add_argument("--port", type=int, default=5177, help="Port, auto-advances if busy.")
|
parser.add_argument("--port", type=int, default=5177, help="Port, auto-advances if busy.")
|
||||||
|
parser.add_argument("--access-token", required=True, help=argparse.SUPPRESS)
|
||||||
parser.add_argument("--daemon", action="store_true", help="Start in background and print URL.")
|
parser.add_argument("--daemon", action="store_true", help="Start in background and print URL.")
|
||||||
parser.add_argument("--reset", action="store_true", help="Re-extract the EPUB and discard prior review state.")
|
parser.add_argument("--reset", action="store_true", help="Re-extract the EPUB and discard prior review state.")
|
||||||
return parser
|
return parser
|
||||||
@@ -4089,6 +4538,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
args = build_parser().parse_args(argv)
|
args = build_parser().parse_args(argv)
|
||||||
|
if not is_loopback_host(args.host):
|
||||||
|
print("Readest inline review API only supports loopback hosts.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
epub_path = Path(args.epub).resolve() if args.epub else None
|
epub_path = Path(args.epub).resolve() if args.epub else None
|
||||||
if epub_path is not None:
|
if epub_path is not None:
|
||||||
if not epub_path.exists():
|
if not epub_path.exists():
|
||||||
@@ -4103,7 +4555,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return run_daemon(args)
|
return run_daemon(args)
|
||||||
|
|
||||||
review_root = Path(args.review_root).resolve()
|
review_root = Path(args.review_root).resolve()
|
||||||
app = create_app(review_root, epub_path, reset=args.reset)
|
app = create_app(review_root, epub_path, reset=args.reset, access_token=args.access_token)
|
||||||
port = find_free_port(args.port, args.host)
|
port = find_free_port(args.port, args.host)
|
||||||
url = f"http://{display_host(args.host)}:{port}"
|
url = f"http://{display_host(args.host)}:{port}"
|
||||||
def on_sigterm(_signum: int, _frame: Any) -> None:
|
def on_sigterm(_signum: int, _frame: Any) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import importlib.util
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).with_name("server.py")
|
||||||
|
SPEC = importlib.util.spec_from_file_location("epub_review_server_translation", MODULE_PATH)
|
||||||
|
server = importlib.util.module_from_spec(SPEC)
|
||||||
|
assert SPEC.loader is not None
|
||||||
|
SPEC.loader.exec_module(server)
|
||||||
|
|
||||||
|
|
||||||
|
class FullBookTranslationTests(unittest.TestCase):
|
||||||
|
def test_default_model_keeps_existing_compatibility(self):
|
||||||
|
self.assertEqual(server.DEFAULT_GPT_MODEL, "gpt-4o-mini")
|
||||||
|
|
||||||
|
def test_chunk_plan_keeps_file_boundaries(self):
|
||||||
|
items = [
|
||||||
|
{"id": "T00001", "file": "a.xhtml", "document_title": "A", "source_text": "一" * 17},
|
||||||
|
{"id": "T00002", "file": "a.xhtml", "document_title": "A", "source_text": "二" * 17},
|
||||||
|
{"id": "T00003", "file": "b.xhtml", "document_title": "B", "source_text": "三" * 17},
|
||||||
|
]
|
||||||
|
chunks = server.build_translation_plan(items, chunk_target=2)
|
||||||
|
self.assertEqual([chunk["item_count"] for chunk in chunks], [2, 1])
|
||||||
|
self.assertEqual([chunk["file"] for chunk in chunks], ["a.xhtml", "b.xhtml"])
|
||||||
|
self.assertEqual(chunks[0]["first_item_id"], "T00001")
|
||||||
|
self.assertEqual(chunks[0]["last_item_id"], "T00002")
|
||||||
|
|
||||||
|
def test_glossary_roundtrip_keeps_legacy_object_shape(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
glossary_file = root / "mingcibiao.json"
|
||||||
|
server.write_json(root / "gpt_config.json", {"glossary_path": str(glossary_file)})
|
||||||
|
payload = server.save_glossary(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
{"source": "勇者", "target": "勇者"},
|
||||||
|
{"source": "聖剣", "target": "圣剑(Excalibur)#保留有意义注音"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
saved = server.read_json(glossary_file, {})
|
||||||
|
self.assertEqual(saved["聖剣"], "圣剑(Excalibur)#保留有意义注音")
|
||||||
|
self.assertEqual(payload["count"], 2)
|
||||||
|
self.assertTrue(all(isinstance(value, str) for value in saved.values()))
|
||||||
|
|
||||||
|
def test_job_list_never_exposes_api_key(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
job = {
|
||||||
|
"id": "tr_test",
|
||||||
|
"status": "running",
|
||||||
|
"source_session_id": "book",
|
||||||
|
"settings": {"api_key": "secret-value", "model": "gpt-5.6-sol"},
|
||||||
|
"logs": [],
|
||||||
|
}
|
||||||
|
server.write_translation_job(root, job)
|
||||||
|
public = server.list_translation_jobs(root)
|
||||||
|
self.assertNotIn("secret-value", str(public))
|
||||||
|
|
||||||
|
def test_library_translation_status_is_derived_from_jobs(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
session = root / "book_session"
|
||||||
|
server.write_json(session / "review_state" / "state.json", {"source_epub": "book.epub"})
|
||||||
|
server.write_json(
|
||||||
|
server.session_manifest_path(session),
|
||||||
|
{"id": session.name, "source_name": "book.epub", "metadata": {"title": "书"}},
|
||||||
|
)
|
||||||
|
server.write_translation_job(
|
||||||
|
root,
|
||||||
|
{
|
||||||
|
"id": "tr_status",
|
||||||
|
"status": "running",
|
||||||
|
"source_session_id": session.name,
|
||||||
|
"settings": {},
|
||||||
|
"logs": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
library = server.build_library_index(root)
|
||||||
|
self.assertEqual(library["sessions"][0]["translation_status"], "translating")
|
||||||
|
|
||||||
|
def test_failed_latest_job_is_not_reported_as_translating(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
session = root / "book_session"
|
||||||
|
server.write_json(session / "review_state" / "state.json", {"source_epub": "book.epub"})
|
||||||
|
server.write_json(
|
||||||
|
server.session_manifest_path(session),
|
||||||
|
{"id": session.name, "source_name": "book.epub", "metadata": {"title": "书"}},
|
||||||
|
)
|
||||||
|
server.write_translation_job(
|
||||||
|
root,
|
||||||
|
{
|
||||||
|
"id": "tr_failed",
|
||||||
|
"status": "failed",
|
||||||
|
"source_session_id": session.name,
|
||||||
|
"settings": {},
|
||||||
|
"logs": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
library = server.build_library_index(root)
|
||||||
|
self.assertEqual(library["sessions"][0]["translation_status"], "failed")
|
||||||
|
|
||||||
|
def test_manual_series_assignment_survives_library_rebuild(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
session = root / "book_session"
|
||||||
|
server.write_json(session / "review_state" / "state.json", {"source_epub": "book.epub"})
|
||||||
|
server.write_json(
|
||||||
|
server.session_manifest_path(session),
|
||||||
|
{
|
||||||
|
"id": session.name,
|
||||||
|
"source_name": "book.epub",
|
||||||
|
"source_epub": "book.epub",
|
||||||
|
"metadata": {"title": "第一卷", "series": "自动系列", "series_id": "auto"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
server.save_library_series_overrides(root, {"action": "create", "series_id": "manual", "title": "手动系列"})
|
||||||
|
library = server.save_library_series_overrides(
|
||||||
|
root,
|
||||||
|
{"action": "assign", "session_id": session.name, "series_id": "manual"},
|
||||||
|
)
|
||||||
|
book = next(item for item in library["sessions"] if item["id"] == session.name)
|
||||||
|
self.assertEqual(book["series_id"], "manual")
|
||||||
|
self.assertEqual(book["series"], "手动系列")
|
||||||
|
rebuilt = server.build_library_index(root)
|
||||||
|
rebuilt_book = next(item for item in rebuilt["sessions"] if item["id"] == session.name)
|
||||||
|
self.assertEqual(rebuilt_book["series_id"], "manual")
|
||||||
|
|
||||||
|
def test_models_endpoint_returns_only_public_ids(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
token = "test-sidecar-token"
|
||||||
|
app = server.create_app(Path(temp_dir), access_token=token)
|
||||||
|
with patch.object(server, "read_scoped_gpt_config", return_value={
|
||||||
|
"api_key": "secret-value",
|
||||||
|
"base_url": "https://example.test/v1",
|
||||||
|
"model": "gpt-5.6-sol",
|
||||||
|
}), patch.object(server, "list_openai_compatible_models", return_value=["gpt-5.6-sol"]):
|
||||||
|
response = app.test_client().get(
|
||||||
|
"/api/gpt/models?session_id=test-session",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.get_json()
|
||||||
|
self.assertEqual(body["models"], ["gpt-5.6-sol"])
|
||||||
|
self.assertNotIn("secret-value", str(body))
|
||||||
|
|
||||||
|
def test_translation_routes_require_token_and_explicit_session(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
token = "test-sidecar-token"
|
||||||
|
app = server.create_app(Path(temp_dir), access_token=token)
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
missing_token = client.get("/api/translation/jobs?session_id=book")
|
||||||
|
missing_session = client.get(
|
||||||
|
"/api/translation/jobs",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(missing_token.status_code, 401)
|
||||||
|
self.assertEqual(missing_session.status_code, 400)
|
||||||
|
|
||||||
|
def test_translation_job_control_rejects_another_session(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
token = "test-sidecar-token"
|
||||||
|
server.write_translation_job(
|
||||||
|
root,
|
||||||
|
{
|
||||||
|
"id": "tr_owned",
|
||||||
|
"status": "paused",
|
||||||
|
"source_session_id": "book-a",
|
||||||
|
"settings": {},
|
||||||
|
"logs": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
app = server.create_app(root, access_token=token)
|
||||||
|
response = app.test_client().post(
|
||||||
|
"/api/translation/jobs/tr_owned/resume?session_id=book-b",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -18,9 +19,77 @@ def write_json(path: Path, data):
|
|||||||
|
|
||||||
|
|
||||||
class InlineReviewSessionScopeTest(unittest.TestCase):
|
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):
|
def test_sidecar_has_no_legacy_standalone_page(self):
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
app = server.create_app(Path(temp_dir))
|
app = self.make_app(Path(temp_dir))
|
||||||
|
|
||||||
response = app.test_client().get("/")
|
response = app.test_client().get("/")
|
||||||
|
|
||||||
@@ -39,6 +108,121 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
|
|||||||
'<span class="review-annotation">(注:注释)</span>',
|
'<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(
|
def make_session(
|
||||||
self,
|
self,
|
||||||
review_root: Path,
|
review_root: Path,
|
||||||
@@ -127,8 +311,8 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
|
|||||||
|
|
||||||
server.call_openai_compatible_chat = fake_call
|
server.call_openai_compatible_chat = fake_call
|
||||||
try:
|
try:
|
||||||
app = server.create_app(review_root)
|
app = self.make_app(review_root)
|
||||||
response = app.test_client().post(
|
response = self.client(app).post(
|
||||||
"/api/row/R00001/retranslate?session_id=session-a",
|
"/api/row/R00001/retranslate?session_id=session-a",
|
||||||
json={"instruction": ""},
|
json={"instruction": ""},
|
||||||
)
|
)
|
||||||
@@ -157,8 +341,8 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
|
|||||||
epub_path.write_bytes(b"not a real epub")
|
epub_path.write_bytes(b"not a real epub")
|
||||||
self.make_session(review_root, "session-a", epub_path, glossary)
|
self.make_session(review_root, "session-a", epub_path, glossary)
|
||||||
|
|
||||||
app = server.create_app(review_root)
|
app = self.make_app(review_root)
|
||||||
response = app.test_client().post(
|
response = self.client(app).post(
|
||||||
"/api/gpt/config?session_id=session-a",
|
"/api/gpt/config?session_id=session-a",
|
||||||
json={
|
json={
|
||||||
"api_key": "session-secret",
|
"api_key": "session-secret",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
version = "1.0.0"
|
version = "2.1.0"
|
||||||
|
|
||||||
VERSION_RULES = {
|
VERSION_RULES = {
|
||||||
"PATCH": "修复 bug 或兼容性小修",
|
"PATCH": "修复 bug 或兼容性小修",
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
param(
|
param(
|
||||||
[string]$Remote = "origin",
|
[string]$Remote = "akai-tools",
|
||||||
[string]$Branch = "codex/readest-inline-review-mode",
|
[string]$Branch = "codex/readest-web-translation-base",
|
||||||
|
[switch]$Web,
|
||||||
|
[switch]$Desktop,
|
||||||
[switch]$SkipPull,
|
[switch]$SkipPull,
|
||||||
[switch]$CheckOnly,
|
[switch]$CheckOnly,
|
||||||
[switch]$KeepRunning
|
[switch]$KeepRunning
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if ($Web -and $Desktop) {
|
||||||
|
throw "-Web and -Desktop cannot be used together. Web is the default mode."
|
||||||
|
}
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
@@ -42,6 +48,17 @@ function Add-PathIfExists {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-SubmoduleCheckout {
|
||||||
|
param([string]$RelativePath)
|
||||||
|
|
||||||
|
$fullPath = Join-Path $RepoRoot $RelativePath
|
||||||
|
if (-not (Test-Path -LiteralPath (Join-Path $fullPath ".git"))) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
& git -C $fullPath rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||||
|
return $LASTEXITCODE -eq 0
|
||||||
|
}
|
||||||
|
|
||||||
function Stop-OldReadestDev {
|
function Stop-OldReadestDev {
|
||||||
if ($KeepRunning) {
|
if ($KeepRunning) {
|
||||||
return
|
return
|
||||||
@@ -62,14 +79,54 @@ function Stop-OldReadestDev {
|
|||||||
ForEach-Object {
|
ForEach-Object {
|
||||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
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 {
|
function Assert-CleanWorktree {
|
||||||
$status = (& git -C $RepoRoot status --porcelain)
|
$status = @(& git -C $RepoRoot status --porcelain --untracked-files=no --ignore-submodules=dirty)
|
||||||
if ($status) {
|
if ($status) {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Local changes were found. The launcher will not pull or overwrite them:" -ForegroundColor Yellow
|
Write-Host "Local changes were found. The launcher will not pull or overwrite them:" -ForegroundColor Yellow
|
||||||
& git -C $RepoRoot status --short
|
$status | ForEach-Object { Write-Host $_ }
|
||||||
throw "Commit, stash, or discard local changes before launching the latest organization version."
|
throw "Commit, stash, or discard local changes before launching the latest organization version."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +141,11 @@ try {
|
|||||||
$env:Path = (($PathParts + ($env:Path -split ";" | Where-Object { $_ })) -join ";")
|
$env:Path = (($PathParts + ($env:Path -split ";" | Where-Object { $_ })) -join ";")
|
||||||
$env:RUSTUP_TOOLCHAIN = "stable-x86_64-pc-windows-gnu"
|
$env:RUSTUP_TOOLCHAIN = "stable-x86_64-pc-windows-gnu"
|
||||||
|
|
||||||
foreach ($command in @("git", "pnpm", "cargo")) {
|
$requiredCommands = @("git", "pnpm")
|
||||||
|
if ($Desktop) {
|
||||||
|
$requiredCommands += "cargo"
|
||||||
|
}
|
||||||
|
foreach ($command in $requiredCommands) {
|
||||||
if (-not (Get-Command $command -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Command $command -ErrorAction SilentlyContinue)) {
|
||||||
throw "Required command not found: $command"
|
throw "Required command not found: $command"
|
||||||
}
|
}
|
||||||
@@ -94,6 +155,7 @@ try {
|
|||||||
Write-Host "Repository: $RepoRoot"
|
Write-Host "Repository: $RepoRoot"
|
||||||
Write-Host "Remote: $Remote"
|
Write-Host "Remote: $Remote"
|
||||||
Write-Host "Branch: $Branch"
|
Write-Host "Branch: $Branch"
|
||||||
|
Write-Host "Mode: $(if ($Desktop) { 'Desktop (Tauri)' } else { 'Web (Fast Refresh)' })"
|
||||||
|
|
||||||
$beforeHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
|
$beforeHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
|
||||||
|
|
||||||
@@ -123,7 +185,26 @@ try {
|
|||||||
$afterHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
|
$afterHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
|
||||||
|
|
||||||
Write-Step "Updating submodules"
|
Write-Step "Updating submodules"
|
||||||
Invoke-Checked "git" @("-C", $RepoRoot, "submodule", "update", "--init", "--recursive")
|
$requiredSubmodules = @(
|
||||||
|
"packages/foliate-js",
|
||||||
|
"packages/simplecc-wasm",
|
||||||
|
"packages/js-mdict"
|
||||||
|
)
|
||||||
|
if ($Desktop) {
|
||||||
|
$requiredSubmodules += "apps/readest-app/src-tauri/plugins/tauri-plugin-turso"
|
||||||
|
}
|
||||||
|
$missingSubmodules = @($requiredSubmodules | Where-Object { -not (Test-SubmoduleCheckout $_) })
|
||||||
|
if ($missingSubmodules.Count -gt 0) {
|
||||||
|
$submoduleArgs = @(
|
||||||
|
"-C", $RepoRoot,
|
||||||
|
"-c", "http.version=HTTP/1.1",
|
||||||
|
"-c", "submodule.fetchJobs=1",
|
||||||
|
"submodule", "update", "--init", "--depth", "1", "--"
|
||||||
|
) + $missingSubmodules
|
||||||
|
Invoke-Checked "git" $submoduleArgs
|
||||||
|
} else {
|
||||||
|
Write-Host "Required submodules are already initialized."
|
||||||
|
}
|
||||||
|
|
||||||
$changedFiles = @()
|
$changedFiles = @()
|
||||||
if ($beforeHead -ne $afterHead) {
|
if ($beforeHead -ne $afterHead) {
|
||||||
@@ -141,6 +222,22 @@ try {
|
|||||||
Invoke-Checked "pnpm" @("install", "--frozen-lockfile")
|
Invoke-Checked "pnpm" @("install", "--frozen-lockfile")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$vendorSentinel = Join-Path $RepoRoot "apps\readest-app\public\vendor\pdfjs\pdf.min.mjs"
|
||||||
|
if ($needsInstall -or -not (Test-Path -LiteralPath $vendorSentinel)) {
|
||||||
|
Write-Step "Preparing browser runtime assets"
|
||||||
|
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "setup-vendors")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Desktop) {
|
||||||
|
$tursoRoot = Join-Path $RepoRoot "apps\readest-app\src-tauri\plugins\tauri-plugin-turso"
|
||||||
|
$tursoOutput = Join-Path $tursoRoot "dist\index.js"
|
||||||
|
if (-not (Test-Path -LiteralPath $tursoOutput)) {
|
||||||
|
Write-Step "Building Tauri plugin dependencies"
|
||||||
|
Invoke-Checked "pnpm" @("--dir", $tursoRoot, "install", "--ignore-workspace", "--frozen-lockfile")
|
||||||
|
Invoke-Checked "pnpm" @("--dir", $tursoRoot, "build")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($CheckOnly) {
|
if ($CheckOnly) {
|
||||||
Write-Step "Launcher check complete"
|
Write-Step "Launcher check complete"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -148,9 +245,15 @@ try {
|
|||||||
|
|
||||||
Stop-OldReadestDev
|
Stop-OldReadestDev
|
||||||
|
|
||||||
Write-Step "Starting Readest desktop with the latest code"
|
if ($Desktop) {
|
||||||
Write-Host "Close the Readest window or press Ctrl+C here to stop the dev server."
|
Write-Step "Starting Readest desktop with the latest code"
|
||||||
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "tauri", "dev")
|
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")
|
||||||
|
} else {
|
||||||
|
Write-Step "Starting Readest web development server"
|
||||||
|
Write-Host "Open http://localhost:3000. Saved React and CSS changes appear through Fast Refresh."
|
||||||
|
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "dev-web")
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Readest launcher failed:" -ForegroundColor Red
|
Write-Host "Readest launcher failed:" -ForegroundColor Red
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
$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 '\[switch\]\$Desktop' "Launcher must expose an explicit desktop mode."
|
||||||
|
Assert-Match 'if\s*\(\$Desktop\)[\s\S]*tauri[\s\S]*else[\s\S]*dev-web' "Launcher must default to dev-web while preserving Tauri."
|
||||||
|
Assert-Match '\$requiredCommands\s*=\s*@\("git",\s*"pnpm"\)' "Web mode must not require Cargo."
|
||||||
|
Assert-Match 'submodule\.fetchJobs=1' "Launcher must initialize missing submodules conservatively."
|
||||||
|
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