Compare commits

..

3 Commits

Author SHA1 Message Date
akai acb7dd4c93 fix: recover local Readest launcher 2026-07-10 12:52:06 +08:00
akai da756eed47 feat: secure inline review sidecar 2026-07-10 12:37:13 +08:00
akai 48383b3db6 fix: harden inline review web workflow 2026-07-10 11:56:25 +08:00
23 changed files with 1169 additions and 115 deletions
+2
View File
@@ -15,3 +15,5 @@ export const SAMPLE_EPUB = path.join(
fixturesDir,
'../../src/__tests__/fixtures/data/sample-alice.epub',
);
export const REVIEW_EPUB = path.join(fixturesDir, 'books/readest-review-e2e.epub');
@@ -0,0 +1,31 @@
import fs from 'node:fs';
import path from 'node:path';
import { zipSync, strToU8 } from 'fflate';
const target = process.argv[2];
if (!target) throw new Error('output path is required');
const files = {
mimetype: strToU8('application/epub+zip'),
'META-INF/container.xml': strToU8(`<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`),
'OEBPS/content.opf': strToU8(`<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="id">urn:readest:review-e2e</dc:identifier>
<dc:title>Readest Review E2E</dc:title><dc:language>zh-CN</dc:language>
</metadata>
<manifest><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine><itemref idref="chapter"/></spine>
</package>`),
'OEBPS/chapter.xhtml': strToU8(`<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter</title></head><body>
<p class="sourceText">これは校正テスト用の原文です。</p>
<p>这是网页审校测试译文。</p>
</body></html>`),
};
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, zipSync(files, { level: 0 }));
@@ -0,0 +1,60 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test } from '../fixtures/base';
import { REVIEW_EPUB } from '../fixtures/books';
const fixtureDir = path.dirname(fileURLToPath(import.meta.url));
test.beforeAll(() => {
execFileSync(
process.execPath,
[path.join(fixtureDir, '../fixtures/create-review-epub.mjs'), REVIEW_EPUB],
{ stdio: 'inherit' },
);
});
test.afterAll(() => {
fs.rmSync(REVIEW_EPUB, { force: true });
});
test.describe('Inline review mode', () => {
test('opens the local sidecar, saves an edit, and resumes the same session', async ({
openBook,
page,
}) => {
test.skip(process.env['READEST_REVIEW_E2E'] !== '1', 'requires the local Python sidecar');
const reader = await openBook(REVIEW_EPUB);
await reader.revealHeader();
const toggle = page.getByRole('button', { name: 'Review Mode' });
await expect(toggle).toBeVisible();
await toggle.click();
const panel = page.getByRole('group', { name: '审校' });
await expect(panel).toBeVisible({ timeout: 120_000 });
const editor = panel.locator('textarea').first();
await expect(editor).toHaveValue('这是网页审校测试译文。');
await editor.fill('这是已保存的网页审校测试译文。');
await panel.getByRole('button', { name: '保存', exact: true }).click();
await expect(panel.getByRole('status')).toContainText('已保存');
await panel.getByRole('button', { name: 'Close' }).click();
await expect(panel).toBeHidden();
await reader.revealHeader();
await page.getByRole('button', { name: 'Disable Review Mode' }).click();
await reader.revealHeader();
await page.getByRole('button', { name: 'Review Mode' }).click();
await expect(panel).toBeVisible({ timeout: 120_000 });
await expect(panel.locator('textarea').first()).toHaveValue(
'这是已保存的网页审校测试译文。',
);
const downloadPromise = page.waitForEvent('download');
await panel.getByRole('button', { name: '导出 EPUB' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.epub$/i);
});
});
+1 -1
View File
@@ -112,9 +112,9 @@ sentry = { version = "0.42", default-features = false, features = [
"rustls",
] }
tauri-plugin-sentry = "0.5"
rand = "0.8"
[target."cfg(target_os = \"macos\")".dependencies]
rand = "0.8"
cocoa = "0.25"
objc = "0.2.7"
objc-foundation = "0.1.1"
+95 -13
View File
@@ -1,8 +1,10 @@
use rand::RngCore;
use serde::Serialize;
use serde_json::Value;
use std::sync::Mutex;
use std::{
fs,
fs::OpenOptions,
io::{Read, Write},
net::{SocketAddr, TcpStream},
path::{Path, PathBuf},
@@ -24,6 +26,7 @@ pub struct ReviewEditorLaunchResponse {
review_root: String,
version: String,
session_id: Option<String>,
access_token: String,
}
struct CommandOutput {
@@ -76,10 +79,12 @@ fn launch_epub_review_editor_sync(
review_root.to_string_lossy()
)
})?;
let access_token = ensure_access_token(&review_root)?;
let expected_version = read_expected_version(&tool_root);
if let Some(url) = find_running_editor(expected_version.as_deref(), &review_root) {
let session_id = ensure_session_from_path(&url, epub_path.as_deref())?;
if let Some(url) = find_running_editor(expected_version.as_deref(), &review_root, &access_token)
{
let session_id = ensure_session_from_path(&url, epub_path.as_deref(), &access_token)?;
return Ok(ReviewEditorLaunchResponse {
ok: true,
url,
@@ -87,6 +92,7 @@ fn launch_epub_review_editor_sync(
review_root: review_root.to_string_lossy().to_string(),
version: expected_version.unwrap_or_default(),
session_id,
access_token,
});
}
@@ -111,13 +117,15 @@ fn launch_epub_review_editor_sync(
"127.0.0.1".to_string(),
"--port".to_string(),
DEFAULT_PORT.to_string(),
"--access-token".to_string(),
access_token.clone(),
"--daemon".to_string(),
],
Some(&tool_root),
)?;
let launched_url = extract_url(&output.stdout)
.or_else(|| find_running_editor(expected_version.as_deref(), &review_root))
.or_else(|| find_running_editor(expected_version.as_deref(), &review_root, &access_token))
.ok_or_else(|| {
format!(
"审校器已启动但没有返回访问地址。\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 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: true,
@@ -135,9 +143,57 @@ fn launch_epub_review_editor_sync(
review_root: review_root.to_string_lossy().to_string(),
version: expected_version.unwrap_or_default(),
session_id,
access_token,
})
}
fn ensure_access_token(review_root: &Path) -> Result<String, String> {
let token_path = review_root.join(".access-token");
if let Ok(existing) = fs::read_to_string(&token_path) {
let token = existing.trim();
if !token.is_empty() {
return Ok(token.to_string());
}
}
let mut bytes = [0_u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
let token = bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let create_result = options
.open(&token_path)
.and_then(|mut file| file.write_all(token.as_bytes()));
match create_result {
Ok(()) => Ok(token),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
fs::read_to_string(&token_path)
.map(|value| value.trim().to_string())
.map_err(|read_err| {
format!("Failed to read review sidecar access token: {read_err}")
})
.and_then(|value| {
if value.is_empty() {
Err("Review sidecar access token file is empty.".to_string())
} else {
Ok(value)
}
})
}
Err(err) => Err(format!(
"Failed to create review sidecar access token: {err}"
)),
}
}
fn resolve_tool_root(app: &AppHandle) -> Result<PathBuf, String> {
if let Ok(raw) = std::env::var("READEST_REVIEW_EDITOR_TOOL_ROOT") {
let path = PathBuf::from(raw);
@@ -292,12 +348,16 @@ fn read_expected_version(tool_root: &Path) -> Option<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
.canonicalize()
.unwrap_or_else(|_| review_root.to_path_buf());
for port in DEFAULT_PORT..(DEFAULT_PORT + PORT_SCAN_LIMIT) {
let Some(payload) = request_json(port, "/api/version") else {
let Some(payload) = request_json(port, "/api/version", access_token) else {
continue;
};
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
}
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 mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(250)).ok()?;
let _ = stream.set_read_timeout(Some(Duration::from_millis(800)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(800)));
let request = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\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()?;
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 addr = SocketAddr::from(([127, 0, 0, 1], port));
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_write_timeout(Some(Duration::from_secs(5)));
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
);
@@ -392,12 +454,16 @@ fn parse_loopback_port(url: &str) -> Result<u16, String> {
.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 {
return Ok(None);
};
let body = serde_json::json!({ "epub_path": raw_path, "activate": false }).to_string();
let payload = post_json(url, "/api/session/from-path", &body)?;
let payload = post_json(url, "/api/session/from-path", &body, access_token)?;
Ok(payload
.get("id")
.and_then(Value::as_str)
@@ -416,13 +482,29 @@ fn run_command(
Err(format!(
"命令执行失败:{} {}\nstdout:\n{}\nstderr:\n{}",
program,
args.join(" "),
redact_command_args(args).join(" "),
output.stdout,
output.stderr
))
}
}
fn redact_command_args(args: &[String]) -> Vec<String> {
let mut redact_next = false;
args.iter()
.map(|arg| {
if redact_next {
redact_next = false;
return "[REDACTED]".to_string();
}
if arg == "--access-token" {
redact_next = true;
}
arg.clone()
})
.collect()
}
fn run_command_allow_failure(
program: &str,
args: &[String],
@@ -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 type { BookDoc } from '@/libs/document';
@@ -144,4 +148,29 @@ describe('ReviewModeController marks', () => {
expect(target.classList.contains('readest-review-active')).toBe(false);
expect(doc.querySelectorAll('p')[3]?.classList.contains('readest-review-active')).toBe(true);
});
test('clears the target paragraph when a saved translation is empty', () => {
const doc = document.implementation.createHTMLDocument('chapter');
doc.body.innerHTML = '<p>原文</p><p>旧译文</p>';
__testing.applyReviewMarks(
doc,
'chapter.xhtml',
[
{
id: 'R00001',
file: 'chapter.xhtml',
ja_p_index: 0,
cn_p_index: 1,
jp_html: '原文',
jp_text: '原文',
cn_html: '旧译文',
current_html: '',
},
],
'R00001',
);
expect(doc.querySelectorAll('p')[1]?.innerHTML).toBe('');
});
});
@@ -0,0 +1,71 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const h = vi.hoisted(() => ({
ask: vi.fn(),
setBookEnabled: vi.fn(),
reviewStore: {
books: {
'book-a': { enabled: true, loading: false, hasUnsavedEdit: true },
},
setActiveBookKey: vi.fn(),
setPanelVisible: vi.fn(),
setBookLoading: vi.fn(),
setBookError: vi.fn(),
setBookEnabled: vi.fn(),
setBookData: vi.fn(),
},
}));
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: { isDesktopApp: true, isMobile: false, ask: h.ask } }),
}));
vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (value: string) => value }));
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: (selector: (state: { getBookData: () => unknown }) => unknown) =>
selector({ getBookData: () => ({ book: { format: 'EPUB' } }) }),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({ setHoveredBookKey: vi.fn() }),
}));
vi.mock('@/store/reviewModeStore', () => {
const useReviewModeStore = (selector?: (state: typeof h.reviewStore) => unknown) =>
selector ? selector(h.reviewStore) : h.reviewStore;
Object.assign(useReviewModeStore, { getState: () => h.reviewStore });
return { useReviewModeStore };
});
vi.mock('@/services/reviewEditorService', () => ({
canLaunchInlineReviewEditor: () => true,
launchInlineReviewEditor: vi.fn(),
loadInlineReviewData: vi.fn(),
}));
vi.mock('@/utils/event', () => ({ eventDispatcher: { dispatch: vi.fn() } }));
import ReviewModeToggler from '@/app/reader/components/ReviewModeToggler';
describe('ReviewModeToggler unsaved edit guard', () => {
beforeEach(() => {
cleanup();
vi.clearAllMocks();
h.reviewStore.setBookEnabled = h.setBookEnabled;
});
test('keeps review mode enabled when discarding an unsaved edit is cancelled', async () => {
h.ask.mockResolvedValue(false);
render(<ReviewModeToggler bookKey='book-a' />);
fireEvent.click(screen.getByRole('button', { name: 'Disable Review Mode' }));
await waitFor(() => expect(h.ask).toHaveBeenCalledOnce());
expect(h.setBookEnabled).not.toHaveBeenCalled();
});
test('disables review mode after confirming the unsaved edit warning', async () => {
h.ask.mockResolvedValue(true);
render(<ReviewModeToggler bookKey='book-a' />);
fireEvent.click(screen.getByRole('button', { name: 'Disable Review Mode' }));
await waitFor(() => expect(h.setBookEnabled).toHaveBeenCalledWith('book-a', false));
});
});
@@ -1,9 +1,14 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { ReviewRow } from '@/services/reviewEditorService';
import {
canLaunchInlineReviewEditor,
createReviewSessionFromPath,
downloadReviewedEpub,
exportReviewedEpub,
generateReviewFeedback,
isReviewEditDirty,
launchInlineReviewEditor,
loadInlineReviewData,
loadReviewGlossary,
saveReviewGptConfig,
@@ -38,14 +43,174 @@ const firstFetchUrl = (fetchMock: ReturnType<typeof vi.fn>) => {
};
describe('reviewEditorService', () => {
test('allows the inline review entry on desktop and local dev-web, but not hosted web', () => {
expect(canLaunchInlineReviewEditor({ isDesktopApp: true }, false)).toBe(true);
expect(canLaunchInlineReviewEditor({ isDesktopApp: false }, true)).toBe(true);
expect(canLaunchInlineReviewEditor({ isDesktopApp: false }, false)).toBe(false);
});
test('rejects a launch response when the Next route returns an HTTP error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
Response.json(
{ ok: false, error: '本地 Web 审校入口未启用' },
{ status: 404 },
),
),
);
await expect(
launchInlineReviewEditor(
{
resolveNativeBookFilePath: async () => 'C:/books/demo.epub',
} as never,
{ format: 'EPUB' } as never,
false,
),
).rejects.toThrow('本地 Web 审校入口未启用');
});
test('uploads the current web book to the local sidecar before creating a session', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
const url = input.toString();
if (url === '/api/review-editor/launch') {
return Response.json({
ok: true,
url: 'http://127.0.0.1:5177',
accessToken: 'sidecar-token',
});
}
return Response.json({ id: 'session-web' });
});
vi.stubGlobal('fetch', fetchMock);
const launched = await launchInlineReviewEditor(
{
resolveNativeBookFilePath: async () => null,
loadBookContent: async () => ({
file: new File(['epub-bytes'], 'demo.epub', { type: 'application/epub+zip' }),
}),
} as never,
{ format: 'EPUB', title: 'Demo book', hash: 'book-hash' } as never,
false,
);
expect(launched.sessionId).toBe('session-web');
expect(fetchMock).toHaveBeenCalledTimes(2);
const uploadCall = fetchMock.mock.calls[1] as
| [string | URL | Request, RequestInit | undefined]
| undefined;
expect(uploadCall?.[0]).toBe('http://127.0.0.1:5177/api/upload');
expect(uploadCall?.[1]?.body).toBeInstanceOf(FormData);
expect(uploadCall?.[1]?.headers).toMatchObject({
Authorization: 'Bearer sidecar-token',
});
expect((uploadCall?.[1]?.body as FormData).get('activate')).toBe('false');
expect((uploadCall?.[1]?.body as FormData).get('book_key')).toBe('book-hash');
});
test('downloads an exported EPUB in web mode with its server filename', async () => {
const click = vi.fn();
const remove = vi.fn();
const anchor = { click, remove, href: '', download: '' };
vi.spyOn(document, 'createElement').mockReturnValue(anchor as never);
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
const fetchMock = vi.fn(async () =>
new Response(new Blob(['epub-bytes'], { type: 'application/epub+zip' })),
);
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:reviewed-epub');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
await downloadReviewedEpub(
'http://127.0.0.1:5177/downloads/export/reviewed.epub?session_id=session-a',
'sidecar-token',
);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/downloads/export/reviewed.epub'),
expect.objectContaining({
headers: { Authorization: 'Bearer sidecar-token' },
}),
);
expect(anchor.href).toBe('blob:reviewed-epub');
expect(anchor.download).toBe('reviewed.epub');
expect(click).toHaveBeenCalledOnce();
expect(remove).toHaveBeenCalledOnce();
});
test('detects unsaved row fields against the selected row baseline', () => {
const row = {
current_html: '译文',
cn_html: '译文',
marked: false,
issue_type: '',
severity: '',
tags: '',
comment: '',
learn_note: '',
} as ReviewRow;
expect(
isReviewEditDirty(row, {
current_html: '译文',
marked: false,
issue_type: '',
severity: '',
tags: '',
comment: '',
learn_note: '',
}),
).toBe(false);
expect(
isReviewEditDirty(row, {
current_html: '修改后译文',
marked: false,
issue_type: '',
severity: '',
tags: '',
comment: '',
learn_note: '',
}),
).toBe(true);
expect(
isReviewEditDirty(
{ ...row, current_html: '' },
{
current_html: '',
marked: false,
issue_type: '',
severity: '',
tags: '',
comment: '',
learn_note: '',
},
),
).toBe(false);
});
test('adds session_id to sidecar API calls without mutating endpoint paths', async () => {
const fetchMock = stubJsonFetch();
await sidecarApi('http://127.0.0.1:5177', '/api/rows', {}, 'session-a');
await sidecarApi(
'http://127.0.0.1:5177',
'/api/rows',
{},
'session-a',
'sidecar-token',
);
const url = new URL(firstFetchUrl(fetchMock));
expect(url.pathname).toBe('/api/rows');
expect(url.searchParams.get('session_id')).toBe('session-a');
const firstCall = fetchMock.mock.calls[0] as
| [string | URL | Request, RequestInit | undefined]
| undefined;
expect(firstCall?.[1]?.headers).toMatchObject({
Authorization: 'Bearer sidecar-token',
});
});
test('row operations are scoped to the current review session', async () => {
@@ -48,6 +48,19 @@ describe('reviewModeStore', () => {
expect(state.books['book-a']?.selectedRowId).toBe('R00001');
});
test('reopens the panel for the selected row without clearing its unsaved flag', () => {
useReviewModeStore.getState().setBookEnabled('book-a', true);
useReviewModeStore.getState().selectRow('book-a', 'R00001');
useReviewModeStore.getState().setBookUnsavedEdit('book-a', true);
useReviewModeStore.getState().setPanelVisible(false);
useReviewModeStore.getState().selectRow('book-a', 'R00001');
const state = useReviewModeStore.getState();
expect(state.isPanelVisible).toBe(true);
expect(state.books['book-a']?.hasUnsavedEdit).toBe(true);
});
test('updates one review row after save', () => {
useReviewModeStore.getState().setBookData('book-a', {
rows: [
@@ -1,4 +1,5 @@
import { spawnSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
@@ -27,6 +28,7 @@ type LaunchResponse = {
reused?: boolean;
reviewRoot?: string;
version?: string;
accessToken?: string;
error?: string;
};
@@ -39,6 +41,28 @@ const appRoot = () => process.cwd();
const toolRoot = () => path.join(appRoot(), 'tools', 'epub-review-editor');
const reviewRoot = () =>
path.resolve(process.env['READEST_REVIEW_ROOT'] || path.join(appRoot(), 'epub_review_sessions'));
const accessTokenPath = () => path.join(reviewRoot(), '.access-token');
const ensureAccessToken = () => {
fs.mkdirSync(reviewRoot(), { recursive: true });
try {
const existing = fs.readFileSync(accessTokenPath(), 'utf8').trim();
if (existing) return existing;
} catch {
// Create the token below.
}
const token = crypto.randomBytes(32).toString('hex');
try {
fs.writeFileSync(accessTokenPath(), token, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
return token;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
const existing = fs.readFileSync(accessTokenPath(), 'utf8').trim();
if (existing) return existing;
}
throw error;
}
};
const venvRoot = () => path.join(toolRoot(), '.venv');
const venvPython = () =>
@@ -91,7 +115,11 @@ const isLoopbackHost = (host: string | null) => {
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) => {
const req = http.get(
{
@@ -99,6 +127,7 @@ const requestJson = (port: number, pathname: string): Promise<Record<string, unk
port,
path: pathname,
timeout: 800,
headers: { Authorization: `Bearer ${accessToken}` },
},
(res) => {
let body = '';
@@ -126,12 +155,18 @@ const requestJson = (port: number, pathname: string): Promise<Record<string, unk
req.on('error', () => resolve(null));
});
const findRunningEditor = async () => {
const findRunningEditor = async (accessToken: string) => {
const expectedVersion = readExpectedVersion();
const expectedReviewRoot = path.resolve(reviewRoot());
for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SCAN_LIMIT; port++) {
const payload = await requestJson(port, '/api/version');
const payload = await requestJson(port, '/api/version', accessToken);
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}`;
}
}
@@ -237,7 +272,8 @@ async function launchEditor(): Promise<LaunchResponse> {
};
}
const runningUrl = await findRunningEditor();
const accessToken = ensureAccessToken();
const runningUrl = await findRunningEditor(accessToken);
if (runningUrl) {
return {
ok: true,
@@ -245,6 +281,7 @@ async function launchEditor(): Promise<LaunchResponse> {
reused: true,
reviewRoot: reviewRoot(),
version: readExpectedVersion(),
accessToken,
};
}
@@ -263,6 +300,8 @@ async function launchEditor(): Promise<LaunchResponse> {
'127.0.0.1',
'--port',
String(DEFAULT_PORT),
'--access-token',
accessToken,
'--daemon',
],
{ cwd: bundledToolRoot, timeout: 45_000 },
@@ -272,7 +311,7 @@ async function launchEditor(): Promise<LaunchResponse> {
}
const launchedUrl = normalizeLoopbackUrl(
/https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor()),
/https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor(accessToken)),
);
if (!launchedUrl) {
throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`);
@@ -284,6 +323,7 @@ async function launchEditor(): Promise<LaunchResponse> {
reused: false,
reviewRoot: reviewRoot(),
version: readExpectedVersion(),
accessToken,
};
} catch (error) {
return {
@@ -212,7 +212,17 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
navigateBackToLibrary();
};
const handleCloseReaderToLibrary = () => {
const handleCloseReaderToLibrary = async () => {
const dirtyReview = Object.values(useReviewModeStore.getState().books).some(
(book) => book.hasUnsavedEdit,
);
if (
dirtyReview &&
appService &&
!(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?'))
) {
return;
}
return handleCloseBooks(true);
};
@@ -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:
// TTS may continue headless. Non-main Tauri windows close their webview
// below, but their per-window TTS dies with the window either way.
const dirtyReview = Object.values(useReviewModeStore.getState().books).some(
(book) => book.hasUnsavedEdit,
);
if (
dirtyReview &&
appService &&
!(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?'))
) {
return;
}
handleCloseBooks(true);
if (isTauriAppPlatform()) {
const currentWindow = getCurrentWindow();
@@ -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.
// The Tauri reader-window branches below destroy their webview, which
// takes the per-window TTS with it either way.
const review = useReviewModeStore.getState().books[bookKey];
if (
review?.hasUnsavedEdit &&
appService &&
!(await appService.ask('当前段落有未保存的修改,确定关闭这本书吗?'))
) {
return;
}
saveConfigAndCloseBook(bookKey, true);
if (sideBarBookKey === bookKey) {
setSideBarBookKey(getNextBookKey(sideBarBookKey));
@@ -1,6 +1,7 @@
import DOMPurify from 'dompurify';
import { useEffect, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import type { BookDoc } from '@/libs/document';
import type { ReviewRow } from '@/services/reviewEditorService';
import { useReaderStore } from '@/store/readerStore';
@@ -147,7 +148,7 @@ const markParagraph = (
htmlElement.setAttribute(ROLE_ATTR, role);
htmlElement.classList.add(role === 'source' ? 'readest-review-source' : 'readest-review-target');
htmlElement.classList.toggle('readest-review-active', active);
if (role === 'target' && row.current_html) {
if (role === 'target') {
if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) {
htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML);
}
@@ -194,6 +195,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
bookKey,
bookDoc,
}) => {
const { appService } = useEnv();
const view = useReaderStore((state) => state.viewStates[bookKey]?.view);
const enabled = useReviewModeStore((state) => !!state.books[bookKey]?.enabled);
const rows = useReviewModeStore((state) => state.books[bookKey]?.rows ?? emptyReviewRows);
@@ -205,6 +207,24 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
selectedRowIdRef.current = selectedRowId;
}, [selectedRowId]);
const selectReviewRow = async (rowId: string) => {
const state = useReviewModeStore.getState();
const previousBookKey = state.activeBookKey;
const current = state.books[previousBookKey || ''];
if (
current?.hasUnsavedEdit &&
(state.activeBookKey !== bookKey || current.selectedRowId !== rowId) &&
appService &&
!(await appService.ask('当前段落有未保存的修改,确定切换到其他段落吗?'))
) {
return;
}
if (previousBookKey && current?.hasUnsavedEdit) {
state.setBookUnsavedEdit(previousBookKey, false);
}
selectRow(bookKey, rowId);
};
useEffect(() => {
if (!view) return;
@@ -235,7 +255,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
if ('stopImmediatePropagation' in event) {
event.stopImmediatePropagation();
}
selectRow(bookKey, rowId);
void selectReviewRow(rowId);
};
const handleSelectionEnd = (event: Event) => {
@@ -244,7 +264,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
window.setTimeout(() => {
const target = selectedReviewTarget(doc);
const rowId = target?.getAttribute(ROW_ATTR);
if (rowId) selectRow(bookKey, rowId);
if (rowId) void selectReviewRow(rowId);
}, 0);
};
@@ -292,7 +312,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
clearReviewMarks(doc);
}
};
}, [bookDoc, bookKey, enabled, rows, selectRow, view]);
}, [appService, bookDoc, bookKey, enabled, rows, selectRow, view]);
useEffect(() => {
if (!view || !enabled || !rows.length) return;
@@ -8,7 +8,11 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useReviewModeStore } from '@/store/reviewModeStore';
import { eventDispatcher } from '@/utils/event';
import { launchInlineReviewEditor, loadInlineReviewData } from '@/services/reviewEditorService';
import {
canLaunchInlineReviewEditor,
launchInlineReviewEditor,
loadInlineReviewData,
} from '@/services/reviewEditorService';
interface ReviewModeTogglerProps {
bookKey: string;
@@ -32,7 +36,7 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
const enabled = !!reviewState?.enabled;
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 () => {
if (appService?.isMobile) {
@@ -40,6 +44,13 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
}
if (enabled) {
if (
reviewState?.hasUnsavedEdit &&
appService &&
!(await appService.ask('当前段落有未保存的修改,确定关闭审校模式吗?'))
) {
return;
}
setBookEnabled(bookKey, false);
return;
}
@@ -49,15 +60,34 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
return;
}
const activeState = useReviewModeStore.getState();
const activeReview = activeState.books[activeState.activeBookKey || ''];
if (
activeState.activeBookKey !== bookKey &&
activeReview?.hasUnsavedEdit &&
appService &&
!(await appService.ask('另一册书的当前段落有未保存修改,确定切换审校书籍吗?'))
) {
return;
}
if (activeState.activeBookKey && activeReview?.hasUnsavedEdit) {
activeState.setBookUnsavedEdit(activeState.activeBookKey, false);
}
setActiveBookKey(bookKey);
setPanelVisible(true);
setBookLoading(bookKey, true);
try {
const launch = await launchInlineReviewEditor(appService, bookData.book);
const data = await loadInlineReviewData(launch.url, launch.sessionId);
const data = await loadInlineReviewData(
launch.url,
launch.sessionId,
launch.accessToken,
);
setBookData(bookKey, {
baseUrl: launch.url,
accessToken: launch.accessToken,
sessionId: launch.sessionId,
reviewRoot: launch.reviewRoot,
version: launch.version,
@@ -91,7 +121,7 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
onClick={handleToggleReviewMode}
disabled={loading}
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,13 @@ import { useReaderStore } from '@/store/readerStore';
import { useReviewModeStore } from '@/store/reviewModeStore';
import { useThemeStore } from '@/store/themeStore';
import { getPanelTopInset } from '@/utils/insets';
import { openExternalUrl } from '@/utils/open';
import { useReviewPanelDrag } from '../hooks/useReviewPanelDrag';
import { useReviewPanelFloatingResize } from '../hooks/useReviewPanelFloatingResize';
import {
downloadReviewedEpub,
exportReviewedEpub,
generateReviewFeedback,
isReviewEditDirty,
loadReviewGlossary,
retranslateReviewRow,
saveReviewGlossary,
@@ -488,6 +489,7 @@ const ReviewPanel: React.FC = () => {
const togglePanelPin = useReviewModeStore((state) => state.togglePanelPin);
const setPanelWidth = useReviewModeStore((state) => state.setPanelWidth);
const setPanelHeight = useReviewModeStore((state) => state.setPanelHeight);
const setBookUnsavedEdit = useReviewModeStore((state) => state.setBookUnsavedEdit);
const updateRow = useReviewModeStore((state) => state.updateRow);
const setBookData = useReviewModeStore((state) => state.setBookData);
const bookState = useReviewModeStore((state) =>
@@ -508,6 +510,7 @@ const ReviewPanel: React.FC = () => {
const [exportInfo, setExportInfo] = useState('');
const [saving, setSaving] = useState(false);
const [retranslating, setRetranslating] = useState(false);
const [exporting, setExporting] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const isMobile = useIsMobileViewport();
const panelTopInset = getPanelTopInset({
@@ -527,6 +530,19 @@ const ReviewPanel: React.FC = () => {
[bookState?.rows, bookState?.selectedRowId],
);
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
? getBookData(activeBookKey)?.book?.title || selectedRow?.document_title || '审校'
@@ -544,7 +560,7 @@ const ReviewPanel: React.FC = () => {
return;
}
setEdit({
current_html: selectedRow.current_html || selectedRow.cn_html || '',
current_html: selectedRow.current_html ?? selectedRow.cn_html ?? '',
marked: Boolean(selectedRow.marked),
issue_type: selectedRow.issue_type || '',
severity: selectedRow.severity || '',
@@ -574,6 +590,17 @@ const ReviewPanel: React.FC = () => {
togglePanelPin();
};
const closePanel = async () => {
if (
hasUnsavedEdit &&
appService &&
!(await appService.ask('当前段落有未保存的修改,确定关闭审校面板吗?'))
) {
return;
}
setPanelVisible(false);
};
const floatingPanelEnabled = !isPanelPinned && !isMobile;
const {
panelPosition,
@@ -666,7 +693,7 @@ const ReviewPanel: React.FC = () => {
setMessage('');
try {
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 = {
...selectedRow,
...edit,
@@ -725,6 +752,7 @@ const ReviewPanel: React.FC = () => {
const exportEpub = async () => {
if (!baseUrl) return;
setExporting(true);
setExportInfo('');
setMessage('');
try {
@@ -733,9 +761,14 @@ const ReviewPanel: React.FC = () => {
? new URL(payload.download_url, baseUrl).toString()
: '';
setExportInfo(downloadUrl || payload.output || '');
if (downloadUrl) {
await downloadReviewedEpub(downloadUrl, bookState.accessToken);
}
setMessage(`已导出:${payload.output || downloadUrl}`);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setExporting(false);
}
};
@@ -806,7 +839,7 @@ const ReviewPanel: React.FC = () => {
{isMobile && (
<Overlay
className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/50 sm:bg-black/20')}
onDismiss={() => setPanelVisible(false)}
onDismiss={() => void closePanel()}
/>
)}
<aside
@@ -893,7 +926,7 @@ const ReviewPanel: React.FC = () => {
type='button'
className='btn btn-ghost h-8 min-h-8 w-8 shrink-0 rounded-full p-0'
title={_('Close')}
onClick={() => setPanelVisible(false)}
onClick={() => void closePanel()}
>
<X className='h-4 w-4' />
</button>
@@ -907,7 +940,10 @@ const ReviewPanel: React.FC = () => {
</div>
) : null}
{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}
</div>
) : null}
@@ -916,22 +952,29 @@ const ReviewPanel: React.FC = () => {
<PanelButton
onClick={generateFeedback}
icon={<FileText className='h-4 w-4' />}
disabled={!baseUrl}
disabled={!baseUrl || hasUnsavedEdit}
>
</PanelButton>
<PanelButton
onClick={exportEpub}
icon={<Download className='h-4 w-4' />}
disabled={!baseUrl}
disabled={!baseUrl || exporting || hasUnsavedEdit}
variant='primary'
>
EPUB
{exporting ? '导出中' : '导出 EPUB'}
</PanelButton>
</div>
{hasUnsavedEdit ? (
<p className='text-base-content/70 text-xs'></p>
) : null}
{message ? (
<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'
role='status'
aria-live='polite'
>
{message}
</div>
) : null}
@@ -939,9 +982,9 @@ const ReviewPanel: React.FC = () => {
<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)}
onClick={() => void downloadReviewedEpub(exportInfo, bookState.accessToken)}
>
<span className='font-semibold'></span>
<span className='font-semibold'></span>
<span className='text-base-content/60 mt-1 block break-all text-xs'>
{exportInfo}
</span>
@@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import type { Book } from '@/types/book';
import type { AppService } from '@/types/system';
import { isTauriAppPlatform } from '@/services/environment';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
export type ReviewEditorLaunchResponse = {
ok: boolean;
@@ -10,6 +10,7 @@ export type ReviewEditorLaunchResponse = {
reused?: boolean;
reviewRoot?: string;
version?: string;
accessToken?: string;
sessionId?: string | null;
error?: string;
};
@@ -97,6 +98,15 @@ export type ReviewEditPayload = {
learn_note: string;
};
export const isReviewEditDirty = (row: ReviewRow, edit: ReviewEditPayload) =>
edit.current_html !== (row.current_html ?? row.cn_html ?? '') ||
edit.marked !== Boolean(row.marked) ||
edit.issue_type !== (row.issue_type || '') ||
edit.severity !== (row.severity || '') ||
edit.tags !== (row.tags || '') ||
edit.comment !== (row.comment || '') ||
edit.learn_note !== (row.learn_note || '');
export type ReviewSaveRowPayload = {
status?: string;
row_id?: string;
@@ -134,23 +144,35 @@ const ensureBaseUrl = (baseUrl: string) => {
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
};
const sidecarAccessTokens = new Map<string, string>();
const rememberSidecarAccessToken = (baseUrl: string, accessToken: string) => {
sidecarAccessTokens.set(ensureBaseUrl(baseUrl), accessToken);
};
export const canLaunchInlineReviewEditor = (
appService: Pick<AppService, 'isDesktopApp'> | null | undefined,
localWebDevelopment =
process.env['NODE_ENV'] === 'development' && isWebAppPlatform(),
) => Boolean(appService?.isDesktopApp || localWebDevelopment);
export async function sidecarApi<T>(
baseUrl: string,
path: string,
options: RequestInit = {},
sessionId?: string | null,
accessToken?: string,
): Promise<T> {
const url = new URL(path, ensureBaseUrl(baseUrl));
if (sessionId) {
url.searchParams.set('session_id', sessionId);
}
const headers =
options.body === undefined
? options.headers
: {
'Content-Type': 'application/json',
...(options.headers || {}),
};
const resolvedAccessToken = accessToken || sidecarAccessTokens.get(ensureBaseUrl(baseUrl));
const headers = {
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(resolvedAccessToken ? { Authorization: `Bearer ${resolvedAccessToken}` } : {}),
...(options.headers || {}),
};
const response = await fetch(url.toString(), {
...options,
headers,
@@ -166,31 +188,62 @@ export async function createReviewSessionFromPath(
baseUrl: string,
epubPath: string,
options: ReviewSessionFromPathOptions = {},
accessToken?: string,
): 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',
body: JSON.stringify({
epub_path: epubPath,
activate: options.activate ?? false,
reset: options.reset ?? false,
}),
body: formData,
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(String(data.error || `HTTP ${response.status}`));
}
return data as ReviewSessionSummary;
}
export async function launchInlineReviewEditor(
appService: AppService | null | undefined,
book: Book,
): 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 (book.format !== 'EPUB') throw new Error('审校模式目前只支持 EPUB 书籍');
const epubPath = await appService.resolveNativeBookFilePath(book);
if (!epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径');
const epubPath = tauriPlatform ? await appService.resolveNativeBookFilePath(book) : null;
if (tauriPlatform && !epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径');
const launch = isTauriAppPlatform()
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', {
epubPath,
})
const launch = tauriPlatform
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', { epubPath })
: await fetch('/api/review-editor/launch', {
method: 'POST',
headers: {
@@ -201,25 +254,30 @@ export async function launchInlineReviewEditor(
if (!launch.ok || !launch.url) {
throw new Error(launch.error || '审校器启动失败');
}
if (!launch.accessToken) throw new Error('审校器启动响应缺少访问令牌');
rememberSidecarAccessToken(launch.url, launch.accessToken);
let sessionId = launch.sessionId || null;
if (!sessionId) {
const createdSession = 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;
}
return { ...launch, url: launch.url, sessionId };
return { ...launch, url: launch.url, sessionId, accessToken: launch.accessToken };
}
export async function loadInlineReviewData(
baseUrl: string,
sessionId: string | null | undefined,
accessToken?: string,
): Promise<ReviewBootstrapPayload> {
if (!sessionId) throw new Error('审校器没有返回当前书籍会话');
const [session, rowsPayload, gptConfig] = await Promise.all([
sidecarApi<ReviewSessionPayload>(baseUrl, '/api/session', {}, sessionId),
sidecarApi<ReviewRowsPayload>(baseUrl, '/api/rows', {}, sessionId),
sidecarApi<ReviewGptConfig>(baseUrl, '/api/gpt/config', {}, sessionId),
sidecarApi<ReviewSessionPayload>(baseUrl, '/api/session', {}, sessionId, accessToken),
sidecarApi<ReviewRowsPayload>(baseUrl, '/api/rows', {}, sessionId, accessToken),
sidecarApi<ReviewGptConfig>(baseUrl, '/api/gpt/config', {}, sessionId, accessToken),
]);
return {
session,
@@ -354,3 +412,25 @@ export async function exportReviewedEpub(
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);
}
+23 -6
View File
@@ -12,6 +12,7 @@ export type ReviewBookState = {
loading: boolean;
error: string;
baseUrl: string;
accessToken: string;
sessionId: string | null;
reviewRoot?: string;
version?: string;
@@ -19,6 +20,7 @@ export type ReviewBookState = {
session: ReviewSessionPayload | null;
gptConfig: ReviewGptConfig | null;
selectedRowId: string;
hasUnsavedEdit: boolean;
};
const DEFAULT_PANEL_WIDTH = '32%';
@@ -49,6 +51,7 @@ type ReviewModeStore = {
data: Partial<Omit<ReviewBookState, 'enabled' | 'loading' | 'error'>>,
) => void;
selectRow: (bookKey: string, rowId: string) => void;
setBookUnsavedEdit: (bookKey: string, hasUnsavedEdit: boolean) => void;
updateRow: (bookKey: string, row: ReviewRow) => void;
clearBook: (bookKey: string) => void;
};
@@ -58,11 +61,13 @@ const emptyBookState = (): ReviewBookState => ({
loading: false,
error: '',
baseUrl: '',
accessToken: '',
sessionId: null,
rows: [],
session: null,
gptConfig: null,
selectedRowId: '',
hasUnsavedEdit: false,
});
const formatSizedValue = (value: number, unit: '%' | 'vh') =>
@@ -153,7 +158,10 @@ export const useReviewModeStore = create<ReviewModeStore>()(
: state.activeBookKey === bookKey
? false
: state.isPanelVisible,
books: withBookState(state.books, bookKey, { enabled }),
books: withBookState(state.books, bookKey, {
enabled,
...(!enabled ? { hasUnsavedEdit: false } : {}),
}),
})),
setBookData: (bookKey, data) =>
set((state) => ({
@@ -164,12 +172,21 @@ export const useReviewModeStore = create<ReviewModeStore>()(
}),
})),
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) => ({
activeBookKey: bookKey,
isPanelVisible: true,
books: withBookState(state.books, bookKey, {
selectedRowId: rowId,
}),
books: withBookState(state.books, bookKey, { hasUnsavedEdit }),
})),
updateRow: (bookKey, row) =>
set((state) => {
@@ -37,6 +37,8 @@ Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧
所有审校 API 调用必须携带当前书的 `session_id`,不能依赖 sidecar 的全局 active session。多窗口或多书同时审校时,不得发生串读、串写。
Next/Tauri 启动器在 review root 的 `.access-token` 中生成并复用随机令牌,启动 sidecar 时通过 `--access-token` 传入。所有 API、资源和下载请求(包括 `/api/version` 健康探测)必须携带 `Authorization: Bearer <accessToken>`;响应、日志、反馈和导出文件不得包含该令牌。CORS 预检不要求令牌,但只允许受信任的本机 origin,并显式允许 `Authorization` 请求头。
典型 session
```text
@@ -68,6 +70,7 @@ Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧
- `GET /api/version`
- `POST /api/session/from-path`
- `POST /api/upload`(本地 `dev-web` 使用 multipart 上传,内嵌调用必须传 `activate=false` 与稳定 `book_key`
- `GET /api/session?session_id=...`
- `GET /api/rows?session_id=...`
- `POST /api/row/<row_id>?session_id=...`
@@ -80,7 +83,9 @@ Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧
中文 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 只能监听本机 loopbackCLI 必须拒绝 `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 +110,7 @@ sidecar 只能监听本机 loopback。Readest/Tauri 本机来源可获得受限
- MINOR:向后兼容的能力或 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。
## 回归清单
@@ -114,8 +119,9 @@ sidecar 只能监听本机 loopback。Readest/Tauri 本机来源可获得受限
```powershell
python -B -m py_compile tools/epub-review-editor/server.py tools/epub-review-editor/version.py
python tools/epub-review-editor/test_inline_review_session_scope.py
pnpm exec vitest run src/__tests__/services/reviewEditorService.test.ts src/__tests__/store/review-mode-store.test.ts src/__tests__/components/ReviewModeController.test.ts
pnpm exec vitest run src/__tests__/services/reviewEditorService.test.ts src/__tests__/store/review-mode-store.test.ts src/__tests__/components/ReviewModeController.test.ts src/__tests__/components/ReviewModeToggler.test.tsx
pnpm exec tsgo --noEmit --pretty false
$env:READEST_REVIEW_E2E='1'; pnpm exec playwright test e2e/tests/review-mode.spec.ts --project=chromium
```
涉及 Tauri 配置或 Rust 启动器时继续执行:
@@ -1,6 +1,6 @@
# Readest 内嵌 EPUB 审校后端
当前版本:`1.0.0`
当前版本:`2.0.0`
本目录只为 Readest 阅读页内的审校模式提供本地 sidecar API。打开 EPUB 后,点击阅读页顶栏的“校”按钮即可启动或复用服务,并在原阅读界面中选择中日文段落进行审校。
@@ -29,7 +29,11 @@ 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 +68,13 @@ pnpm --filter @readest/readest-app dev-web
API Key 只写入本地运行配置,接口不得回传密钥,也不得写入 EPUB、反馈或导出文件。
sidecar 的所有 API 和下载请求都必须携带启动器返回的访问令牌,`/api/version` 健康探测也不例外。除创建 session 的 `/api/upload``/api/session/from-path` 等入口外,书籍审校接口缺少显式 `session_id` 时直接返回 `400`,不再回退全局 active session。
## 主要 API
- `GET /api/version`
- `POST /api/session/from-path`
- `POST /api/upload`(本地 `dev-web`multipart`activate=false`,携带稳定 `book_key` 复用会话)
- `GET /api/session?session_id=...`
- `GET /api/rows?session_id=...`
- `POST /api/row/<row_id>?session_id=...`
@@ -84,3 +91,5 @@ API Key 只写入本地运行配置,接口不得回传密钥,也不得写入
- `0.16.0`:新增 Readest 阅读页内嵌审校模式。
- `0.16.1``0.16.5`:完善停靠/浮动布局、宽高调整、注音注释快捷操作和响应式工程结构。
- `1.0.0`:删除旧独立审校页面、书库入口、静态浏览器前端及独立启动链路,只保留内嵌审校所需的共享 sidecar API。
- `1.0.1`:修复本地 `dev-web` EPUB 上传与下载、会话隔离、恢复初始译文写回、空译文预览、未保存修改提醒及动态开发端口 CORS;限制 sidecar 仅监听 loopback。
- `2.0.0`:所有 sidecar API 和下载请求启用 Bearer 访问令牌;书籍审校 API 强制显式 `session_id`,移除对全局 active session 的隐式回退。
@@ -4,6 +4,7 @@ import argparse
import copy
import datetime as dt
import hashlib
import hmac
import html
import json
import os
@@ -35,7 +36,7 @@ except ImportError:
"MINOR": "新增向后兼容能力、API 字段或可选配置",
"MAJOR": "破坏兼容的 API、配置或行为变更",
}
version = "1.0.0"
version = "2.0.0"
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
@@ -139,7 +140,21 @@ def find_free_port(start: int, host: str = "127.0.0.1") -> int:
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:
@@ -1444,14 +1459,20 @@ def validate_epub_file(path: Path) -> None:
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)
if not filename.lower().endswith(".epub"):
filename = f"{filename}.epub"
upload_dir = review_root / UPLOAD_DIR_NAME
upload_dir.mkdir(parents=True, exist_ok=True)
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:
file_storage.save(target)
validate_epub_file(target)
@@ -2636,9 +2657,11 @@ def write_feedback_summary(session_root: Path) -> Path:
def apply_edits_to_xhtml(session_root: Path) -> list[str]:
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]]] = {}
for row in edited_rows:
for row in saved_rows:
by_file.setdefault(row["file"], []).append(row)
modified = []
for entry_name, items in by_file.items():
@@ -3227,7 +3250,14 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
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)
review_root.mkdir(parents=True, exist_ok=True)
translation_jobs_root(review_root).mkdir(parents=True, exist_ok=True)
@@ -3237,6 +3267,47 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
app.config["EPUB_PATH"] = None
app.config["LOCK"] = threading.Lock()
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/glossary",
"/api/glossary/entry",
"/api/glossary/search",
"/api/apply",
"/api/export",
"/api/feedback",
}
return (
path in exact_paths
or path.startswith("/api/row/")
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:
app.config["SESSION_ROOT"] = session_root
@@ -3267,7 +3338,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
def session_from_request() -> tuple[Path | None, Path | None]:
session_id = str(request.args.get("session_id") or "").strip()
if not session_id:
return active_session()
return None, None
try:
session_root = session_root_from_id(review_root, session_id)
except ValueError:
@@ -3296,20 +3367,11 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
def add_local_embedding_headers(response):
# The Readest clients call the loopback API directly. Keep CORS limited
# 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", "")
if origin in allowed_origins:
if is_allowed_local_origin(origin):
response.headers["Access-Control-Allow-Origin"] = origin
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["Vary"] = "Origin"
response.headers.setdefault("Cross-Origin-Embedder-Policy", "require-corp")
@@ -3397,15 +3459,23 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
if file_storage is None or not file_storage.filename:
return jsonify({"error": "请选择一个 .epub 文件"}), 400
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:
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)
with app.config["LOCK"]:
if reset_upload and session_root.exists():
shutil.rmtree(session_root)
ensure_state(session_root, epub_path, reset=reset_upload)
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["has_session"] = True
return jsonify(summary)
@@ -3998,13 +4068,17 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
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
while time.time() < deadline:
if proc.poll() is not None:
return False
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:
return True
except OSError:
@@ -4043,6 +4117,8 @@ def run_daemon(args: argparse.Namespace) -> int:
args.host,
"--port",
str(port),
"--access-token",
args.access_token,
]
if epub_path is not None:
cmd.insert(2, str(epub_path))
@@ -4064,7 +4140,7 @@ def run_daemon(args: argparse.Namespace) -> int:
**popen_kwargs,
)
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)
return 1
print(url)
@@ -4080,8 +4156,9 @@ def build_parser() -> argparse.ArgumentParser:
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("--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("--access-token", required=True, help=argparse.SUPPRESS)
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.")
return parser
@@ -4089,6 +4166,9 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: list[str] | None = None) -> int:
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
if epub_path is not None:
if not epub_path.exists():
@@ -4103,7 +4183,7 @@ def main(argv: list[str] | None = None) -> int:
return run_daemon(args)
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)
url = f"http://{display_host(args.host)}:{port}"
def on_sigterm(_signum: int, _frame: Any) -> None:
@@ -1,4 +1,5 @@
import importlib.util
import io
import json
import tempfile
import unittest
@@ -18,9 +19,77 @@ def write_json(path: Path, data):
class InlineReviewSessionScopeTest(unittest.TestCase):
ACCESS_TOKEN = "test-sidecar-token"
def make_app(self, review_root: Path, **kwargs):
return server.create_app(review_root, access_token=self.ACCESS_TOKEN, **kwargs)
def client(self, app):
client = app.test_client()
client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {self.ACCESS_TOKEN}"
return client
def test_cli_rejects_non_loopback_host(self):
self.assertNotEqual(
server.main(["--host", "0.0.0.0", "--access-token", self.ACCESS_TOKEN]), 0
)
def test_local_dev_web_origin_on_dynamic_port_receives_cors_headers(self):
with tempfile.TemporaryDirectory() as temp_dir:
app = self.make_app(Path(temp_dir))
response = self.client(app).get(
"/api/version", headers={"Origin": "http://127.0.0.1:3017"}
)
self.assertEqual(
response.headers.get("Access-Control-Allow-Origin"),
"http://127.0.0.1:3017",
)
self.assertIn(
"Authorization", response.headers.get("Access-Control-Allow-Headers", "")
)
def test_sidecar_rejects_missing_or_incorrect_access_token(self):
with tempfile.TemporaryDirectory() as temp_dir:
app = self.make_app(Path(temp_dir))
client = app.test_client()
missing = client.get("/api/version")
incorrect = client.get(
"/api/version", headers={"Authorization": "Bearer incorrect"}
)
authorized = client.get(
"/api/version",
headers={"Authorization": f"Bearer {self.ACCESS_TOKEN}"},
)
self.assertEqual(missing.status_code, 401)
self.assertEqual(incorrect.status_code, 401)
self.assertEqual(authorized.status_code, 200)
self.assertNotIn(self.ACCESS_TOKEN, authorized.get_data(as_text=True))
def test_cors_preflight_does_not_require_access_token(self):
with tempfile.TemporaryDirectory() as temp_dir:
app = self.make_app(Path(temp_dir))
response = app.test_client().options(
"/api/rows?session_id=session-a",
headers={
"Origin": "http://localhost:3017",
"Access-Control-Request-Headers": "authorization,content-type",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.headers.get("Access-Control-Allow-Origin"),
"http://localhost:3017",
)
def test_sidecar_has_no_legacy_standalone_page(self):
with tempfile.TemporaryDirectory() as temp_dir:
app = server.create_app(Path(temp_dir))
app = self.make_app(Path(temp_dir))
response = app.test_client().get("/")
@@ -39,6 +108,121 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
'<span class="review-annotation">(注:注释)</span>',
)
def test_saved_row_restores_initial_html_after_previous_apply(self):
with tempfile.TemporaryDirectory() as temp_dir:
session_root = Path(temp_dir)
review_state = session_root / "review_state"
extracted = session_root / "extracted" / "Text"
review_state.mkdir(parents=True)
extracted.mkdir(parents=True)
row = {
"id": "R00001",
"file": "Text/chapter.xhtml",
"file_label": "chapter.xhtml",
"ja_p_index": 0,
"cn_p_index": 1,
"jp_html": "原文",
"jp_text": "原文",
"cn_html": "初始译文",
"cn_text": "初始译文",
}
write_json(review_state / "rows.json", [row])
write_json(
server.rows_meta_path(session_root),
{"parser_version": server.ROWS_PARSER_VERSION, "row_count": 1},
)
write_json(
review_state / "state.json",
{"edits": {"R00001": {"current_html": "初始译文"}}},
)
xhtml_path = extracted / "chapter.xhtml"
xhtml_path.write_text(
'<html><body><p class="sourceText">原文</p><p>先前修改</p></body></html>',
encoding="utf-8",
)
modified = server.apply_edits_to_xhtml(session_root)
self.assertEqual(modified, ["Text/chapter.xhtml"])
self.assertIn("<p>初始译文</p>", xhtml_path.read_text(encoding="utf-8"))
self.assertEqual(
json.loads((review_state / "rows.json").read_text(encoding="utf-8")),
[row],
)
def test_upload_can_create_a_session_without_changing_the_active_session(self):
with tempfile.TemporaryDirectory() as temp_dir:
review_root = Path(temp_dir)
first_epub = review_root / "first.epub"
self.write_minimal_epub(first_epub)
first_session = self.make_session(
review_root, "first", first_epub, review_root / "glossary.json"
)
app = self.make_app(review_root=review_root, initial_epub_path=first_epub)
client = self.client(app)
response = client.post(
"/api/upload",
data={
"epub": (io.BytesIO(first_epub.read_bytes()), "uploaded.epub"),
"activate": "false",
},
content_type="multipart/form-data",
)
self.assertEqual(response.status_code, 200)
uploaded_id = response.get_json()["id"]
active_session_id = client.get("/api/sessions").get_json()["active_session_id"]
self.assertNotEqual(active_session_id, uploaded_id)
self.assertIn("first", active_session_id)
scoped_session = client.get(f"/api/session?session_id={uploaded_id}")
self.assertEqual(scoped_session.status_code, 200)
self.assertEqual(scoped_session.get_json()["id"], uploaded_id)
for endpoint in (
"/api/session",
"/api/rows",
"/api/gpt/config",
"/api/glossary",
"/api/feedback",
):
unscoped = client.get(endpoint)
self.assertEqual(unscoped.status_code, 400, endpoint)
self.assertIn("session_id", unscoped.get_data(as_text=True))
def test_inline_upload_reuses_the_book_session(self):
with tempfile.TemporaryDirectory() as temp_dir:
review_root = Path(temp_dir)
epub = review_root / "book.epub"
self.write_minimal_epub(epub)
app = self.make_app(review_root=review_root)
client = self.client(app)
def upload(filename: str):
return client.post(
"/api/upload",
data={
"epub": (io.BytesIO(epub.read_bytes()), filename),
"activate": "false",
"book_key": "stable-book-hash",
},
content_type="multipart/form-data",
)
first = upload("book.epub")
second = upload("renamed-book.epub")
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertEqual(first.get_json()["id"], second.get_json()["id"])
@staticmethod
def write_minimal_epub(path: Path) -> None:
import zipfile
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("mimetype", "application/epub+zip")
def make_session(
self,
review_root: Path,
@@ -127,8 +311,8 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
server.call_openai_compatible_chat = fake_call
try:
app = server.create_app(review_root)
response = app.test_client().post(
app = self.make_app(review_root)
response = self.client(app).post(
"/api/row/R00001/retranslate?session_id=session-a",
json={"instruction": ""},
)
@@ -157,8 +341,8 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
epub_path.write_bytes(b"not a real epub")
self.make_session(review_root, "session-a", epub_path, glossary)
app = server.create_app(review_root)
response = app.test_client().post(
app = self.make_app(review_root)
response = self.client(app).post(
"/api/gpt/config?session_id=session-a",
json={
"api_key": "session-secret",
@@ -1,4 +1,4 @@
version = "1.0.0"
version = "2.0.0"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",
+43 -3
View File
@@ -1,5 +1,5 @@
param(
[string]$Remote = "origin",
[string]$Remote = "akai-tools",
[string]$Branch = "codex/readest-inline-review-mode",
[switch]$SkipPull,
[switch]$CheckOnly,
@@ -62,14 +62,54 @@ function Stop-OldReadestDev {
ForEach-Object {
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
$portConnection = Get-NetTCPConnection -State Listen -LocalPort 3000 -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $portConnection) {
return
}
$ownerPid = $portConnection.OwningProcess
$processLineage = @()
$lineagePid = $ownerPid
while ($lineagePid -and $processLineage.Count -lt 8) {
$lineageProcess = Get-CimInstance Win32_Process -Filter "ProcessId = $lineagePid" -ErrorAction SilentlyContinue
if (-not $lineageProcess) {
break
}
$processLineage += $lineageProcess
$lineagePid = $lineageProcess.ParentProcessId
}
$isReadestDev = $processLineage | Where-Object {
$_.Name -ieq "Readest.exe" -or
($_.CommandLine -and
$_.CommandLine -match "(?i)readest" -and
$_.CommandLine -match "(?i)next(.+?)dev|start-server\.js|@readest/readest-app|tauri(.+?)dev")
}
if (-not $isReadestDev) {
$owner = $processLineage | Select-Object -First 1
$ownerDescription = if ($owner.CommandLine) { $owner.CommandLine } else { "$($owner.Name) (PID $ownerPid)" }
throw "Port 3000 is occupied by an unrelated process: $ownerDescription. Stop it or launch Readest after freeing port 3000."
}
Write-Host "Stopping old Readest/Next listener on port 3000 (PID $ownerPid)." -ForegroundColor Yellow
Stop-Process -Id $ownerPid -Force -ErrorAction SilentlyContinue
for ($attempt = 0; $attempt -lt 20; $attempt++) {
Start-Sleep -Milliseconds 250
if (-not (Get-NetTCPConnection -State Listen -LocalPort 3000 -ErrorAction SilentlyContinue)) {
return
}
}
throw "Port 3000 is still occupied after stopping the old Readest/Next process. Close the old development terminal and try again."
}
function Assert-CleanWorktree {
$status = (& git -C $RepoRoot status --porcelain)
$status = @(& git -C $RepoRoot status --porcelain --untracked-files=no --ignore-submodules=dirty)
if ($status) {
Write-Host ""
Write-Host "Local changes were found. The launcher will not pull or overwrite them:" -ForegroundColor Yellow
& git -C $RepoRoot status --short
$status | ForEach-Object { Write-Host $_ }
throw "Commit, stash, or discard local changes before launching the latest organization version."
}
}
+24
View File
@@ -0,0 +1,24 @@
$ErrorActionPreference = "Stop"
$ScriptPath = Join-Path $PSScriptRoot "open-readest-latest.ps1"
$ScriptText = Get-Content -LiteralPath $ScriptPath -Raw
function Assert-Match {
param(
[string]$Pattern,
[string]$Message
)
if ($ScriptText -notmatch $Pattern) {
throw $Message
}
}
Assert-Match '\[string\]\$Remote\s*=\s*"akai-tools"' "Launcher must pull from akai-tools by default."
Assert-Match 'status\s+--porcelain\s+--untracked-files=no\s+--ignore-submodules=dirty' "Clean check must ignore runtime logs and dirty submodule contents."
Assert-Match 'Get-NetTCPConnection[^\r\n]*-LocalPort\s+3000' "Launcher must inspect the Tauri dev port before starting."
Assert-Match 'Stop-Process[^\r\n]*-Id\s+\$ownerPid' "Launcher must stop an old Readest/Next process that owns port 3000."
Assert-Match 'Port 3000 is occupied' "Launcher must report an actionable error for an unrelated port owner."
Assert-Match 'Port 3000 is still occupied' "Launcher must wait for the previous dev server to release the port."
Write-Host "open-readest-latest launcher checks passed."