Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3b4a00ed1 |
@@ -26,6 +26,7 @@ docker/.env
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
readest-dev.log
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# EPUB review editor migration
|
||||
|
||||
Date: 2026-07-08
|
||||
|
||||
This fork includes a first-stage migration of the Chinese/Japanese EPUB review editor into Readest.
|
||||
|
||||
Current shape:
|
||||
- Bundled sidecar lives at `apps/readest-app/tools/epub-review-editor`.
|
||||
- Readest route is `/review-editor`.
|
||||
- Library menu entry is `Settings Menu -> Advanced Settings -> EPUB 审校器`.
|
||||
- Dev launcher API is `POST /api/review-editor/launch`.
|
||||
- Dev script is `pnpm epub-reviewer:dev`.
|
||||
- Default runtime data is `apps/readest-app/epub_review_sessions/`; it is ignored by git. Override with `READEST_REVIEW_ROOT`.
|
||||
- The automatic launcher is deliberately gated to local `dev-web`; packaged Tauri/static export needs a Rust command or packaged sidecar.
|
||||
|
||||
Important boundary:
|
||||
- This is not a full native rewrite yet. The migrated tool still uses the proven Flask backend and static UI from the previous long-term translation project.
|
||||
- Next/Tauri production static export will need a Tauri desktop command or packaged sidecar before this becomes a production desktop feature.
|
||||
- Do not remove existing review-editor behavior while migrating: bookshelf, upload, bilingual review, glossary editing, GPT retranslate, full-book AI translation, soft delete, duplicate translation layer prevention, ruby preservation.
|
||||
- If `/review-editor` shows `ERR_BLOCKED_BY_RESPONSE`, check both sides of cross-origin isolation: the Readest route keeps COEP `require-corp`, and the sidecar response should include local `frame-ancestors`, `Cross-Origin-Embedder-Policy: require-corp`, and `Cross-Origin-Resource-Policy: cross-origin`.
|
||||
|
||||
Next recommended steps:
|
||||
1. Move launching from the Next dev API route into a Tauri desktop command.
|
||||
2. Add per-book "Open in EPUB Reviewer" actions from Readest bookshelf/detail views.
|
||||
3. Gradually port the review UI into React after the sidecar entry is stable.
|
||||
@@ -72,6 +72,9 @@ docs/superpowers
|
||||
/public/swe-worker-*.js
|
||||
|
||||
/dist/
|
||||
/epub_review_sessions/
|
||||
/tools/epub-review-editor/.venv/
|
||||
/tools/epub-review-editor/__pycache__/
|
||||
|
||||
.context/
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -49,7 +49,7 @@ const nextConfig = {
|
||||
assetPrefix: '',
|
||||
reactStrictMode: true,
|
||||
serverExternalPackages: ['isows'],
|
||||
allowedDevOrigins: ['192.168.2.120'],
|
||||
allowedDevOrigins: ['192.168.2.120', '127.0.0.1', 'localhost'],
|
||||
webpack: (config) => {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"build": "dotenv -e .env.tauri -- next build",
|
||||
"start": "dotenv -e .env.tauri -- next start",
|
||||
"dev-web": "dotenv -e .env.web -- next dev",
|
||||
"epub-reviewer:dev": "node scripts/epub-reviewer-dev.mjs",
|
||||
"build-web": "dotenv -e .env.web -- next build",
|
||||
"start-web": "dotenv -e .env.web -- next start",
|
||||
"dev-web:vinext": "dotenv -e .env.web -- vinext dev",
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
"Imported {{count}} annotations_one": "Imported {{count}} annotation",
|
||||
"Imported {{count}} annotations_other": "Imported {{count}} annotations",
|
||||
"Recently read": "Recently read",
|
||||
"Bilingual": "Bilingual",
|
||||
"Show recently read": "Show recently read",
|
||||
"Your books will appear here": "Your books will appear here",
|
||||
"{{count}} results_one": "{{count}} result",
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"Apply": "应用",
|
||||
"Auto Mode": "自动主题",
|
||||
"Behavior": "行为",
|
||||
"Bilingual": "双语拆分",
|
||||
"Book": "书籍",
|
||||
"Bookmark": "书签",
|
||||
"Cancel": "取消",
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"Apply": "應用",
|
||||
"Auto Mode": "自動主題",
|
||||
"Behavior": "行為",
|
||||
"Bilingual": "雙語拆分",
|
||||
"Book": "書籍",
|
||||
"Bookmark": "書籤",
|
||||
"Cancel": "取消",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, '..');
|
||||
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 defaultPort = Number(process.env.READEST_REVIEW_PORT || 5177);
|
||||
const venvRoot = path.join(toolRoot, '.venv');
|
||||
const venvPython =
|
||||
process.platform === 'win32'
|
||||
? path.join(venvRoot, 'Scripts', 'python.exe')
|
||||
: path.join(venvRoot, 'bin', 'python');
|
||||
|
||||
const run = (command, args, options = {}) =>
|
||||
spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: options.timeout ?? 30_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const pythonCandidates =
|
||||
process.platform === 'win32'
|
||||
? [
|
||||
['py', ['-3']],
|
||||
['python', []],
|
||||
['python3', []],
|
||||
]
|
||||
: [
|
||||
['python3', []],
|
||||
['python', []],
|
||||
];
|
||||
|
||||
const findPython = () => {
|
||||
for (const [command, args] of pythonCandidates) {
|
||||
const result = run(command, [...args, '--version'], { timeout: 10_000 });
|
||||
if (result.status === 0) return { command, args };
|
||||
}
|
||||
throw new Error('Python 3 was not found.');
|
||||
};
|
||||
|
||||
const requestJson = (port, pathname) =>
|
||||
new Promise((resolve) => {
|
||||
const req = http.get({ host: '127.0.0.1', port, path: pathname, timeout: 800 }, (res) => {
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(res.statusCode === 200 ? JSON.parse(body) : null);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
req.on('error', () => resolve(null));
|
||||
});
|
||||
|
||||
const findRunning = async () => {
|
||||
for (let port = defaultPort; port < defaultPort + 100; port++) {
|
||||
const payload = await requestJson(port, '/api/version');
|
||||
if (payload?.version) return `http://localhost:${port}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
if (!fs.existsSync(path.join(toolRoot, 'server.py'))) {
|
||||
throw new Error(`Missing review editor server: ${path.join(toolRoot, 'server.py')}`);
|
||||
}
|
||||
|
||||
const existing = await findRunning();
|
||||
if (existing) {
|
||||
console.log(`EPUB review editor is already running: ${existing}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(venvPython)) {
|
||||
console.log('Creating review editor Python environment...');
|
||||
const python = findPython();
|
||||
const result = run(python.command, [...python.args, '-m', 'venv', venvRoot], {
|
||||
cwd: appRoot,
|
||||
timeout: 120_000,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || result.stdout || 'Failed to create virtual environment.');
|
||||
}
|
||||
}
|
||||
|
||||
const flaskCheck = run(
|
||||
venvPython,
|
||||
['-c', 'import importlib.util, sys; sys.exit(0 if importlib.util.find_spec("flask") else 1)'],
|
||||
{ cwd: toolRoot, timeout: 30_000 },
|
||||
);
|
||||
|
||||
if (flaskCheck.status !== 0) {
|
||||
console.log('Installing review editor dependencies...');
|
||||
const install = run(venvPython, ['-m', 'pip', 'install', '-r', path.join(toolRoot, 'requirements.txt')], {
|
||||
cwd: toolRoot,
|
||||
timeout: 180_000,
|
||||
});
|
||||
if (install.status !== 0) {
|
||||
throw new Error(install.stderr || install.stdout || 'Failed to install dependencies.');
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(reviewRoot, { recursive: true });
|
||||
|
||||
const child = spawn(
|
||||
venvPython,
|
||||
[
|
||||
path.join(toolRoot, 'server.py'),
|
||||
'--review-root',
|
||||
reviewRoot,
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
String(defaultPort),
|
||||
'--no-browser',
|
||||
],
|
||||
{
|
||||
cwd: toolRoot,
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
|
||||
child.on('exit', (code) => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
@@ -19,7 +19,7 @@
|
||||
"img-src": "'self' blob: data: asset: http://asset.localhost https://* https://*:* http://* http://*:*",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://cdnjs.cloudflare.com https://storage.readest.com",
|
||||
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com https://storage.readest.com",
|
||||
"frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com",
|
||||
"frame-src": "'self' blob: asset: http://asset.localhost http://127.0.0.1:* https://*.stripe.com",
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' data: blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
|
||||
},
|
||||
"assetProtocol": {
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('middleware cross-origin isolation headers', () => {
|
||||
it('keeps the stricter require-corp on every other document route', () => {
|
||||
expect(coep('/')).toBe('require-corp');
|
||||
expect(coep('/library')).toBe('require-corp');
|
||||
expect(coep('/review-editor')).toBe('require-corp');
|
||||
// Must not be caught by a naive startsWith('/s').
|
||||
expect(coep('/settings')).toBe('require-corp');
|
||||
expect(coep('/search')).toBe('require-corp');
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const DEFAULT_PORT = 5177;
|
||||
const PORT_SCAN_LIMIT = 100;
|
||||
const launchState = {
|
||||
promise: null as Promise<LaunchResponse> | null,
|
||||
};
|
||||
|
||||
type CommandResult = {
|
||||
ok: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
status: number | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type LaunchResponse = {
|
||||
ok: boolean;
|
||||
url?: string;
|
||||
reused?: boolean;
|
||||
reviewRoot?: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type PythonCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
const appRoot = () => process.cwd();
|
||||
const toolRoot = () => path.join(appRoot(), 'tools', 'epub-review-editor');
|
||||
const reviewRoot = () =>
|
||||
path.resolve(process.env['READEST_REVIEW_ROOT'] || path.join(appRoot(), 'epub_review_sessions'));
|
||||
|
||||
const venvRoot = () => path.join(toolRoot(), '.venv');
|
||||
const venvPython = () =>
|
||||
process.platform === 'win32'
|
||||
? path.join(venvRoot(), 'Scripts', 'python.exe')
|
||||
: path.join(venvRoot(), 'bin', 'python');
|
||||
|
||||
const run = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd?: string; timeout?: number } = {},
|
||||
): CommandResult => {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: options.timeout ?? 30_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
return {
|
||||
ok: result.status === 0 && !result.error,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
status: result.status,
|
||||
error: result.error?.message,
|
||||
};
|
||||
};
|
||||
|
||||
const readExpectedVersion = () => {
|
||||
try {
|
||||
const versionText = fs.readFileSync(path.join(toolRoot(), 'version.py'), 'utf8');
|
||||
return /version\s*=\s*"([^"]+)"/.exec(versionText)?.[1] || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const isLaunchEnabled = () =>
|
||||
process.env['NODE_ENV'] === 'development' &&
|
||||
process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web' &&
|
||||
process.env['READEST_REVIEW_EDITOR_LAUNCH'] !== '0';
|
||||
|
||||
const isLoopbackHost = (host: string | null) => {
|
||||
const normalized = (host || '').toLowerCase();
|
||||
return (
|
||||
normalized.startsWith('localhost') ||
|
||||
normalized.startsWith('127.0.0.1') ||
|
||||
normalized.startsWith('[::1]')
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeLoopbackUrl = (url: string) => url.replace('http://localhost:', 'http://127.0.0.1:');
|
||||
|
||||
const requestJson = (port: number, pathname: string): Promise<Record<string, unknown> | null> =>
|
||||
new Promise((resolve) => {
|
||||
const req = http.get(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: pathname,
|
||||
timeout: 800,
|
||||
},
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(body) as Record<string, unknown>);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
req.on('error', () => resolve(null));
|
||||
});
|
||||
|
||||
const findRunningEditor = async () => {
|
||||
const expectedVersion = readExpectedVersion();
|
||||
for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SCAN_LIMIT; port++) {
|
||||
const payload = await requestJson(port, '/api/version');
|
||||
if (!payload) continue;
|
||||
if (!expectedVersion || payload['version'] === expectedVersion) {
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const resolvePythonCommand = (): PythonCommand => {
|
||||
const candidates: PythonCommand[] =
|
||||
process.platform === 'win32'
|
||||
? [
|
||||
{ command: 'py', args: ['-3'] },
|
||||
{ command: 'python', args: [] },
|
||||
{ command: 'python3', args: [] },
|
||||
]
|
||||
: [
|
||||
{ command: 'python3', args: [] },
|
||||
{ command: 'python', args: [] },
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const versionCheck = run(candidate.command, [...candidate.args, '--version'], {
|
||||
timeout: 10_000,
|
||||
});
|
||||
if (versionCheck.ok) return candidate;
|
||||
}
|
||||
throw new Error('没有找到 Python 3。请先安装 Python 3,然后重新打开审校器。');
|
||||
};
|
||||
|
||||
const ensureVenv = () => {
|
||||
const pythonPath = venvPython();
|
||||
if (fs.existsSync(pythonPath)) return;
|
||||
fs.mkdirSync(toolRoot(), { recursive: true });
|
||||
const python = resolvePythonCommand();
|
||||
const result = run(python.command, [...python.args, '-m', 'venv', venvRoot()], {
|
||||
cwd: appRoot(),
|
||||
timeout: 120_000,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(`创建 Python 环境失败:${result.stderr || result.error || result.stdout}`);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureDependencies = () => {
|
||||
const pythonPath = venvPython();
|
||||
const flaskCheck = run(
|
||||
pythonPath,
|
||||
['-c', 'import importlib.util, sys; sys.exit(0 if importlib.util.find_spec("flask") else 1)'],
|
||||
{ cwd: toolRoot(), timeout: 30_000 },
|
||||
);
|
||||
if (flaskCheck.ok) return;
|
||||
|
||||
const install = run(
|
||||
pythonPath,
|
||||
['-m', 'pip', 'install', '-r', path.join(toolRoot(), 'requirements.txt')],
|
||||
{ cwd: toolRoot(), timeout: 180_000 },
|
||||
);
|
||||
if (!install.ok) {
|
||||
throw new Error(`安装审校器依赖失败:${install.stderr || install.error || install.stdout}`);
|
||||
}
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isLaunchEnabled()) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: 'EPUB 审校器的 Next 启动入口仅在本地 dev-web 开发环境启用。',
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
if (
|
||||
request.headers.get('x-readest-review-editor-launch') !== '1' ||
|
||||
!isLoopbackHost(request.headers.get('host'))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: 'EPUB 审校器启动请求必须来自本机 Readest 开发页面。',
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (launchState.promise) {
|
||||
const data = await launchState.promise;
|
||||
return NextResponse.json(data, { status: data.ok ? 200 : 500 });
|
||||
}
|
||||
|
||||
launchState.promise = launchEditor();
|
||||
const data = await launchState.promise;
|
||||
launchState.promise = null;
|
||||
return NextResponse.json(data, { status: data.ok ? 200 : 500 });
|
||||
}
|
||||
|
||||
async function launchEditor(): Promise<LaunchResponse> {
|
||||
const bundledToolRoot = toolRoot();
|
||||
const serverPath = path.join(bundledToolRoot, 'server.py');
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `没有找到已迁移的审校器后端:${serverPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
const runningUrl = await findRunningEditor();
|
||||
if (runningUrl) {
|
||||
return {
|
||||
ok: true,
|
||||
url: runningUrl,
|
||||
reused: true,
|
||||
reviewRoot: reviewRoot(),
|
||||
version: readExpectedVersion(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
ensureVenv();
|
||||
ensureDependencies();
|
||||
fs.mkdirSync(reviewRoot(), { recursive: true });
|
||||
|
||||
const launch = run(
|
||||
venvPython(),
|
||||
[
|
||||
serverPath,
|
||||
'--review-root',
|
||||
reviewRoot(),
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
String(DEFAULT_PORT),
|
||||
'--daemon',
|
||||
'--no-browser',
|
||||
],
|
||||
{ cwd: bundledToolRoot, timeout: 45_000 },
|
||||
);
|
||||
if (!launch.ok) {
|
||||
throw new Error(`启动审校器失败:${launch.stderr || launch.error || launch.stdout}`);
|
||||
}
|
||||
|
||||
const launchedUrl = normalizeLoopbackUrl(
|
||||
/https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor()),
|
||||
);
|
||||
if (!launchedUrl) {
|
||||
throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
url: launchedUrl,
|
||||
reused: false,
|
||||
reviewRoot: reviewRoot(),
|
||||
version: readExpectedVersion(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
reviewRoot: reviewRoot(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -129,16 +129,8 @@ const devHmrPatchScript = `(${patchTauriHmrWebSocket.toString()})(${JSON.stringi
|
||||
// fallback HTML and crash with `Unexpected token '<'`. All runtime-config
|
||||
// consumers fall back to `NEXT_PUBLIC_*` envs baked at build time on Tauri.
|
||||
const shouldInjectRuntimeConfig = process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
|
||||
const shouldUseViewTransitions =
|
||||
process.env['NODE_ENV'] !== 'development' || process.env['NEXT_PUBLIC_APP_PLATFORM'] !== 'web';
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const app = (
|
||||
<EnvProvider>
|
||||
<Providers>{children}</Providers>
|
||||
</EnvProvider>
|
||||
);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang='en'
|
||||
@@ -152,7 +144,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<script dangerouslySetInnerHTML={{ __html: devHmrPatchScript }} />
|
||||
) : null}
|
||||
</head>
|
||||
<body>{shouldUseViewTransitions ? <ViewTransitions>{app}</ViewTransitions> : app}</body>
|
||||
<body>
|
||||
<ViewTransitions>
|
||||
<EnvProvider>
|
||||
<Providers>{children}</Providers>
|
||||
</EnvProvider>
|
||||
</ViewTransitions>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { LuLanguages } from 'react-icons/lu';
|
||||
import { PiX } from 'react-icons/pi';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import type { BilingualFilterMode, BilingualFilterOptions } from '@/services/bilingualEpubFilter';
|
||||
|
||||
interface BilingualFilterAlertProps {
|
||||
bookTitle: string;
|
||||
safeAreaBottom: number;
|
||||
processing: boolean;
|
||||
onCancel: () => void;
|
||||
onFilter: (options: BilingualFilterOptions) => void;
|
||||
}
|
||||
|
||||
const BilingualFilterAlert: React.FC<BilingualFilterAlertProps> = ({
|
||||
bookTitle,
|
||||
safeAreaBottom,
|
||||
processing,
|
||||
onCancel,
|
||||
onFilter,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const divRef = useKeyDownActions({ onCancel });
|
||||
const [mode, setMode] = useState<BilingualFilterMode>('auto');
|
||||
const [removeUnknown, setRemoveUnknown] = useState(false);
|
||||
|
||||
const handleFilter = (removeLanguage: BilingualFilterOptions['removeLanguage']) => {
|
||||
if (processing) return;
|
||||
onFilter({ removeLanguage, mode, removeUnknown });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={divRef}
|
||||
className='fixed bottom-0 left-0 right-0 z-50 flex justify-center px-3'
|
||||
style={{ paddingBottom: `${safeAreaBottom + 16}px` }}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'border-base-content/10 bg-base-200/95 flex w-full max-w-xl flex-col gap-4 rounded-2xl border p-4 shadow-lg backdrop-blur-sm',
|
||||
processing && 'pointer-events-none opacity-80',
|
||||
)}
|
||||
>
|
||||
<div className='relative flex min-w-0 items-center justify-center gap-2 pr-8'>
|
||||
<LuLanguages className='size-5 shrink-0' />
|
||||
<div className='min-w-0 truncate text-center text-sm font-medium'>
|
||||
{_('Bilingual EPUB')}: {bookTitle}
|
||||
</div>
|
||||
<button
|
||||
className={clsx(
|
||||
'absolute right-0 flex items-center justify-center rounded-full p-1.5',
|
||||
'text-base-content/70 transition-colors hover:text-base-content',
|
||||
)}
|
||||
onClick={onCancel}
|
||||
aria-label={_('Cancel')}
|
||||
disabled={processing}
|
||||
>
|
||||
<PiX className='size-5' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center'>
|
||||
<label className='flex min-w-0 items-center gap-2'>
|
||||
<span className='text-neutral-content shrink-0 text-xs'>{_('Detection')}</span>
|
||||
<select
|
||||
className='select select-bordered select-sm min-w-0 flex-1'
|
||||
value={mode}
|
||||
disabled={processing}
|
||||
onChange={(event) => setMode(event.target.value as BilingualFilterMode)}
|
||||
>
|
||||
<option value='auto'>{_('Auto')}</option>
|
||||
<option value='style'>{_('Style')}</option>
|
||||
<option value='script'>{_('Script')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className='flex cursor-pointer items-center gap-2 justify-self-start sm:justify-self-end'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='checkbox checkbox-sm'
|
||||
checked={removeUnknown}
|
||||
disabled={processing}
|
||||
onChange={(event) => setRemoveUnknown(event.target.checked)}
|
||||
/>
|
||||
<span className='text-sm'>{_('Remove uncertain text')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
|
||||
<button
|
||||
className='btn btn-primary btn-sm'
|
||||
disabled={processing}
|
||||
onClick={() => handleFilter('ja')}
|
||||
>
|
||||
{_('Keep Chinese')}
|
||||
</button>
|
||||
<button
|
||||
className='btn btn-secondary btn-sm'
|
||||
disabled={processing}
|
||||
onClick={() => handleFilter('zh')}
|
||||
>
|
||||
{_('Keep Japanese')}
|
||||
</button>
|
||||
<button className='btn btn-ghost btn-sm' disabled={processing} onClick={onCancel}>
|
||||
{processing ? _('Processing...') : _('Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BilingualFilterAlert;
|
||||
@@ -63,11 +63,6 @@ import GroupingModal from './GroupingModal';
|
||||
import SetStatusAlert from './SetStatusAlert';
|
||||
import RecentShelf, { RECENT_SHELF_BOOK_COUNT } from './RecentShelf';
|
||||
import { useOpenBook } from '../hooks/useOpenBook';
|
||||
import BilingualFilterAlert from './BilingualFilterAlert';
|
||||
import {
|
||||
filterBilingualEpubFile,
|
||||
type BilingualFilterOptions,
|
||||
} from '@/services/bilingualEpubFilter';
|
||||
|
||||
interface BookshelfProps {
|
||||
libraryBooks: Book[];
|
||||
@@ -204,9 +199,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [showStatusAlert, setShowStatusAlert] = useState(false);
|
||||
const [showGroupingModal, setShowGroupingModal] = useState(false);
|
||||
const [showBilingualFilterAlert, setShowBilingualFilterAlert] = useState(false);
|
||||
const [bilingualFilterBook, setBilingualFilterBook] = useState<Book | null>(null);
|
||||
const [bilingualFilterProcessing, setBilingualFilterProcessing] = useState(false);
|
||||
const [importBookUrl] = useState(searchParams?.get('url') || '');
|
||||
|
||||
const abortDeletionRef = useRef(false);
|
||||
@@ -460,112 +452,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
setShowStatusAlert(true);
|
||||
};
|
||||
|
||||
const getSingleSelectedBook = () => {
|
||||
const ids = getSelectedBooks();
|
||||
if (ids.length !== 1) return;
|
||||
return filteredBooks.find((book) => book.hash === ids[0]);
|
||||
};
|
||||
|
||||
const showBilingualFilterSelection = () => {
|
||||
const book = getSingleSelectedBook();
|
||||
if (!book || book.format !== 'EPUB') return;
|
||||
setBilingualFilterBook(null);
|
||||
setShowSelectModeActions(false);
|
||||
setShowBilingualFilterAlert(true);
|
||||
};
|
||||
|
||||
const showBilingualFilterBook = useCallback(
|
||||
(book: Book) => {
|
||||
if (book.format !== 'EPUB') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'warning',
|
||||
message: _('Only EPUB books can be filtered'),
|
||||
timeout: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBilingualFilterBook(book);
|
||||
setShowSelectModeActions(false);
|
||||
setShowBilingualFilterAlert(true);
|
||||
},
|
||||
[_],
|
||||
);
|
||||
|
||||
const closeBilingualFilterSelection = () => {
|
||||
if (bilingualFilterProcessing) return;
|
||||
setShowBilingualFilterAlert(false);
|
||||
setBilingualFilterBook(null);
|
||||
if (isSelectMode) setShowSelectModeActions(true);
|
||||
};
|
||||
|
||||
const runBilingualFilter = async (options: BilingualFilterOptions) => {
|
||||
const book = bilingualFilterBook ?? getSingleSelectedBook();
|
||||
if (!book || !appService) return;
|
||||
if (book.format !== 'EPUB') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'warning',
|
||||
message: _('Only EPUB books can be filtered'),
|
||||
timeout: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setBilingualFilterProcessing(true);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (!(await appService.isBookAvailable(book))) {
|
||||
if (!book.uploadedAt || !(await handleBookDownload(book, { queued: false }))) {
|
||||
throw new Error('Book file is not available locally');
|
||||
}
|
||||
}
|
||||
|
||||
const { file } = await appService.loadBookContent(book);
|
||||
try {
|
||||
const result = await filterBilingualEpubFile(file, options);
|
||||
const importedBook = await appService.importBook(result.file, libraryBooks);
|
||||
if (!importedBook) throw new Error('Failed to import generated EPUB');
|
||||
|
||||
importedBook.group = book.group;
|
||||
importedBook.groupId = book.groupId;
|
||||
importedBook.groupName = book.groupName;
|
||||
importedBook.tags = book.tags;
|
||||
importedBook.updatedAt = Date.now();
|
||||
|
||||
await updateBooks(envConfig, [importedBook]);
|
||||
handlePushLibrary();
|
||||
setSelectedBooks([]);
|
||||
setBilingualFilterBook(null);
|
||||
setShowBilingualFilterAlert(false);
|
||||
handleSetSelectMode(false);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: _('Created {{title}}. Removed {{count}} paragraph(s).', {
|
||||
title: importedBook.title || result.title,
|
||||
count: result.stats.removed,
|
||||
}),
|
||||
timeout: 3500,
|
||||
});
|
||||
} finally {
|
||||
const closable = file as File & { close?: () => Promise<void> | void };
|
||||
await closable.close?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to filter bilingual EPUB:', error);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Failed to filter bilingual EPUB'),
|
||||
timeout: 3000,
|
||||
});
|
||||
setBilingualFilterBook(null);
|
||||
setShowBilingualFilterAlert(false);
|
||||
if (isSelectMode) setShowSelectModeActions(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setBilingualFilterProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendSelectedBook = async () => {
|
||||
// "Send" hands the actual book file (epub/pdf/...) to the OS share
|
||||
// sheet (UIActivityViewController on iOS, Intent.ACTION_SEND on
|
||||
@@ -792,11 +678,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
);
|
||||
|
||||
const selectedBooks = getSelectedBooks();
|
||||
const selectedBilingualBook =
|
||||
selectedBooks.length === 1
|
||||
? filteredBooks.find((book) => book.hash === selectedBooks[0])
|
||||
: null;
|
||||
const bilingualFilterEnabled = !!selectedBilingualBook && selectedBilingualBook.format === 'EPUB';
|
||||
const isGridMode = viewMode === 'grid';
|
||||
const hasItems = sortedBookshelfItems.length > 0;
|
||||
// In grid mode the Import-Books "+" tile is rendered as an extra grid cell
|
||||
@@ -910,7 +791,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleBilingualFilterBook={showBilingualFilterBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
handleUpdateReadingStatus={handleUpdateReadingStatus}
|
||||
transferProgress={
|
||||
@@ -936,7 +816,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
showBilingualFilterBook,
|
||||
handleLibraryNavigation,
|
||||
handleUpdateReadingStatus,
|
||||
],
|
||||
@@ -1010,20 +889,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
onGroup={groupSelectedBooks}
|
||||
onDetails={openBookDetails}
|
||||
onStatus={showStatusSelection}
|
||||
onBilingualFilter={showBilingualFilterSelection}
|
||||
onSend={sendSelectedBook}
|
||||
onDelete={deleteSelectedBooks}
|
||||
onCancel={() => handleSetSelectMode(false)}
|
||||
bilingualFilterEnabled={bilingualFilterEnabled}
|
||||
/>
|
||||
)}
|
||||
{showBilingualFilterAlert && (bilingualFilterBook || selectedBilingualBook) && (
|
||||
<BilingualFilterAlert
|
||||
bookTitle={(bilingualFilterBook || selectedBilingualBook)?.title || ''}
|
||||
safeAreaBottom={safeAreaInsets?.bottom || 0}
|
||||
processing={bilingualFilterProcessing}
|
||||
onCancel={closeBilingualFilterSelection}
|
||||
onFilter={runBilingualFilter}
|
||||
/>
|
||||
)}
|
||||
{showGroupingModal && selectedBooks.length > 0 && (
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { Menu as TauriMenu } from '@tauri-apps/api/menu';
|
||||
import { Menu, type MenuItemOptions } from '@tauri-apps/api/menu';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { openExternalUrl } from '@/utils/open';
|
||||
import { getBookGoodreadsQuery, getGoodreadsSearchUrl } from '@/utils/goodreads';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { Overlay } from '@/components/Overlay';
|
||||
import Menu from '@/components/Menu';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
|
||||
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
|
||||
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
|
||||
@@ -27,17 +24,6 @@ import BookItem from './BookItem';
|
||||
import GroupItem from './GroupItem';
|
||||
import { useOpenBook } from '../hooks/useOpenBook';
|
||||
|
||||
type WebContextMenuItem = {
|
||||
text: string;
|
||||
action: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
type WebContextMenuState = {
|
||||
x: number;
|
||||
y: number;
|
||||
items: WebContextMenuItem[];
|
||||
} | null;
|
||||
|
||||
export const generateBookshelfItems = (
|
||||
books: Book[],
|
||||
parentGroupName: string,
|
||||
@@ -117,7 +103,6 @@ interface BookshelfItemProps {
|
||||
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
handleBilingualFilterBook: (book: Book) => void;
|
||||
handleLibraryNavigation: (targetGroup: string) => void;
|
||||
handleUpdateReadingStatus: (book: Book, status: ReadingStatus | undefined) => void;
|
||||
}
|
||||
@@ -136,7 +121,6 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
handleBookDownload,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
handleBilingualFilterBook,
|
||||
handleLibraryNavigation,
|
||||
handleUpdateReadingStatus,
|
||||
}) => {
|
||||
@@ -144,7 +128,6 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const { appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { openBook } = useOpenBook({ setLoading, handleBookDownload });
|
||||
const [webContextMenu, setWebContextMenu] = useState<WebContextMenuState>(null);
|
||||
|
||||
const showBookDetailsModal = useCallback(async (book: Book) => {
|
||||
handleShowDetailsBook(book);
|
||||
@@ -174,7 +157,8 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
[isSelectMode, handleLibraryNavigation],
|
||||
);
|
||||
|
||||
const bookContextMenuHandler = async (book: Book, event?: React.MouseEvent) => {
|
||||
const bookContextMenuHandler = async (book: Book) => {
|
||||
if (!appService?.hasContextMenu) return;
|
||||
const osPlatform = getOSPlatform();
|
||||
const fileRevealLabel =
|
||||
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
|
||||
@@ -182,7 +166,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
// in a single Menu.new({ items }) call. Appending items one-by-one with
|
||||
// un-awaited Menu.append() promises races on the Tauri IPC boundary and
|
||||
// shuffles the order on every open (issue #4389).
|
||||
const itemOptions: Record<BookContextMenuItemId, WebContextMenuItem> = {
|
||||
const itemOptions: Record<BookContextMenuItemId, MenuItemOptions> = {
|
||||
select: {
|
||||
text: itemSelected ? _('Deselect Book') : _('Select Book'),
|
||||
action: async () => {
|
||||
@@ -230,12 +214,6 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
showBookDetailsModal(book);
|
||||
},
|
||||
},
|
||||
bilingual: {
|
||||
text: _('Bilingual'),
|
||||
action: async () => {
|
||||
handleBilingualFilterBook(book);
|
||||
},
|
||||
},
|
||||
showInFinder: {
|
||||
text: _(fileRevealLabel),
|
||||
action: async () => {
|
||||
@@ -276,22 +254,16 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
},
|
||||
},
|
||||
};
|
||||
const itemIds = getBookContextMenuItemIds(book).filter(
|
||||
(id) => appService?.hasContextMenu || id !== 'showInFinder',
|
||||
);
|
||||
const items = itemIds.map((id) => itemOptions[id]);
|
||||
if (appService?.hasContextMenu) {
|
||||
const menu = await TauriMenu.new({ items });
|
||||
await menu.popup();
|
||||
return;
|
||||
}
|
||||
if (event) setWebContextMenu({ x: event.clientX, y: event.clientY, items });
|
||||
const items = getBookContextMenuItemIds(book).map((id) => itemOptions[id]);
|
||||
const menu = await Menu.new({ items });
|
||||
await menu.popup();
|
||||
};
|
||||
|
||||
const groupContextMenuHandler = async (group: BooksGroup, event?: React.MouseEvent) => {
|
||||
const groupContextMenuHandler = async (group: BooksGroup) => {
|
||||
if (!appService?.hasContextMenu) return;
|
||||
// Single Menu.new({ items }) call keeps the order deterministic — see the
|
||||
// note in bookContextMenuHandler about the Menu.append() IPC race (#4389).
|
||||
const items: WebContextMenuItem[] = [
|
||||
const items: MenuItemOptions[] = [
|
||||
{
|
||||
text: itemSelected ? _('Deselect Group') : _('Select Group'),
|
||||
action: async () => {
|
||||
@@ -321,12 +293,8 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
},
|
||||
},
|
||||
];
|
||||
if (appService?.hasContextMenu) {
|
||||
const menu = await TauriMenu.new({ items });
|
||||
await menu.popup();
|
||||
return;
|
||||
}
|
||||
if (event) setWebContextMenu({ x: event.clientX, y: event.clientY, items });
|
||||
const menu = await Menu.new({ items });
|
||||
await menu.popup();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -362,28 +330,14 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleContextMenu = useCallback(
|
||||
throttle((event?: React.MouseEvent) => {
|
||||
throttle(() => {
|
||||
if ('format' in item) {
|
||||
bookContextMenuHandler(item as Book, event);
|
||||
bookContextMenuHandler(item as Book);
|
||||
} else {
|
||||
groupContextMenuHandler(item as BooksGroup, event);
|
||||
groupContextMenuHandler(item as BooksGroup);
|
||||
}
|
||||
}, 100),
|
||||
[
|
||||
appService?.hasContextMenu,
|
||||
appService?.isMobileApp,
|
||||
handleBookDownload,
|
||||
handleBookUpload,
|
||||
handleBilingualFilterBook,
|
||||
handleGroupBooks,
|
||||
handleSetSelectMode,
|
||||
handleUpdateReadingStatus,
|
||||
item,
|
||||
itemSelected,
|
||||
settings.localBooksDir,
|
||||
showBookDetailsModal,
|
||||
toggleSelection,
|
||||
],
|
||||
[itemSelected, settings.localBooksDir],
|
||||
);
|
||||
|
||||
const { pressing, handlers } = useLongPress(
|
||||
@@ -394,11 +348,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
onTap: () => {
|
||||
handleOpenItem();
|
||||
},
|
||||
onContextMenu: (event) => {
|
||||
onContextMenu: () => {
|
||||
if (appService?.hasContextMenu) {
|
||||
handleContextMenu();
|
||||
} else if (!appService?.isMobileApp) {
|
||||
handleContextMenu(event);
|
||||
} else if (appService?.isAndroidApp) {
|
||||
handleSelectItem();
|
||||
}
|
||||
@@ -428,29 +380,6 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
|
||||
return (
|
||||
<div className={clsx(mode === 'grid' ? 'h-full' : 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
|
||||
{webContextMenu && (
|
||||
<>
|
||||
<Overlay onDismiss={() => setWebContextMenu(null)} className='z-40' />
|
||||
<Menu
|
||||
className='bg-base-100 border-base-300 fixed z-50 min-w-56 rounded-lg border p-1 shadow-xl'
|
||||
style={{ left: webContextMenu.x, top: webContextMenu.y }}
|
||||
onCancel={() => setWebContextMenu(null)}
|
||||
>
|
||||
{webContextMenu.items.map((menuItem) => (
|
||||
<MenuItem
|
||||
key={menuItem.text}
|
||||
label={menuItem.text}
|
||||
noIcon
|
||||
transient
|
||||
onClick={() => {
|
||||
setWebContextMenu(null);
|
||||
void menuItem.action();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'visible-focus-inset-2 group',
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
MdCheckCircleOutline,
|
||||
} from 'react-icons/md';
|
||||
import { IoShareSocialOutline } from 'react-icons/io5';
|
||||
import { LuFolderPlus, LuLanguages } from 'react-icons/lu';
|
||||
import { LuFolderPlus } from 'react-icons/lu';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { isMd5 } from '@/utils/md5';
|
||||
@@ -25,7 +25,6 @@ interface SelectModeActionsProps {
|
||||
onGroup: () => void;
|
||||
onDetails: () => void;
|
||||
onStatus: () => void;
|
||||
onBilingualFilter: () => void;
|
||||
// The macOS / iPad share popover is anchored to the selected book's
|
||||
// cover (located via its data-book-hash attribute), not to this
|
||||
// button — the user's visual focus is on the cover they just tapped.
|
||||
@@ -33,7 +32,6 @@ interface SelectModeActionsProps {
|
||||
onSend: () => void;
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
bilingualFilterEnabled?: boolean;
|
||||
}
|
||||
|
||||
const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
@@ -44,11 +42,9 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
onGroup,
|
||||
onDetails,
|
||||
onStatus,
|
||||
onBilingualFilter,
|
||||
onSend,
|
||||
onDelete,
|
||||
onCancel,
|
||||
bilingualFilterEnabled = false,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
|
||||
@@ -114,21 +110,13 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
<MdInfoOutline />
|
||||
<div>{_('Details')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={onBilingualFilter}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
!bilingualFilterEnabled && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<LuLanguages />
|
||||
<div>{_('Bilingual')}</div>
|
||||
</button>
|
||||
{sendEnabled && (
|
||||
<button
|
||||
onClick={onSend}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
// Wraps to the start of the second row on narrow viewports.
|
||||
'max-[500px]:col-start-1',
|
||||
(!hasSingleSelection || !hasValidBooks) && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
@@ -140,6 +128,12 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
onClick={onDelete}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
// Without Send (Linux/Windows/web), Delete needs an explicit
|
||||
// col-start-2 so the wrapped row {Delete, Cancel} stays centred
|
||||
// under the 4-col grid. With Send present, the layout is
|
||||
// {Send, Delete, Cancel} starting at col-start-1, so Delete
|
||||
// naturally lands in col-start-2 without an override.
|
||||
!sendEnabled && 'max-[500px]:col-start-2',
|
||||
!hasSelection && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useTransferQueue } from '@/hooks/useTransferQueue';
|
||||
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
|
||||
import { navigateToLogin, navigateToProfile, navigateToReviewEditor } from '@/utils/nav';
|
||||
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import { setMigrateDataDirDialogVisible } from '@/app/library/components/MigrateDataWindow';
|
||||
@@ -204,6 +204,11 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
|
||||
setCacheManagerDialogVisible(true);
|
||||
};
|
||||
|
||||
const handleOpenReviewEditor = () => {
|
||||
navigateToReviewEditor(router);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleRefreshMetadata = async () => {
|
||||
if (!appService || isRefreshingMetadata) return;
|
||||
setIsRefreshingMetadata(true);
|
||||
@@ -431,6 +436,9 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
|
||||
<hr aria-hidden='true' className='border-base-200 my-1' />
|
||||
<MenuItem label={_('Advanced Settings')}>
|
||||
<ul className='ms-0 flex flex-col ps-0 before:hidden'>
|
||||
{process.env['NODE_ENV'] === 'development' && isWebAppPlatform() && (
|
||||
<MenuItem label='EPUB 审校器' onClick={handleOpenReviewEditor} />
|
||||
)}
|
||||
<MenuItem label={_('Backup & Restore')} onClick={handleBackupRestore} />
|
||||
{appService?.canCustomizeRootDir && (
|
||||
<MenuItem label={_('Change Data Location')} onClick={handleSetRootDir} />
|
||||
|
||||
@@ -651,7 +651,6 @@ export type BookContextMenuItemId =
|
||||
| 'markAbandoned'
|
||||
| 'clearStatus'
|
||||
| 'showDetails'
|
||||
| 'bilingual'
|
||||
| 'showInFinder'
|
||||
| 'searchGoodreads'
|
||||
| 'download'
|
||||
@@ -751,9 +750,7 @@ export const getBookContextMenuItemIds = (book: Book): BookContextMenuItemId[] =
|
||||
) {
|
||||
ids.push('clearStatus');
|
||||
}
|
||||
ids.push('showDetails');
|
||||
if (book.format === 'EPUB') ids.push('bilingual');
|
||||
ids.push('showInFinder', 'searchGoodreads');
|
||||
ids.push('showDetails', 'showInFinder', 'searchGoodreads');
|
||||
if (book.uploadedAt && !book.downloadedAt) ids.push('download');
|
||||
if (!book.uploadedAt && book.downloadedAt) ids.push('upload');
|
||||
// Share is offered for any local-or-uploaded book; the dialog uploads first
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { ArrowLeft, ExternalLink, RefreshCw, Rocket, Wrench } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { openExternalUrl } from '@/utils/open';
|
||||
|
||||
type LaunchState = 'idle' | 'launching' | 'ready' | 'failed';
|
||||
|
||||
type LaunchResponse = {
|
||||
ok: boolean;
|
||||
url?: string;
|
||||
reused?: boolean;
|
||||
reviewRoot?: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const LaunchButton = ({
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
variant = 'primary',
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
variant?: 'primary' | 'ghost';
|
||||
}) => (
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
'btn h-10 min-h-10 rounded-md px-4 text-sm font-medium',
|
||||
variant === 'primary' ? 'btn-primary' : 'btn-ghost eink-bordered',
|
||||
disabled && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
export default function ReviewEditorLauncher() {
|
||||
const router = useRouter();
|
||||
const [state, setState] = useState<LaunchState>('idle');
|
||||
const [payload, setPayload] = useState<LaunchResponse | null>(null);
|
||||
const didAutoLaunchRef = useRef(false);
|
||||
|
||||
const launch = useCallback(async () => {
|
||||
setState('launching');
|
||||
setPayload(null);
|
||||
try {
|
||||
const response = await fetch('/api/review-editor/launch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-readest-review-editor-launch': '1',
|
||||
},
|
||||
});
|
||||
const data = (await response.json()) as LaunchResponse;
|
||||
setPayload(data);
|
||||
setState(response.ok && data.ok && data.url ? 'ready' : 'failed');
|
||||
} catch (error) {
|
||||
setPayload({
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
setState('failed');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (didAutoLaunchRef.current) return;
|
||||
didAutoLaunchRef.current = true;
|
||||
void launch();
|
||||
}, [launch]);
|
||||
|
||||
const editorUrl = payload?.url;
|
||||
const frameTitle = useMemo(() => {
|
||||
if (!payload?.version) return 'EPUB 审校器';
|
||||
return `EPUB 审校器 v${payload.version}`;
|
||||
}, [payload?.version]);
|
||||
|
||||
return (
|
||||
<main className='bg-base-200 text-base-content flex h-dvh min-h-dvh flex-col overflow-hidden'>
|
||||
<header className='bg-base-100/95 border-base-300 flex h-14 flex-shrink-0 items-center justify-between border-b px-3 backdrop-blur sm:px-5'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='返回书架'
|
||||
title='返回书架'
|
||||
className='btn btn-ghost h-9 min-h-9 w-9 rounded-md p-0'
|
||||
onClick={() => router.push('/library')}
|
||||
>
|
||||
<ArrowLeft className='h-5 w-5' />
|
||||
</button>
|
||||
<div className='min-w-0'>
|
||||
<h1 className='truncate text-base font-semibold'>EPUB 审校器</h1>
|
||||
<p className='text-base-content/60 truncate text-xs'>
|
||||
Readest 基座内置旧版审校、书架与 AI 翻译工作流
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='重新启动审校器'
|
||||
title='重新启动审校器'
|
||||
className='btn btn-ghost h-9 min-h-9 w-9 rounded-md p-0'
|
||||
onClick={launch}
|
||||
disabled={state === 'launching'}
|
||||
>
|
||||
<RefreshCw className={clsx('h-5 w-5', state === 'launching' && 'animate-spin')} />
|
||||
</button>
|
||||
{editorUrl ? (
|
||||
<button
|
||||
type='button'
|
||||
aria-label='在新窗口打开'
|
||||
title='在新窗口打开'
|
||||
className='btn btn-ghost h-9 min-h-9 w-9 rounded-md p-0'
|
||||
onClick={() => openExternalUrl(editorUrl)}
|
||||
>
|
||||
<ExternalLink className='h-5 w-5' />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{state === 'ready' && editorUrl ? (
|
||||
<iframe
|
||||
src={editorUrl}
|
||||
title={frameTitle}
|
||||
className='bg-base-100 h-full min-h-0 w-full flex-1 border-0'
|
||||
allow='clipboard-read; clipboard-write'
|
||||
/>
|
||||
) : (
|
||||
<section className='flex min-h-0 flex-1 items-center justify-center p-5'>
|
||||
<div className='bg-base-100 eink-bordered w-full max-w-2xl rounded-lg p-6 shadow-xl'>
|
||||
<div className='mb-5 flex items-center gap-3'>
|
||||
<div className='bg-base-200 eink-bordered flex h-11 w-11 items-center justify-center rounded-md'>
|
||||
{state === 'failed' ? (
|
||||
<Wrench className='h-5 w-5' />
|
||||
) : (
|
||||
<Rocket className='h-5 w-5' />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className='text-lg font-semibold'>
|
||||
{state === 'failed' ? '审校器启动失败' : '正在启动审校器'}
|
||||
</h2>
|
||||
<p className='text-base-content/60 text-sm'>
|
||||
{state === 'failed'
|
||||
? '下面是本地启动错误,修复后可重新启动。'
|
||||
: '首次启动会创建 Python 环境并安装 Flask,可能需要一点时间。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state === 'launching' ? (
|
||||
<div className='bg-base-200 h-2 overflow-hidden rounded-full'>
|
||||
<div className='bg-primary h-full w-1/2 animate-pulse rounded-full' />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state === 'failed' ? (
|
||||
<pre className='bg-base-200 text-base-content/85 mt-4 max-h-64 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap'>
|
||||
{payload?.error || '未知错误'}
|
||||
</pre>
|
||||
) : null}
|
||||
|
||||
{payload?.reviewRoot ? (
|
||||
<p className='text-base-content/60 mt-4 break-all text-xs'>
|
||||
审校数据目录:{payload.reviewRoot}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className='mt-6 flex flex-wrap gap-2'>
|
||||
<LaunchButton onClick={launch} disabled={state === 'launching'}>
|
||||
{state === 'failed' ? '重新启动' : '手动重试'}
|
||||
</LaunchButton>
|
||||
<LaunchButton onClick={() => router.push('/library')} variant='ghost'>
|
||||
返回书架
|
||||
</LaunchButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ReviewEditorLauncher from './ReviewEditorLauncher';
|
||||
|
||||
export default function ReviewEditorPage() {
|
||||
return <ReviewEditorLauncher />;
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { initReplicaSync } from '@/services/sync/replicaSync';
|
||||
import { createSettingsCursorStore } from '@/services/sync/replicaCursorStore';
|
||||
import { startReplicaTransferIntegration } from '@/services/sync/replicaTransferIntegration';
|
||||
import { enableReplicaAutoPersist } from '@/services/sync/replicaPersist';
|
||||
import { isViewTransitionTimeoutError } from '@/utils/error';
|
||||
|
||||
interface EnvContextType {
|
||||
envConfig: EnvConfigType;
|
||||
@@ -41,33 +40,14 @@ export const EnvProvider = ({ children }: { children: ReactNode }) => {
|
||||
console.warn('replica sync init failed', err);
|
||||
}
|
||||
});
|
||||
const handleError = (e: ErrorEvent) => {
|
||||
window.addEventListener('error', (e) => {
|
||||
if (e.message === 'ResizeObserver loop limit exceeded') {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (isViewTransitionTimeoutError(e.error)) {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const handleUnhandledRejection = (e: PromiseRejectionEvent) => {
|
||||
if (isViewTransitionTimeoutError(e.reason)) {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
window.addEventListener('error', handleError);
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
return () => {
|
||||
window.removeEventListener('error', handleError);
|
||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
};
|
||||
});
|
||||
}, [envConfig]);
|
||||
|
||||
const value = useMemo(() => ({ envConfig, appService }), [envConfig, appService]);
|
||||
|
||||
@@ -2,12 +2,7 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTransitionRouter } from 'next-view-transitions';
|
||||
|
||||
const isWebDevMode =
|
||||
process.env['NODE_ENV'] === 'development' && process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
|
||||
|
||||
const usePlainAppRouter = () => useRouter();
|
||||
|
||||
const useTransitionAppRouter = () => {
|
||||
export const useAppRouter = () => {
|
||||
const { appService } = useEnv();
|
||||
const transitionRouter = useTransitionRouter();
|
||||
const plainRouter = useRouter();
|
||||
@@ -20,5 +15,3 @@ const useTransitionAppRouter = () => {
|
||||
// seen on unsupported webviews (Sentry READEST-9).
|
||||
return appService?.supportsViewTransitionsAPI ? transitionRouter : plainRouter;
|
||||
};
|
||||
|
||||
export const useAppRouter = isWebDevMode ? usePlainAppRouter : useTransitionAppRouter;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
interface UseLongPressOptions {
|
||||
onTap?: () => void;
|
||||
onLongPress?: () => void;
|
||||
onContextMenu?: (event: React.MouseEvent) => void;
|
||||
onContextMenu?: () => void;
|
||||
onCancel?: () => void;
|
||||
threshold?: number;
|
||||
moveThreshold?: number;
|
||||
@@ -149,7 +149,7 @@ export const useLongPress = (
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTimeout(() => {
|
||||
onContextMenu(e);
|
||||
onContextMenu();
|
||||
}, 100);
|
||||
}
|
||||
reset();
|
||||
|
||||
@@ -1,471 +0,0 @@
|
||||
import { makeSafeFilename } from '@/utils/misc';
|
||||
import { getBaseFilename } from '@/utils/path';
|
||||
|
||||
export type BilingualFilterLanguage = 'ja' | 'zh';
|
||||
export type BilingualFilterMode = 'auto' | 'style' | 'script';
|
||||
|
||||
export interface BilingualFilterOptions {
|
||||
removeLanguage: BilingualFilterLanguage;
|
||||
mode?: BilingualFilterMode;
|
||||
removeUnknown?: boolean;
|
||||
}
|
||||
|
||||
export interface BilingualFilterFileStats {
|
||||
path: string;
|
||||
paragraphs: number;
|
||||
removed: number;
|
||||
kept: number;
|
||||
unknown: number;
|
||||
anchorsMoved: number;
|
||||
byLanguage: Record<BilingualFilterLanguage | 'unknown', number>;
|
||||
}
|
||||
|
||||
export interface BilingualFilterStats {
|
||||
filesSeen: number;
|
||||
htmlFiles: number;
|
||||
changedFiles: number;
|
||||
paragraphs: number;
|
||||
removed: number;
|
||||
kept: number;
|
||||
unknown: number;
|
||||
anchorsMoved: number;
|
||||
fileStats: BilingualFilterFileStats[];
|
||||
}
|
||||
|
||||
export interface BilingualFilterResult {
|
||||
file: File;
|
||||
filename: string;
|
||||
title: string;
|
||||
stats: BilingualFilterStats;
|
||||
}
|
||||
|
||||
const HTML_EXTENSIONS = ['.html', '.htm', '.xhtml'];
|
||||
const TEXT_BLOCK_RE = /<p\b[^>]*>.*?<\/p>/gis;
|
||||
const BODY_END_RE = /<\/body\s*>/i;
|
||||
const ANCHOR_RE =
|
||||
/<a\b(?=[^>]*(?:\bid\s*=\s*['"][^'"]+['"]|\bname\s*=\s*['"][^'"]+['"]))[^>]*>\s*<\/a\s*>/gis;
|
||||
const TAG_RE = /<[^>]+>/g;
|
||||
const OPF_EXT = '.opf';
|
||||
|
||||
const makeEmptyFileStats = (path: string): BilingualFilterFileStats => ({
|
||||
path,
|
||||
paragraphs: 0,
|
||||
removed: 0,
|
||||
kept: 0,
|
||||
unknown: 0,
|
||||
anchorsMoved: 0,
|
||||
byLanguage: { ja: 0, zh: 0, unknown: 0 },
|
||||
});
|
||||
|
||||
const makeEmptyRunStats = (): BilingualFilterStats => ({
|
||||
filesSeen: 0,
|
||||
htmlFiles: 0,
|
||||
changedFiles: 0,
|
||||
paragraphs: 0,
|
||||
removed: 0,
|
||||
kept: 0,
|
||||
unknown: 0,
|
||||
anchorsMoved: 0,
|
||||
fileStats: [],
|
||||
});
|
||||
|
||||
const isHtmlPath = (path: string) => {
|
||||
const lower = path.toLowerCase();
|
||||
return HTML_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
};
|
||||
|
||||
const decodeHtmlEntities = (text: string) => {
|
||||
if (typeof document !== 'undefined') {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = text;
|
||||
return textarea.value;
|
||||
}
|
||||
return text
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
const stripTags = (fragment: string) => decodeHtmlEntities(fragment.replace(TAG_RE, '')).trim();
|
||||
|
||||
const getOpeningTag = (fragment: string) => fragment.match(/^<p\b[^>]*>/is)?.[0] ?? '';
|
||||
|
||||
const isImageOrEmptyParagraph = (fragment: string) => {
|
||||
if (stripTags(fragment)) return false;
|
||||
return /<(img|svg|math)\b/i.test(fragment);
|
||||
};
|
||||
|
||||
const getStyleAttribute = (openingTag: string) =>
|
||||
openingTag.match(/\bstyle\s*=\s*(["'])(.*?)\1/is)?.[2] ?? '';
|
||||
|
||||
const getClassAttribute = (openingTag: string) =>
|
||||
openingTag.match(/\bclass\s*=\s*(["'])(.*?)\1/is)?.[2] ?? '';
|
||||
|
||||
const hasGrayColor = (style: string) => {
|
||||
const compact = style.replace(/\s+/g, '').toLowerCase();
|
||||
return (
|
||||
/(^|;)color:(gray|grey)(;|$)/i.test(style) ||
|
||||
compact.includes('color:#808080') ||
|
||||
compact.includes('color:#888') ||
|
||||
compact.includes('color:rgb(128,128,128)') ||
|
||||
compact.includes('color:rgba(128,128,128,')
|
||||
);
|
||||
};
|
||||
|
||||
const isProbablyJapaneseByStyle = (fragment: string) => {
|
||||
const openingTag = getOpeningTag(fragment);
|
||||
const style = getStyleAttribute(openingTag);
|
||||
const className = getClassAttribute(openingTag).toLowerCase();
|
||||
return (
|
||||
/\bopacity\s*:\s*0(?:\.40*|\.4|\.5|\.50*)\b/i.test(style) ||
|
||||
hasGrayColor(style) ||
|
||||
/\b(japanese|source|original|src|ja|jp)\b/.test(className)
|
||||
);
|
||||
};
|
||||
|
||||
const isProbablyChineseByStyle = (fragment: string) => {
|
||||
const openingTag = getOpeningTag(fragment);
|
||||
const className = getClassAttribute(openingTag).toLowerCase();
|
||||
return /\b(chinese|translation|translated|target|zh|cn)\b/.test(className);
|
||||
};
|
||||
|
||||
const scriptCounts = (text: string) => {
|
||||
const counts = { hiragana: 0, katakana: 0, kana: 0, cjk: 0, ascii: 0 };
|
||||
for (const ch of text) {
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
if (code >= 0x3040 && code <= 0x309f) {
|
||||
counts.hiragana += 1;
|
||||
counts.kana += 1;
|
||||
} else if (
|
||||
(code >= 0x30a0 && code <= 0x30ff) ||
|
||||
(code >= 0x31f0 && code <= 0x31ff) ||
|
||||
(code >= 0xff66 && code <= 0xff9d)
|
||||
) {
|
||||
counts.katakana += 1;
|
||||
counts.kana += 1;
|
||||
} else if (
|
||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
||||
(code >= 0x3400 && code <= 0x4dbf) ||
|
||||
(code >= 0xf900 && code <= 0xfaff)
|
||||
) {
|
||||
counts.cjk += 1;
|
||||
} else if (/^[a-z]$/i.test(ch)) {
|
||||
counts.ascii += 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
|
||||
const classifyByScript = (fragment: string): BilingualFilterLanguage | 'unknown' => {
|
||||
const text = stripTags(fragment);
|
||||
if (!text) return 'unknown';
|
||||
|
||||
const counts = scriptCounts(text);
|
||||
const meaningful = counts.kana + counts.cjk + counts.ascii;
|
||||
if (meaningful === 0) return 'unknown';
|
||||
|
||||
if (counts.kana >= 2) return 'ja';
|
||||
if (counts.kana === 1 && counts.cjk <= 2) return 'ja';
|
||||
if (counts.cjk >= 2 && counts.kana === 0) return 'zh';
|
||||
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
const classifyParagraph = (
|
||||
fragment: string,
|
||||
mode: BilingualFilterMode,
|
||||
): BilingualFilterLanguage | 'unknown' => {
|
||||
if (isImageOrEmptyParagraph(fragment)) return 'unknown';
|
||||
if (mode === 'auto' || mode === 'style') {
|
||||
if (isProbablyJapaneseByStyle(fragment)) return 'ja';
|
||||
if (isProbablyChineseByStyle(fragment)) return 'zh';
|
||||
}
|
||||
if (mode === 'style') {
|
||||
return stripTags(fragment) ? 'zh' : 'unknown';
|
||||
}
|
||||
return classifyByScript(fragment);
|
||||
};
|
||||
|
||||
const looksLikeStyleBilingual = (text: string) => {
|
||||
const paragraphs = text.match(TEXT_BLOCK_RE) ?? [];
|
||||
if (paragraphs.length < 10) return false;
|
||||
const textBlocks = paragraphs.filter((paragraph) => stripTags(paragraph)).length;
|
||||
if (textBlocks === 0) return false;
|
||||
const styled = paragraphs.filter(isProbablyJapaneseByStyle).length;
|
||||
const ratio = styled / textBlocks;
|
||||
return styled >= 5 && ratio >= 0.15 && ratio <= 0.85;
|
||||
};
|
||||
|
||||
const extractEmptyAnchors = (fragment: string) => fragment.match(ANCHOR_RE) ?? [];
|
||||
|
||||
const insertAnchorsIntoParagraph = (paragraph: string, anchors: string[]) => {
|
||||
if (anchors.length === 0) return paragraph;
|
||||
const insertion = anchors.join('');
|
||||
const match = paragraph.match(/^<p\b[^>]*>/is);
|
||||
if (!match) return insertion + paragraph;
|
||||
const index = match[0].length;
|
||||
return paragraph.slice(0, index) + insertion + paragraph.slice(index);
|
||||
};
|
||||
|
||||
const insertPendingAnchorsBeforeBodyEnd = (text: string, anchors: string[]) => {
|
||||
if (anchors.length === 0) return text;
|
||||
const fallback = `<p style="display:none;">${anchors.join('')}</p>\n`;
|
||||
const match = text.match(BODY_END_RE);
|
||||
if (!match || match.index === undefined) return `${text}\n${fallback}`;
|
||||
return text.slice(0, match.index) + fallback + text.slice(match.index);
|
||||
};
|
||||
|
||||
const shouldRemove = (
|
||||
language: BilingualFilterLanguage | 'unknown',
|
||||
removeLanguage: BilingualFilterLanguage,
|
||||
removeUnknown: boolean,
|
||||
) => language === removeLanguage || (removeUnknown && language === 'unknown');
|
||||
|
||||
const filterHtmlText = (text: string, options: Required<BilingualFilterOptions>, path: string) => {
|
||||
const stats = makeEmptyFileStats(path);
|
||||
const output: string[] = [];
|
||||
let pendingAnchors: string[] = [];
|
||||
let position = 0;
|
||||
let changed = false;
|
||||
const mode = options.mode === 'auto' && looksLikeStyleBilingual(text) ? 'style' : options.mode;
|
||||
|
||||
for (const match of text.matchAll(TEXT_BLOCK_RE)) {
|
||||
if (match.index === undefined) continue;
|
||||
output.push(text.slice(position, match.index));
|
||||
|
||||
let paragraph = match[0];
|
||||
const language = classifyParagraph(paragraph, mode);
|
||||
stats.paragraphs += 1;
|
||||
stats.byLanguage[language] += 1;
|
||||
|
||||
if (shouldRemove(language, options.removeLanguage, options.removeUnknown)) {
|
||||
const anchors = extractEmptyAnchors(paragraph);
|
||||
pendingAnchors = [...pendingAnchors, ...anchors];
|
||||
stats.removed += 1;
|
||||
stats.anchorsMoved += anchors.length;
|
||||
changed = true;
|
||||
} else {
|
||||
if (language === 'unknown') stats.unknown += 1;
|
||||
stats.kept += 1;
|
||||
if (pendingAnchors.length > 0) {
|
||||
paragraph = insertAnchorsIntoParagraph(paragraph, pendingAnchors);
|
||||
pendingAnchors = [];
|
||||
changed = true;
|
||||
}
|
||||
output.push(paragraph);
|
||||
}
|
||||
|
||||
position = match.index + match[0].length;
|
||||
}
|
||||
|
||||
output.push(text.slice(position));
|
||||
let newText = output.join('');
|
||||
if (pendingAnchors.length > 0) {
|
||||
newText = insertPendingAnchorsBeforeBodyEnd(newText, pendingAnchors);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return { text: changed ? newText : text, stats };
|
||||
};
|
||||
|
||||
const escapeXml = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const decodeText = (data: Uint8Array) => {
|
||||
const encodings = ['utf-8', 'shift_jis', 'gb18030'];
|
||||
for (const encoding of encodings) {
|
||||
try {
|
||||
const decoder = new TextDecoder(encoding, { fatal: true });
|
||||
return decoder.decode(data);
|
||||
} catch {
|
||||
// Try the next common EPUB encoding.
|
||||
}
|
||||
}
|
||||
return new TextDecoder('utf-8').decode(data);
|
||||
};
|
||||
|
||||
const setDocumentLanguageAndTitle = (text: string, title: string, language: string) => {
|
||||
let updated = text;
|
||||
if (/<title\b[^>]*>.*?<\/title>/is.test(updated)) {
|
||||
updated = updated.replace(/<title\b[^>]*>.*?<\/title>/is, `<title>${escapeXml(title)}</title>`);
|
||||
}
|
||||
if (/\bxml:lang\s*=\s*["'][^"']*["']/i.test(updated)) {
|
||||
updated = updated.replace(/\bxml:lang\s*=\s*(["'])[^"']*\1/i, `xml:lang="${language}"`);
|
||||
}
|
||||
if (/\blang\s*=\s*["'][^"']*["']/i.test(updated)) {
|
||||
updated = updated.replace(/\blang\s*=\s*(["'])[^"']*\1/i, `lang="${language}"`);
|
||||
} else {
|
||||
updated = updated.replace(/<html\b/i, `<html lang="${language}"`);
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
|
||||
const extractOpfTitle = (text: string) => {
|
||||
const match = text.match(/<dc:title\b[^>]*>(.*?)<\/dc:title>/is);
|
||||
return match ? stripTags(match[1] ?? '') : '';
|
||||
};
|
||||
|
||||
const getVariantInfo = (
|
||||
sourceName: string,
|
||||
removeLanguage: BilingualFilterLanguage,
|
||||
metadataTitle?: string,
|
||||
) => {
|
||||
const sourceTitle = metadataTitle?.trim() || getBaseFilename(sourceName);
|
||||
const suffix = removeLanguage === 'ja' ? '简中译本' : '日文原文版';
|
||||
const language = removeLanguage === 'ja' ? 'zh-CN' : 'ja';
|
||||
const title = `${sourceTitle}(${suffix})`;
|
||||
const filename = `${makeSafeFilename(`${sourceTitle}_${suffix}`)}.epub`;
|
||||
const identifierSuffix = removeLanguage === 'ja' ? 'readest-no-ja' : 'readest-no-zh';
|
||||
return { title, filename, language, identifierSuffix };
|
||||
};
|
||||
|
||||
const patchOpfMetadata = (
|
||||
text: string,
|
||||
variant: ReturnType<typeof getVariantInfo>,
|
||||
removeLanguage: BilingualFilterLanguage,
|
||||
) => {
|
||||
let updated = text;
|
||||
const title = escapeXml(variant.title);
|
||||
if (/<dc:title\b[^>]*>.*?<\/dc:title>/is.test(updated)) {
|
||||
updated = updated.replace(
|
||||
/<dc:title\b([^>]*)>.*?<\/dc:title>/is,
|
||||
`<dc:title$1>${title}</dc:title>`,
|
||||
);
|
||||
}
|
||||
if (/<dc:language\b[^>]*>.*?<\/dc:language>/is.test(updated)) {
|
||||
updated = updated.replace(
|
||||
/<dc:language\b([^>]*)>.*?<\/dc:language>/is,
|
||||
`<dc:language$1>${variant.language}</dc:language>`,
|
||||
);
|
||||
}
|
||||
if (/<dc:identifier\b[^>]*>.*?<\/dc:identifier>/is.test(updated)) {
|
||||
updated = updated.replace(
|
||||
/<dc:identifier\b([^>]*)>(.*?)<\/dc:identifier>/gis,
|
||||
(_match, attrs: string, value: string) => {
|
||||
const trimmed = value.trim();
|
||||
const suffix = `:${variant.identifierSuffix}`;
|
||||
const nextValue = trimmed.includes(suffix) ? trimmed : `${trimmed}${suffix}`;
|
||||
return `<dc:identifier${attrs}>${escapeXml(nextValue)}</dc:identifier>`;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
updated = updated.replace(
|
||||
/<\/metadata\s*>/i,
|
||||
`<dc:identifier id="readest-filter-id">readest:${removeLanguage}:${Date.now()}</dc:identifier></metadata>`,
|
||||
);
|
||||
}
|
||||
updated = updated.replace(
|
||||
/<meta\b([^>]*\bproperty\s*=\s*["']dcterms:modified["'][^>]*)>.*?<\/meta>/is,
|
||||
`<meta$1>${new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')}</meta>`,
|
||||
);
|
||||
return updated;
|
||||
};
|
||||
|
||||
const updateRunStats = (runStats: BilingualFilterStats, fileStats: BilingualFilterFileStats) => {
|
||||
runStats.fileStats.push(fileStats);
|
||||
runStats.paragraphs += fileStats.paragraphs;
|
||||
runStats.removed += fileStats.removed;
|
||||
runStats.kept += fileStats.kept;
|
||||
runStats.unknown += fileStats.unknown;
|
||||
runStats.anchorsMoved += fileStats.anchorsMoved;
|
||||
};
|
||||
|
||||
export async function filterBilingualEpubFile(
|
||||
sourceFile: File,
|
||||
filterOptions: BilingualFilterOptions,
|
||||
): Promise<BilingualFilterResult> {
|
||||
const options: Required<BilingualFilterOptions> = {
|
||||
mode: 'auto',
|
||||
removeUnknown: false,
|
||||
...filterOptions,
|
||||
};
|
||||
const {
|
||||
BlobReader,
|
||||
BlobWriter,
|
||||
TextReader,
|
||||
Uint8ArrayReader,
|
||||
Uint8ArrayWriter,
|
||||
ZipReader,
|
||||
ZipWriter,
|
||||
} = await import('@zip.js/zip.js');
|
||||
|
||||
const reader = new ZipReader(new BlobReader(sourceFile));
|
||||
const writer = new ZipWriter(new BlobWriter('application/epub+zip'));
|
||||
const runStats = makeEmptyRunStats();
|
||||
|
||||
try {
|
||||
const entries = await reader.getEntries();
|
||||
const readableEntries = entries.filter(isReadableZipEntry);
|
||||
const opfEntry = readableEntries.find((entry) =>
|
||||
entry.filename.toLowerCase().endsWith(OPF_EXT),
|
||||
);
|
||||
const opfTitle = opfEntry
|
||||
? extractOpfTitle(decodeText(await opfEntry.getData(new Uint8ArrayWriter())))
|
||||
: '';
|
||||
const variant = getVariantInfo(sourceFile.name, options.removeLanguage, opfTitle);
|
||||
const seen = new Set<string>();
|
||||
const mimetype = entries.find((entry) => entry.filename.toLowerCase() === 'mimetype');
|
||||
if (mimetype) {
|
||||
await writer.add('mimetype', new TextReader('application/epub+zip'), { level: 0 });
|
||||
seen.add(mimetype.filename);
|
||||
runStats.filesSeen += 1;
|
||||
}
|
||||
|
||||
for (const entry of readableEntries) {
|
||||
if (seen.has(entry.filename)) continue;
|
||||
seen.add(entry.filename);
|
||||
runStats.filesSeen += 1;
|
||||
|
||||
const lowerName = entry.filename.toLowerCase();
|
||||
const rawData = await entry.getData(new Uint8ArrayWriter());
|
||||
let outData: Uint8Array | string = rawData;
|
||||
|
||||
if (isHtmlPath(lowerName)) {
|
||||
runStats.htmlFiles += 1;
|
||||
const originalText = decodeText(rawData);
|
||||
const filtered = filterHtmlText(originalText, options, entry.filename);
|
||||
const patchedText = setDocumentLanguageAndTitle(
|
||||
filtered.text,
|
||||
variant.title,
|
||||
variant.language,
|
||||
);
|
||||
updateRunStats(runStats, filtered.stats);
|
||||
if (patchedText !== originalText) runStats.changedFiles += 1;
|
||||
outData = patchedText;
|
||||
} else if (lowerName.endsWith(OPF_EXT)) {
|
||||
const originalText = decodeText(rawData);
|
||||
const patchedText = patchOpfMetadata(originalText, variant, options.removeLanguage);
|
||||
if (patchedText !== originalText) runStats.changedFiles += 1;
|
||||
outData = patchedText;
|
||||
}
|
||||
|
||||
const writerReader =
|
||||
typeof outData === 'string'
|
||||
? new TextReader(outData)
|
||||
: new Uint8ArrayReader(outData as Uint8Array);
|
||||
await writer.add(entry.filename, writerReader, zipOptionsForEntry(entry));
|
||||
}
|
||||
|
||||
const blob = await writer.close();
|
||||
const file = new File([blob], variant.filename, { type: 'application/epub+zip' });
|
||||
return { file, filename: variant.filename, title: variant.title, stats: runStats };
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
}
|
||||
|
||||
const isReadableZipEntry = (
|
||||
entry: import('@zip.js/zip.js').Entry,
|
||||
): entry is import('@zip.js/zip.js').FileEntry =>
|
||||
!entry.directory && typeof entry.getData === 'function';
|
||||
|
||||
const zipOptionsForEntry = (entry: import('@zip.js/zip.js').Entry) =>
|
||||
entry.filename.toLowerCase() === 'mimetype' ? { level: 0 } : {};
|
||||
@@ -1,17 +1,4 @@
|
||||
const VIEW_TRANSITION_TIMEOUT = 'Transition was aborted because of timeout in DOM update';
|
||||
|
||||
export const isViewTransitionTimeoutError = (error: unknown) => {
|
||||
if (process.env['NODE_ENV'] !== 'development') return false;
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.name === 'TimeoutError' &&
|
||||
error.message === VIEW_TRANSITION_TIMEOUT
|
||||
);
|
||||
};
|
||||
|
||||
export const handleGlobalError = (e: Error) => {
|
||||
if (isViewTransitionTimeoutError(e)) return;
|
||||
|
||||
const isChunkError = e?.message?.includes('Loading chunk');
|
||||
|
||||
if (!isChunkError) {
|
||||
|
||||
@@ -132,6 +132,10 @@ export const navigateToLibrary = (
|
||||
router.replace(`/library${queryParams ? `?${queryParams}` : ''}`, navOptions);
|
||||
};
|
||||
|
||||
export const navigateToReviewEditor = (router: ReturnType<typeof useRouter>) => {
|
||||
router.push('/review-editor');
|
||||
};
|
||||
|
||||
// Recovery action when a reader has nothing to display — e.g. all books were
|
||||
// closed, or a book failed to load in a freshly-opened reader window.
|
||||
// In a dedicated reader window we close the window itself, ensuring the main
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
epub_review_sessions/
|
||||
review_sessions/
|
||||
*.log
|
||||
*.epub
|
||||
@@ -0,0 +1,281 @@
|
||||
# EPUB 审校器长期维护手册
|
||||
|
||||
## 目标与边界
|
||||
|
||||
本工具服务于中日双语 EPUB 的网页审校:长篇连续阅读、逐段修改中文译文、标记翻译问题、导出反馈与修订 EPUB。它也提供可选的书架入口 AI 翻译模块,用于对已入书架的日语 EPUB 发起 API 辅助首译,并生成中日双语或纯中文 EPUB。翻译质量规则仍由项目根目录的 `AGENTS.md`、`rules/translation_rules.md`、当前系列的 `series-style.md` 与术语表共同约束。
|
||||
|
||||
AI 翻译模块是可选辅助首译入口,不替代长期项目里的候选词确认、复审、QA 与正式交付流程。用户在网页中留下的编辑、问题备注、长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。网页术语表编辑是用户显式操作正式术语表的入口,保存时会直接写入配置路径指向的 JSON 文件。
|
||||
|
||||
## 数据模型与会话目录
|
||||
|
||||
`--review-root` 是唯一需要备份的运行数据目录。典型结构:
|
||||
|
||||
```text
|
||||
epub_review_sessions/
|
||||
app_state.json
|
||||
gpt_config.json
|
||||
library.json
|
||||
series_configs/
|
||||
<series-id>.json
|
||||
translation_jobs/
|
||||
<job-id>/
|
||||
job.json
|
||||
translations.json
|
||||
translated_outputs/
|
||||
*_AI翻译_*.epub
|
||||
<session-id>/
|
||||
source.epub
|
||||
extracted/
|
||||
review_state/
|
||||
epub_entries.json
|
||||
rows.json
|
||||
session.json
|
||||
state.json
|
||||
feedback/
|
||||
translation_feedback.jsonl
|
||||
feedback_for_codex.md
|
||||
exports/
|
||||
```
|
||||
|
||||
兼容要求:
|
||||
|
||||
- `rows.json` 是初始解析结果,不应因用户编辑而重写。
|
||||
- `state.json.edits` 保存用户修改与标记,字段缺省时必须能回退到原始译文。
|
||||
- `state.json.reading_position` 保存当前阅读位置,字段包括 `mode`、`scope`、`chapter_id`、`part_id`、`image_item_id`、`row_id`、`scroll_top`、`offset`、`updated_at`;缺省时必须回退到首页/首章。
|
||||
- `session.json` 与 `app_state.json` 只保存会话定位信息。
|
||||
- `gpt_config.json` 只属于本地运行环境,不写入 EPUB、不写入导出文件、不通过 API 回传密钥。
|
||||
- `gpt_config.json` 可保存本地提示词设置与术语表路径,但仍不得写入 EPUB 或导出文件。
|
||||
- `library.json` 是由 EPUB 元数据和会话状态生成的书架索引,可重建;字段包括书籍、系列、作者、出版社、语言、封面和更新时间。
|
||||
- `series_configs/<series-id>.json` 保存同系列共享的 `glossary_path`、`translation_prompt`、`format_prompt`、`character_prompt`,不得保存 API Key、Base URL 或模型密钥。
|
||||
- `translation_jobs/<job-id>/job.json` 保存整书 AI 翻译任务状态、进度、输出路径和日志;不得保存 API Key。
|
||||
- `translated_outputs/` 保存 AI 翻译生成的 EPUB;生成后应创建独立 session,使 `/api/sessions` 和书架自然可见。
|
||||
- Readest 迁移阶段的本地入口委托 `tools/epub-review-editor/open_editor.ps1` 或 `/api/review-editor/launch` 创建 `.venv`、安静检测 Flask 依赖、缺失时自动安装依赖、复用同版本已运行服务或启动 `server.py --daemon`;该入口不得硬编码用户私有路径。
|
||||
|
||||
## 目录与插图
|
||||
|
||||
结构接口 `/api/structure` 应保持向后兼容:
|
||||
|
||||
- text chapter: `kind: "text"`、`parts`、`row_count`、`touched_count`、`marked_count`
|
||||
- image item: 优先作为 text chapter 的 `items` 子项返回,字段为 `kind: "image"`、`images`、`parts: []`、`row_count: 0`;没有所属文字章时才保留顶层 image chapter
|
||||
- 统计字段同时保留总数和细分:`chapter_count`、`text_chapter_count`、`image_chapter_count`、`top_level_image_chapter_count`、`image_count`、`asset_count`。其中 `image_chapter_count` 表示目录里的图片条目数,不一定是顶层章节数
|
||||
- part 标题默认使用 `part0000` 形式,真实标题可保留在 `source_title`
|
||||
- 插图通过 `/api/asset/<entry>` 读取,必须继续使用 `extracted_path()` 防止路径穿越;只把 raster 图片纳入 `/api/structure` 与 `/api/asset` 白名单,不能任意读取解包目录文件
|
||||
|
||||
章节来源优先级:
|
||||
|
||||
1. EPUB 内部目录页的中文条目
|
||||
2. `nav.xhtml` / `toc.xhtml`
|
||||
3. 正文 `<h1-6>` 标题
|
||||
4. 文件名 fallback
|
||||
|
||||
不要把正文首段或第一句对白作为章节标题 fallback。宁可显示 `part0000`,也不要让目录被正文内容污染。
|
||||
|
||||
## 阅读与侧栏交互
|
||||
|
||||
验收口径:
|
||||
|
||||
- 顶栏左上角必须保留“书架”入口;书架是独立页面,列出 `--review-root` 下历史打开过的审校书籍/会话。
|
||||
- 点击书架条目必须通过会话打开流程进入对应审校页面,并恢复该书上次阅读位置;从审校页进入书架或切换书籍前必须保存当前阅读位置并沿用未保存编辑保护。
|
||||
- 没有历史书籍时,书架显示空状态并提供“打开新的 EPUB”入口;开始页/书架页顶栏保持正常显示,不套用审校页沉浸隐藏。
|
||||
- 书架应按 EPUB 元数据建立分类:全部书籍、同系列、作者、出版社、语言;同系列分类使用稳定 `series_id`,显示书籍数量。
|
||||
- 书架主区域以封面网格为主,不用横向信息卡替代封面;无封面时使用稳定 fallback,不出现破图或布局跳动。
|
||||
- 鼠标悬停或键盘 focus 到封面时,封面变暗并显示书名、系列/作者/卷数、审校段数、最近更新时间,以及“校对”“翻译”“删除”按钮;点击“翻译”或“删除”不得触发“校对”。
|
||||
- 书架删除默认是软删除:只在 session manifest/state 中写入 `hidden` / `deleted_at`,从 `/api/sessions` 与 `/api/library` 隐藏,不删除原始 EPUB、导出 EPUB 或反馈文件;删除 active session 时必须清空 `active_session_id`。
|
||||
- 选中同系列分类后,必须能打开“系列设置”,设置该系列共享的术语表路径、翻译内容提示词、格式提示词和角色口吻提示词。
|
||||
- 初始进入审校时,左侧侧栏隐藏,正文占主要空间。
|
||||
- 顶部“目录”只打开目录,“检索”只打开搜索/段落列表,不合并成一个常驻面板。
|
||||
- 目录可展开/收起章节,插图作为所属章节的次级目录条目可点击阅读,文字 part 可点击定位。
|
||||
- 点击目录章节、part 或插图条目后,侧栏不得自动收起;用户仍可点“隐藏”或按 `Escape` 关闭。
|
||||
- 检索必须保留“当前范围 / 全书全局”切换;当前范围随当前章节或 part 更新,全局检索可跨章节返回并跳转。
|
||||
- 阅读模式不显示每段编号、快速编辑、快速标记按钮。
|
||||
- 阅读区提供默认、护眼、夜间背景切换,并允许调整字号与行距;设置可保存在浏览器本地,不要求写入 EPUB 或 session。
|
||||
- 审校页顶部标题栏与阅读标题栏默认收起,鼠标移到页面上方或键盘焦点进入标题栏时再显示;开始页保持正常显示;展开后不得遮住侧栏“隐藏”或审校工具“关闭”等面板操作。
|
||||
- 鼠标停留在顶栏或阅读标题栏时,沉浸顶栏不得因自动计时过早收起;前端脚本和样式发布时应带版本参数或等效缓存失效机制,避免浏览器继续执行旧入口代码。
|
||||
- 阅读区“上一节 / 下一节”必须按 spine/目录内的小章节顺序移动,覆盖文字 part 与插图条目,不得只跳顶层大章节。
|
||||
- 点击正文段落打开右侧精修与重翻面板。
|
||||
- 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。
|
||||
- 刷新页面、重启服务或从已有会话重新打开 EPUB 时,应恢复到上次阅读的章节/part/插图、段落和滚动位置。
|
||||
- 移动窄屏下,侧栏和右侧面板应以抽屉/底部面板方式显示,不遮死主要操作。
|
||||
|
||||
## 编辑、反馈与导出
|
||||
|
||||
保存本段时必须:
|
||||
|
||||
- sanitize 中文 HTML,但允许 `ruby/rb/rt/rp/mark/span` 等必要内联标签。
|
||||
- 记录 `before_cn_html`、`after_cn_html`、问题类型、严重程度、标签、备注、长期规则建议。
|
||||
- 更新 `feedback/translation_feedback.jsonl`。
|
||||
|
||||
生成反馈时必须:
|
||||
|
||||
- 写入 `feedback_for_codex.md`。
|
||||
- 只汇总已编辑、已标记或有备注的段落。
|
||||
- 保留日文原文、修改前、修改后,便于后续翻译规则复盘。
|
||||
|
||||
导出 EPUB 前必须先写回已保存的译文修改,插图文件保持原样。
|
||||
|
||||
## GPT 重翻
|
||||
|
||||
GPT 能力是可选增强,不应影响离线审校。
|
||||
|
||||
安全与配置:
|
||||
|
||||
- 支持 OpenAI 兼容的 `base_url`、`model`、`api_key`。
|
||||
- 支持单独设置 `translation_prompt`、`format_prompt`、`character_prompt`;缺省时必须回退到内置预设。
|
||||
- API Key 优先读取 `gpt_config.json`,也支持环境变量 `OPENAI_API_KEY`。
|
||||
- `/api/gpt/config` 不得返回 API Key。
|
||||
- `/api/gpt/config` 可以返回提示词与预设,但不得返回密钥。
|
||||
- `/api/glossary` 读取和保存配置路径指向的 `mingcibiao.json`;默认路径应由当前打开 EPUB 对应的系列目录或用户保存的配置决定,不应在通用维护口径中硬编码某个具体系列。
|
||||
- 未保存配置时,可在项目根目录下唯一的一级系列目录 `mingcibiao.json` 中自动推断默认术语表;若无法唯一推断,则回退到项目根目录 `mingcibiao.json` 并允许用户在网页中显式设置。
|
||||
- 术语表保存应保持 JSON 对象结构:key 为源语或匹配词,value 为译名,可继续使用 `#` 作为备注分隔。
|
||||
- 重翻时必须实时读取术语表,按目标段落日文纯文本、日文 HTML 与 ruby/rt 命中相关条目,不得只在提示词里泛泛要求“遵守术语表”。
|
||||
- 未配置 Key 时,重翻接口应返回可读错误,不影响编辑保存。
|
||||
- 重翻请求不得临时覆盖 `base_url` 或 `model` 后继续复用已保存密钥;要更换端点或模型必须先保存配置,避免把密钥发往未确认端点。
|
||||
|
||||
单段重翻流程:
|
||||
|
||||
1. 用户点击阅读段落,打开右侧面板。
|
||||
2. 用户可填写额外重翻要求。
|
||||
3. `/api/row/<row_id>/retranslate` 使用提示词、目标语句、实时命中的术语表条目、同文件前后一段少量上下文、角色口吻提示生成候选。
|
||||
4. 候选只显示在面板里,不自动覆盖。
|
||||
5. 用户点击“应用重翻”后写入编辑框,再点击保存才进入审校记录。
|
||||
6. 候选事件写入 jsonl,便于后续根据精修反馈优化提示词;当前版本不自动改写提示词。
|
||||
|
||||
术语实时命中实现口径:
|
||||
|
||||
- 每次 `/api/row/<row_id>/retranslate` 请求都必须读取当前配置的 glossary 文件,不使用服务启动时的缓存作为唯一来源。
|
||||
- 单段重翻的术语命中范围只包括目标段落日文纯文本和日文 HTML 中的可见文本/ruby/rb/rt 信息;当前中文译文不能反向触发新术语,避免把旧误译或幻觉带入 prompt。
|
||||
- 传给 GPT 的目标日文 HTML 只保留必要内联标签和 ruby 结构,不携带 class/style/data-* 等属性,避免属性值污染术语命中或 prompt。
|
||||
- 前后一段上下文只用于语气、指代和连贯性参考,不参与单段重翻的术语命中;单段重翻的 `glossary_matches` 只由目标段落自身触发。
|
||||
- 正式术语表 value 如包含 `#`,`#` 前是指定译名,`#` 后只是备注,不写入正文。
|
||||
- 未命中术语时,重翻 prompt 不得塞入无关术语兜底规则;例如只有原文出现 `ファルシオン` 或 `Falchion` 时,才可命中“大砍刀”。
|
||||
- 读取旧版保存提示词时,若只剩“优先服从给定名词表”这类泛泛规则,应回退到当前内置预设,避免用户继续沿用已淘汰的全局术语设计。
|
||||
- 任何术语特例都不得写入全局 prompt。Falchion / ファルシオン / 大砍刀 只能由 `glossary_matches` 动态生成;未命中时,重翻 messages 中不得出现这些字符串。
|
||||
- 命中结果必须随候选事件写入 jsonl,字段至少包含 `glossary_path` 与 `glossary_matches`;未命中时记录空数组,便于排查 prompt。
|
||||
|
||||
Prompt 第一性原理:
|
||||
|
||||
- prompt 必须只携带目标段落、少量上下文、命中术语、系列风格摘要和输出格式;单段重翻不携带完整术语表。
|
||||
- prompt 不得只泛泛要求遵守术语表,必须列出本段命中的具体术语;未命中的术语不得进入提示。
|
||||
- prompt 必须要求只输出修订后的中文 HTML,不输出说明。
|
||||
- prompt 必须要求不合并、不拆分段落,不删除段落编号或必要标签。
|
||||
- prompt 必须要求保留有意义 ruby 为真实 `<ruby><rb>...</rb><rt>...</rt></ruby>`。
|
||||
|
||||
Prompt 硬规则:
|
||||
|
||||
- 输出简体中文轻小说文风。
|
||||
- 意义优先,不贴日语语序硬译。
|
||||
- 不合并、不拆分段落。
|
||||
- 有意义 ruby 必须保留为真实 `<ruby><rb>...</rb><rt>...</rt></ruby>`,不要改成括号。
|
||||
- 标点统一为简中出版格式:对话引号用「」,省略号用……,破折号用——,问号用?,感叹号用!,问叹连用用?!/!?。
|
||||
|
||||
## 整书 AI 翻译
|
||||
|
||||
整书 AI 翻译从书架页进入,只处理已经上传或打开过、因此已有 session 的 EPUB。它必须作为后台任务运行,前端只负责创建任务、轮询进度、展示日志和打开输出 session。
|
||||
|
||||
后端接口口径:
|
||||
|
||||
- `GET /api/translation/defaults` 返回当前公开 GPT 配置、默认提示词、默认术语表路径、默认范围和输出模式,不返回 API Key。
|
||||
- `GET /api/session/<session_id>/translation-source` 只读取指定 session,统计可翻译日文正文块和样例,不切换 active session;必须同时返回诊断统计,说明审校行数、可翻译正文块、图片、目录、空白块不是同一口径。
|
||||
- `GET /api/library` 与 `GET /api/sessions` 返回按 EPUB 元数据生成的书架索引、系列列表和封面 URL。
|
||||
- `GET/POST /api/series/<series_id>/config` 读写同系列翻译配置;空字段表示未设置,不能自动写入全局默认 prompt。
|
||||
- `POST /api/translation/start` 创建后台任务并快速返回 `job_id`;请求不得临时覆盖 `base_url` 或 `model` 后复用已保存密钥,要更换端点或模型必须先保存配置。
|
||||
- `GET /api/translation/jobs/<job_id>` 返回任务状态、进度、日志、输出 EPUB 和输出 session id,不返回 API Key。
|
||||
|
||||
翻译输入与提示词:
|
||||
|
||||
- 日语源 EPUB 需要新的源段落抽取器,不复用只识别双语灰字的 `build_rows()`。
|
||||
- 翻译源抽取器至少覆盖 `<p>` 与正文 `<h1-h6>`;日文上下文中的纯汉字/标点短句应进入翻译队列,纯图片、空白和目录页不进入。
|
||||
- 默认范围必须是前 N 段试译,当前默认 20 段;全书翻译必须由用户显式选择。
|
||||
- 每段请求只携带目标段落日文 HTML、前后一段少量上下文、该段实时命中的术语、翻译提示词、格式提示词与角色口吻提示词。
|
||||
- 不得把完整术语表塞进 prompt;未命中的术语不得进入 prompt。
|
||||
- 若同系列配置存在,整书翻译优先使用同系列的术语表路径和提示词;API Key、Base URL、模型仍使用全局 API 配置。
|
||||
- 输出只接受当前段中文 HTML 片段,随后进行中文标点规整、HTML sanitize 与术语 ruby 括号形式修复。
|
||||
|
||||
输出与书架:
|
||||
|
||||
- `bilingual` 模式在原日文 `<p>` 后插入中文 `<p>`,并把原日文段落标为灰色且标记为 source,方便进入现有双语审校器逐段审校。
|
||||
- 如果输入已经是中日双语 EPUB,翻译源抽取必须识别显式灰色/source 源段与下一段中文译文的 pair;`bilingual` 模式应替换已有中文层,不得追加第二个中文层;`translated` 模式应输出新的中文层并移除对应日文源层。
|
||||
- 如果历史 session 已经出现“日文源层 + 中文层 + 旧中文灰色源层 + 新中文层”的四层坏结构,进入翻译源统计、整书翻译或 rows 重建前应自动清理为“日文源层 + 第一个中文层”,避免旧中文继续被当作日文源文。
|
||||
- `translated` 模式用中文替换原日文段落;该输出会加入书架,但由于没有日中成对段落,可能没有逐段双语审校行。
|
||||
- 图片、样式、OPF、spine 和非正文文件应原样保留。
|
||||
- 翻译失败的单段必须写入可见占位和任务日志,不能让整本输出静默缺段。
|
||||
- 输出 EPUB 创建 session 时不应自动切换用户当前 active session;只有用户点击“打开输出审校”才切换。
|
||||
|
||||
任务安全与恢复:
|
||||
|
||||
- 后台 job 不能在网络调用期间持有全局锁;只在写状态文件时短暂加锁或原子写入。
|
||||
- `job.json` 和中间 `translations.json` 必须原子写入,避免前端轮询读到半截 JSON。
|
||||
- 日志、状态、EPUB 元数据和反馈文件不得包含 API Key。
|
||||
- 任务失败时应将状态置为 `failed` 并写清楚错误,不能让前端永久停在 running。
|
||||
- 后续如加入暂停/取消/恢复,必须保持现有 `job.json` 字段向后兼容。
|
||||
|
||||
## 版本规则
|
||||
|
||||
版本号使用 `version = "0.1.0"` 格式:
|
||||
|
||||
| 层级 | 触发条件 |
|
||||
| --- | --- |
|
||||
| PATCH | 修复 bug 或兼容性小修 |
|
||||
| MINOR | 新增向后兼容能力、API 字段或可选配置 |
|
||||
| MAJOR | 破坏兼容的 API、配置或行为变更 |
|
||||
|
||||
发版时必须同步:
|
||||
|
||||
- `version.py`
|
||||
- `static/index.html` 顶部版本徽标 fallback
|
||||
- `README.md` 当前版本与版本说明
|
||||
- 必要时同步 `MAINTENANCE.md` 中的维护口径
|
||||
|
||||
版本文档同步边界:
|
||||
|
||||
- 审校器自身行为、API、配置、前端交互变化,同步本文件。
|
||||
- 会影响整个翻译项目的规则变化,同步项目根目录 `AGENTS.md` 与 `rules/translation_rules.md`,不要只写在审校器维护手册中。
|
||||
- 对外最小包内容、启动方式或发布说明变化,同步 README。
|
||||
|
||||
## 回归清单
|
||||
|
||||
每轮改动至少检查:
|
||||
|
||||
- `python -B -m py_compile tools/epub-review-editor/server.py tools/epub-review-editor/version.py`
|
||||
- `node --check tools/epub-review-editor/static/app.js`
|
||||
- `powershell -NoProfile -ExecutionPolicy Bypass -File apps/readest-app/tools/epub-review-editor/open_editor.ps1` 能启动服务并打开书架/上传页
|
||||
- Readest dev-web 中 `POST /api/review-editor/launch` 必须带 `x-readest-review-editor-launch: 1`,只能从本机入口调用;成功时返回 sidecar URL、版本和 review root
|
||||
- 重复执行一键入口时优先复用同版本已运行服务;如需新起服务,端口探测不得允许多个服务同时监听同一端口
|
||||
- `git diff --check`
|
||||
- `/api/version`
|
||||
- `/api/session`
|
||||
- `/api/structure`
|
||||
- `/api/gpt/config`
|
||||
- 在 Readest dev-web 的 `/review-editor` 中嵌入本地 sidecar 时,iframe 不应出现 `ERR_BLOCKED_BY_RESPONSE`;HTML 入口应带限定本机 Readest 的 `frame-ancestors`,并保留 `Cross-Origin-Embedder-Policy: require-corp` 与 `Cross-Origin-Resource-Policy: cross-origin`
|
||||
- 首个插图 `/api/asset/...` 返回 200 且浏览器可显示
|
||||
- 非白名单或非 raster asset 不能被 `/api/asset/...` 读取
|
||||
- 目录按钮、检索按钮、隐藏侧栏、点击段落打开右侧面板
|
||||
- 阅读段落下方没有编号和“快速编辑/快速标记”
|
||||
- 未配置 API Key 时重翻返回清晰错误
|
||||
- 配置保存后 `/api/gpt/config` 不包含密钥字段
|
||||
- `/api/glossary` 能读取术语表;新增、修改、删除条目后保存,再读取内容一致
|
||||
- 重翻候选事件包含 `glossary_path` 与 `glossary_matches`
|
||||
- 无关段落的重翻 messages 不得包含 `大砍刀`、`Falchion` 或 `ファルシオン`;含 `ファルシオン` 或 ruby `Falchion` 的段落必须命中“大砍刀”
|
||||
- 顶栏复杂操作收敛在“审校工具”入口;未选择段落时仍可配置 AI、术语表并执行反馈与交付,段落编辑/重翻按钮禁用
|
||||
- `/api/reading-position` 能保存/读取位置;刷新或重启后回到上次阅读处
|
||||
- 阅读外观默认/护眼/夜间、字号、行距切换即时生效,刷新后仍保留;夜间模式下正文、原文、按钮和侧栏文本可读
|
||||
- 审校页顶部栏和阅读标题栏默认不遮挡正文,鼠标移到页面上方后可操作;开始页顶栏不隐藏;目录侧栏和审校工具打开时,其顶部按钮仍可直接点击
|
||||
- 鼠标停留在顶栏按钮区域时,书架/打开 EPUB/审校工具按钮能连续点击,不会在点击前自动收起;浏览器重新加载后应使用当前版本 app.js/style.css
|
||||
- “上一节 / 下一节”在相邻 part 和章节内插图之间移动,不再按顶层大章节跳转
|
||||
- 顶栏左上角“书架”能打开独立书架页;书架列出历史会话,空状态可进入上传页,点击书籍可进入对应审校页并恢复阅读位置
|
||||
- 书架左侧分类可按全部书籍、同系列、作者、出版社、语言筛选;右侧封面网格在桌面端悬停变暗显示信息和“校对/翻译”,移动端不依赖 hover 才能操作
|
||||
- `GET /api/sessions` 和 `GET /api/library` 返回 `library`、`series`、书籍元数据、`series_id`、`cover_url`,并写入可重建的 `library.json`
|
||||
- `GET/POST /api/series/<series_id>/config` 能读写同系列术语表和提示词;保存后同系列其他书进入翻译页会自动套用
|
||||
- 书架卡片“翻译”按钮能进入独立翻译页,且不会触发卡片的“进入审校”
|
||||
- `/api/translation/defaults` 不返回 API Key,提示词和术语表路径可读取
|
||||
- `/api/session/<session_id>/translation-source` 能统计日语 EPUB 可翻译正文块和诊断字段;非日语或无正文时返回清晰空状态;审校行数、源正文块数、试译 limit 不得在 UI 上混为一个“段落数”
|
||||
- 翻译页默认只显示源书名和“可翻译正文块 N 个”,不保留冗余段落预览、诊断 note 或大批统计 chip 的可见入口;诊断字段可保留在 API 中供排查
|
||||
- 未配置 API Key 时,`/api/translation/start` 返回清晰错误,不影响阅读、编辑、导出和单段重翻
|
||||
- 启动整书翻译任务后,`/api/translation/jobs/<job_id>` 可看到 pending/running/completed/failed、总段数、已完成数、当前文件和日志
|
||||
- 默认翻译范围是前 20 段试译;全书翻译必须由用户显式切换并确认
|
||||
- 整书翻译每段只注入该段源文/ruby 命中的术语,不注入无关术语或全表
|
||||
- 整书翻译完成后,输出 EPUB 写入 `translated_outputs/` 并自动出现在书架;中日双语输出打开后 `row_count > 0`
|
||||
- 已双语 EPUB 再次执行整书翻译时,输出不得出现“旧中文译文 + 新中文译文”重复层;审校行 `jp_text` 不得变成中文旧译文
|
||||
- 纯中文输出可以加入书架,且文档说明其不适合逐段双语审校
|
||||
- 离开翻译页、返回书架或打开输出审校时,前端进度轮询会停止
|
||||
- 书架卡片“删除”按钮能软删除会话、刷新书架、清空 active session(如删除当前书),且不会删除原始 EPUB 文件
|
||||
@@ -0,0 +1,51 @@
|
||||
# Readest EPUB 审校器迁移记录
|
||||
|
||||
## 当前定位
|
||||
|
||||
本目录是从长期翻译项目的 EPUB 审校器迁移进 Readest 的第一阶段基座。当前保留原 Python/Flask 后端与静态审校 UI,Readest 负责提供入口、启动本地 sidecar,并在 `/review-editor` 页面中承载审校器。
|
||||
|
||||
## 入口
|
||||
|
||||
- Readest 书架页菜单:`Settings Menu -> Advanced Settings -> EPUB 审校器`
|
||||
- Next 路由:`/review-editor`
|
||||
- 本地启动 API:`POST /api/review-editor/launch`
|
||||
- 开发脚本:`pnpm epub-reviewer:dev`
|
||||
|
||||
当前 Readest 页面内自动启动仅限本地 `dev-web` 开发环境。生产 Tauri 包会静态导出 Next 页面,不能依赖 Next API route 启动本地进程;正式迁移到桌面包前,需要改为 Tauri desktop command 或打包 sidecar。
|
||||
|
||||
Readest dev-web 页面带跨源隔离头;本地 sidecar 会为 HTML 入口返回限定本机 Readest 来源的 `frame-ancestors`,并发送 `Cross-Origin-Embedder-Policy: require-corp` 与 `Cross-Origin-Resource-Policy: cross-origin`,否则 Chromium 会把 iframe 请求拦截为 `ERR_BLOCKED_BY_RESPONSE`。
|
||||
|
||||
## 运行数据
|
||||
|
||||
默认审校数据写入:
|
||||
|
||||
```text
|
||||
apps/readest-app/epub_review_sessions/
|
||||
```
|
||||
|
||||
可用环境变量覆盖:
|
||||
|
||||
```text
|
||||
READEST_REVIEW_ROOT=<absolute path>
|
||||
```
|
||||
|
||||
该目录包含书架索引、GPT 配置、翻译任务、审校 session、反馈和导出文件,必须保持在 git 之外。
|
||||
|
||||
## 迁移边界
|
||||
|
||||
当前阶段不重写审校器业务逻辑,不把 Flask API 立即拆成 Next API 或 Tauri command。这样可以先保留已验证的功能闭环:书架、上传、双语审校、术语表、GPT 重翻、整书 AI 翻译和导出。
|
||||
|
||||
后续原生化顺序建议:
|
||||
|
||||
1. 把启动 sidecar 从 Next dev API 迁到 Tauri desktop command,生产桌面包不依赖 Next API route。
|
||||
2. 复用 Readest 的书库与文件选择能力,把单书“发送到审校器”入口接到 EPUB 文件路径。
|
||||
3. 逐步把书架/审校/翻译 UI 拆成 React 组件,最后再替换 Flask API。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 本地 `dev-web` 环境下,`/review-editor` 能从 Readest 书架菜单进入。
|
||||
- 首次进入会创建 `.venv`、安装 Flask 依赖并启动 `server.py --daemon --no-browser`。
|
||||
- 已运行同版本审校器时优先复用 `localhost:5177+` 端口,不重复启动。
|
||||
- iframe 中能显示审校器书架/上传页。
|
||||
- 审校数据目录不会进入 git。
|
||||
- 原审校器 `server.py` 能通过 Python 编译检查,迁移入口 TypeScript 能通过类型检查。
|
||||
@@ -0,0 +1,218 @@
|
||||
# 双语 EPUB 审校编辑器
|
||||
|
||||
当前版本:`0.13.2`
|
||||
|
||||
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
|
||||
|
||||
## 版本规则
|
||||
|
||||
维护版本号使用 `version = "0.1.0"` 这种语义化格式:
|
||||
|
||||
| 层级 | 触发条件 |
|
||||
| --- | --- |
|
||||
| PATCH | 修复 bug 或兼容性小修 |
|
||||
| MINOR | 新增向后兼容能力、API 字段或可选配置 |
|
||||
| MAJOR | 破坏兼容的 API、配置或行为变更 |
|
||||
|
||||
本次从 `0.1.0` 升到 `0.2.0`,因为新增了独立上传、会话管理、下载接口与可选 `--host` 配置,且保持原命令行 EPUB 路径用法兼容。
|
||||
|
||||
`0.2.1` 修复目录章节识别:优先读取 EPUB 内部目录页的中文标题,并把目录锚点之间的多个正文 part 合并为同一章;左侧目录增加章节展开/收起按钮。
|
||||
|
||||
`0.3.0` 新增插图阅读能力:目录中会把 EPUB spine 里的插图页作为独立章节显示,阅读模式可直接查看图片;正文子章节标题统一使用 `part0000` 这类规整编号,方便长篇审校快速定位。
|
||||
|
||||
`0.4.0` 新增阅读型交互与 GPT 重翻:左侧目录/检索改为按需打开的独立侧栏;阅读模式去掉每段下方操作按钮,点击段落直接打开右侧精修与重翻面板,并在面板挤开正文后恢复原阅读位置;本地可配置 OpenAI 兼容 GPT API,对单段生成重翻候选,确认后再应用到编辑框。
|
||||
|
||||
`0.4.1` 修复后端/API 安全与结构细节:章节内插图与文字按 spine 顺序 flush,结构统计补充 text/image 细分字段;内部目录识别增加目录页/链接密度判定;结构和 `/api/asset` 仅允许已识别的 raster 图片并加 `nosniff`;重翻接口不再允许单次请求临时覆盖 Base URL 或模型后复用已保存密钥。
|
||||
|
||||
`0.5.0` 改进长篇目录与检索:目录章节标题不再用正文首段猜测,章节内插图作为次级条目挂在所属章节下,阅读时可按原 spine 顺序穿插显示;点击目录不会自动收起侧栏,桌面端正文会为常驻侧栏让位;检索新增“当前范围 / 全书全局”切换。
|
||||
|
||||
`0.6.0` 新增 GPT 提示词设置:可在网页中单独配置“翻译内容提示词”“格式与标点提示词”“角色状态与口吻提示词”,并提供默认预设;单段重翻只携带目标语句、相关名词表、少量前后文和角色口吻提示,候选输出会进行中文标点规整与术语 ruby 括号形式修正。
|
||||
|
||||
`0.7.0` 新增实时术语表能力:GPT 重翻会从配置的 `mingcibiao.json` 实时读取命中术语并写入提示词;网页中可查看当前术语表路径、搜索术语,新增、修改、删除条目并保存,保存后的术语会立即用于下一次重翻。
|
||||
|
||||
`0.8.0` 新增阅读位置恢复:阅读器会自动记录当前章节/part/插图、当前可见段落和滚动位置;重新打开服务、刷新页面或切回已有会话时,会回到上次阅读的位置。
|
||||
|
||||
`0.9.0` 收敛重翻与审校工具入口:顶栏复杂操作统一到“审校工具”;单段重翻只携带目标段落真实命中的术语,移除无关段落里的 Falchion/大砍刀全局注入,并按第一性原理精简 prompt。
|
||||
|
||||
`0.10.0` 新增阅读外观与沉浸阅读:阅读区可切换默认、护眼、夜间背景,并保存字号与行距;审校页顶部栏和阅读标题栏默认收起,鼠标移到页面上方时再显示;阅读区上一节/下一节按 part 或插图小章节移动。
|
||||
|
||||
`0.11.0` 新增书架页面:顶栏左上角提供“书架”入口,集中列出历史打开过的审校书籍;点击书籍可直接进入对应审校页面,并继续沿用该书的上次阅读位置。
|
||||
|
||||
`0.11.1` 修复沉浸顶栏在鼠标停留时过早收起的问题,并给前端静态资源加入版本参数,避免浏览器缓存旧脚本导致新入口不可用。
|
||||
|
||||
`0.12.0` 新增书架入口的 AI 翻译模块:可从书架选择日语 EPUB,设置 OpenAI 兼容 API、提示词、术语表、翻译范围与输出模式,后台显示翻译进度;完成后生成的中日双语或纯中文 EPUB 会自动加入书架。
|
||||
|
||||
`0.12.1` 新增一键启动入口:项目根目录提供 `打开EPUB审校器.cmd`,会自动准备本地 Python 环境、安装依赖并以前台浏览器打开书架/上传页。
|
||||
|
||||
`0.12.2` 修复一键启动入口首次运行时依赖检测会被 Flask 缺失的 traceback 中断的问题;现在会先安静检测依赖,缺失时自动安装,再启动网页工具。重复打开入口时会优先复用同版本已运行服务,并修正端口探测,避免多个服务同时监听同一端口。
|
||||
|
||||
`0.13.0` 新增元数据书架与同系列配置:书架按 EPUB 元数据建立 `library.json`,左侧提供系列、作者、出版社、语言分类,封面悬停显示书籍信息以及“校对/翻译”入口;同系列可共享术语表路径、翻译内容提示词、格式提示词和角色口吻提示词。AI 翻译源统计改为正文块诊断,区分审校行数、可翻译日文正文块、图片、目录和空白块,并补翻日文标题与日语上下文里的短句,避免把默认试译范围误认为正文被删。
|
||||
|
||||
`0.13.1` 修复整书 AI 翻译重复正文块:已是中日双语的 EPUB 再翻译时会识别旧的日文源文/中文译文 pair,并替换中文层,不再把旧译文当成源文追加新译文;审校行识别也收敛为只接受显式灰色/source 标记。翻译页删除冗余段落预览和诊断提示,统一使用“正文块”口径,书架封面悬停菜单新增“删除”,可从书架软删除会话且不删除原始 EPUB。
|
||||
|
||||
`0.13.2` 修复迁移到 Readest dev-web 后被浏览器阻止 iframe 加载的问题:本地 sidecar 为 HTML 入口添加限定本机 Readest 的 `frame-ancestors`,并通过 `Cross-Origin-Embedder-Policy: require-corp` 与 `Cross-Origin-Resource-Policy: cross-origin` 兼容 Readest 的跨源隔离页面。
|
||||
|
||||
## 独立安装
|
||||
|
||||
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub-review-editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
./.venv/bin/python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 在 Readest 中启动
|
||||
|
||||
Readest 迁移阶段的推荐入口:
|
||||
|
||||
```powershell
|
||||
pnpm dev-web
|
||||
```
|
||||
|
||||
然后打开:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3000/review-editor
|
||||
```
|
||||
|
||||
也可以在 Readest 书架页菜单中进入:
|
||||
|
||||
```text
|
||||
Advanced Settings -> EPUB 审校器
|
||||
```
|
||||
|
||||
第一次进入会通过 `/api/review-editor/launch` 自动创建本目录下的 `.venv`、安装依赖并启动本地 sidecar。审校数据默认保存在:
|
||||
|
||||
```text
|
||||
apps/readest-app\epub_review_sessions\
|
||||
```
|
||||
|
||||
这一阶段仍是 dev-web 基座:正式 Tauri 桌面包需要后续把启动逻辑迁到 Rust command 或打包 sidecar。
|
||||
|
||||
## 独立启动
|
||||
|
||||
如果只想脱离 Readest 单独运行本工具,可以在本目录执行:
|
||||
|
||||
```text
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\open_editor.ps1
|
||||
```
|
||||
|
||||
第一次运行会在 `tools/epub-review-editor/.venv` 创建本地 Python 环境并安装依赖;之后会直接启动服务并打开浏览器。
|
||||
|
||||
不预先指定 EPUB,直接打开上传界面:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONIOENCODING='utf-8'
|
||||
python '.\server.py'
|
||||
```
|
||||
|
||||
也可以像原来一样直接打开某个 EPUB:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONIOENCODING='utf-8'
|
||||
python '.\server.py' '<中日双语版.epub>' --daemon
|
||||
```
|
||||
|
||||
默认会自动打开浏览器。如果没有自动打开,命令会打印 URL,例如:
|
||||
|
||||
```text
|
||||
http://localhost:5177
|
||||
```
|
||||
|
||||
局域网或服务器部署时,把监听地址改为 `0.0.0.0`,并把审校数据目录固定到一个你会备份的位置:
|
||||
|
||||
```powershell
|
||||
python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessions
|
||||
```
|
||||
|
||||
当前版本没有登录鉴权,不要直接暴露到公网。需要远程使用时,优先放在可信局域网、VPN、反向代理鉴权或 SSH 隧道后面。
|
||||
|
||||
## 浏览器上传 EPUB
|
||||
|
||||
- 启动时不带 EPUB 路径,会进入开始页。
|
||||
- 点击“选择 EPUB 文件”,上传中日双语 EPUB 后会自动创建审校会话。
|
||||
- 开始页会列出已有会话,换电脑或重启服务后可以继续打开之前的审校记录。
|
||||
- 顶栏左上角“书架”会列出历史打开过的书籍,并按 EPUB 元数据生成分类;点击封面或“校对”会进入对应审校页面。
|
||||
- 书架中每本书封面悬停会变暗并显示书籍信息、“校对”“翻译”和“删除”按钮;“翻译”可对日语 EPUB 发起 AI 翻译任务,输出 EPUB 会自动加入书架;“删除”只会从书架隐藏审校会话,不删除原始 EPUB。
|
||||
- 上传的 EPUB、解包副本、反馈与导出文件都会保存在 `--review-root` 指定目录下。
|
||||
|
||||
## 能做什么
|
||||
|
||||
- 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。
|
||||
- 顶栏左上角可进入“书架”,集中查看历史打开过的书籍、审校进度和最近阅读记录;书架左侧可按全部书籍、同系列、作者、出版社和语言分类,右侧以封面网格显示书籍。
|
||||
- 阅读区可切换默认、护眼、夜间背景,并调整字号与行距;设置会保存在当前浏览器中,下次打开继续沿用。
|
||||
- 审校页顶部标题栏与阅读标题栏默认隐藏,鼠标移到页面上方时显示,减少长文阅读时的遮挡。
|
||||
- 阅读区“上一节 / 下一节”按 part 或插图小章节移动,不再只跳顶层大章节。
|
||||
- 每个审校会话会自动保存上次阅读位置,下次打开会回到上次所在章节、part、插图或段落附近。
|
||||
- 左侧目录和检索默认隐藏,通过顶部“目录”“检索”两个按钮分别打开,不会把两种入口混在同一个常驻侧栏里。
|
||||
- 目录优先按 EPUB 内部目录页或 nav/toc 组织章节;章节下显示内部 `part0000` 形式的 part 与次级插图条目,可展开/收起,点击 part 后只看该 part,点击章节则按原 spine 顺序连续阅读正文和插图。
|
||||
- 插图页会作为所属章节的次级条目出现在目录里,点击后可在阅读模式中直接查看原 EPUB 图片;点击目录项不会自动隐藏左侧侧栏。
|
||||
- 检索支持“当前范围”和“全书全局”两种范围,适合在单章精查和整本书排查术语时切换。
|
||||
- 在阅读模式中点击段落即可打开右侧精修与重翻面板,直接编辑中文译文、标记问题或调用 GPT 重翻;打开/关闭面板时会尽量保持原来的阅读位置。
|
||||
- 精修模式保留原有逐段编辑界面,用于较细的逐段复审。
|
||||
- 可从“审校工具”统一配置 OpenAI 兼容的 `base_url`、`model`、API Key、重翻提示词与术语表,并对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。
|
||||
- 可在“审校工具”的“AI 设置与术语表”里配置并读取正式术语表,实时搜索、增删改术语并保存;单段重翻会按当前段落命中术语表条目,而不是只在提示词里泛泛要求遵守术语。
|
||||
- 可从书架进入“AI 翻译 EPUB”页面,对日语 EPUB 设置翻译内容提示词、格式与标点提示词、角色口吻提示词、术语表路径、翻译范围、输出为中日双语或纯中文 EPUB;同系列书籍会优先套用系列提示词和术语表配置,任务在后台运行,页面实时显示进度与日志。
|
||||
- 直接编辑中文译文。
|
||||
- 标记问题段落或选中文本。
|
||||
- 记录问题类型、严重程度、标签、备注和可沉淀的长期规则。
|
||||
- 生成反馈文件,供 Codex 后续持续优化翻译和润色质量。
|
||||
- 将编辑写回 EPUB 解包内容。
|
||||
- 导出一份新的“网页审校版 EPUB”。
|
||||
|
||||
## 重要输出
|
||||
|
||||
每个 EPUB 会建立独立审校 session。通过浏览器上传时,默认位于:
|
||||
|
||||
```text
|
||||
epub_review_sessions\<timestamp>_<epub-name>_<hash>\
|
||||
```
|
||||
|
||||
通过命令行直接指定 EPUB 时,仍沿用固定路径:
|
||||
|
||||
```text
|
||||
epub_review_sessions\<epub-name>_<hash>\
|
||||
```
|
||||
|
||||
关键文件:
|
||||
|
||||
- `feedback/translation_feedback.jsonl`:逐次保存的结构化反馈记录。
|
||||
- `feedback/feedback_for_codex.md`:适合直接给 Codex 阅读的审校反馈摘要。
|
||||
- `exports/*_网页审校版_*.epub`:导出的修订版 EPUB。
|
||||
- `review_state/state.json`:网页编辑器当前状态。
|
||||
- `extracted/`:EPUB 解包后的工作副本。
|
||||
- `translation_jobs/<job-id>/job.json`:AI 翻译任务状态、进度和日志。
|
||||
- `translated_outputs/*_AI翻译_*.epub`:AI 翻译模块生成并自动加入书架的 EPUB。
|
||||
- `library.json`:按 EPUB 元数据生成的书架索引,包含书籍、系列和封面信息。
|
||||
- `series_configs/*.json`:同系列共享的提示词与术语表路径配置,不包含 API Key。
|
||||
|
||||
在服务器上使用时,“生成反馈”和“导出审校 EPUB”会返回浏览器可访问的下载地址,不需要登录服务器找文件。
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 正式长篇 EPUB 优先使用“阅读模式”:先在左侧点章节/part 连续读,看到问题再点段落快速编辑或标记。
|
||||
- 同时审校多本书时,优先从顶栏左上角进入“书架”,再选择目标书籍;从书架进入会恢复该书上次阅读位置。
|
||||
- 需要集中逐段复审时切到“精修模式”,左侧段落列表会跟随当前章节筛选。
|
||||
- 只想记录问题时:勾选“标记为翻译问题”,写备注,保存本段。
|
||||
- 想直接修正译文时:编辑中文译文后保存本段。
|
||||
- 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“某个术语只在原文实际出现时才进入重翻提示”。
|
||||
- 想单段重翻时:点击段落打开“审校工具”,先在“AI 设置与术语表”中保存 GPT 配置;提示词分为翻译内容、格式标点、角色口吻三块,可恢复预设。术语表路径会从当前项目中可唯一识别的系列 `mingcibiao.json` 推断,也可以改成其他 `mingcibiao.json`。再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。
|
||||
- 想整书 AI 翻译时:先上传或打开日语 EPUB,让它出现在书架里;在书架封面悬停后点击“翻译”,保存 API 设置,并在系列设置里保存该系列术语表和提示词。默认先做前 20 段试译,确认效果后再把范围切换为全书。中日双语输出可直接进入审校;纯中文输出会入书架,但没有逐段双语审校行。
|
||||
- 术语表路径默认会从当前项目中可唯一识别的系列 `mingcibiao.json` 推断;如果无法唯一推断,请在页面里填写目标术语表路径。
|
||||
- 想调整术语时:打开“审校工具”的“AI 设置与术语表”,在术语表区域搜索、修改或新增条目,点击“保存术语表”;下一次重翻会重新读取已保存的最新术语。
|
||||
- 审校结束后点击“生成反馈”,再在聊天里告诉 Codex 读取 `feedback_for_codex.md` 和 `translation_feedback.jsonl`。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 当前解析规则针对本项目的“日文灰字 + 中文紧随其后”的双语 EPUB。
|
||||
- AI 翻译模块是可选辅助首译入口,不替代正式长卷的候选词确认、复审和 QA;默认试译前 20 段,避免误触发全书 API 成本。
|
||||
- 目录用于审校导航;插图可以阅读和跳转,但当前版本不提供图片内容编辑。
|
||||
- GPT API Key、提示词与术语表路径仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;重翻时只能使用已保存的 Base URL 和模型,避免把密钥发往未确认端点。术语表编辑会直接写入配置路径指向的 JSON 文件,服务器部署时请保护好审校目录和术语表,不要把没有鉴权的服务暴露到公网。
|
||||
- 浏览器内保存的是审校工作副本;要生成 EPUB 文件,需要点击“导出审校 EPUB”。
|
||||
@@ -0,0 +1,134 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$toolRoot = Split-Path -Parent $PSCommandPath
|
||||
$projectRoot = (Resolve-Path (Join-Path $toolRoot "..\..")).Path
|
||||
$serverPath = Join-Path $toolRoot "server.py"
|
||||
$reviewRoot = Join-Path $projectRoot "epub_review_sessions"
|
||||
$venvRoot = Join-Path $toolRoot ".venv"
|
||||
$venvPython = Join-Path $venvRoot "Scripts\python.exe"
|
||||
$requirements = Join-Path $toolRoot "requirements.txt"
|
||||
$versionFile = Join-Path $toolRoot "version.py"
|
||||
$defaultPort = 5177
|
||||
|
||||
$env:PYTHONIOENCODING = "utf-8"
|
||||
|
||||
function Get-PythonCommand {
|
||||
$pyLauncher = Get-Command py -ErrorAction SilentlyContinue
|
||||
if ($pyLauncher) {
|
||||
return [pscustomobject]@{ Exe = $pyLauncher.Source; Args = @("-3") }
|
||||
}
|
||||
|
||||
$python = Get-Command python -ErrorAction SilentlyContinue
|
||||
if ($python) {
|
||||
return [pscustomobject]@{ Exe = $python.Source; Args = @() }
|
||||
}
|
||||
|
||||
throw "Python was not found. Install Python 3, then run this launcher again."
|
||||
}
|
||||
|
||||
function Invoke-PythonCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Exe,
|
||||
[string[]]$Args = @()
|
||||
)
|
||||
|
||||
& $Exe @Args
|
||||
return $LASTEXITCODE
|
||||
}
|
||||
|
||||
function Test-FlaskInstalled {
|
||||
& $venvPython -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('flask') else 1)" 2>$null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
|
||||
function Get-ExpectedVersion {
|
||||
if (!(Test-Path $versionFile)) {
|
||||
return ""
|
||||
}
|
||||
$content = Get-Content -Raw -Encoding UTF8 $versionFile
|
||||
$match = [regex]::Match($content, 'version\s*=\s*"([^"]+)"')
|
||||
if ($match.Success) {
|
||||
return $match.Groups[1].Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function Test-LocalPortOpen {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$Port
|
||||
)
|
||||
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
try {
|
||||
$async = $client.BeginConnect("127.0.0.1", $Port, $null, $null)
|
||||
if (!$async.AsyncWaitHandle.WaitOne(100, $false)) {
|
||||
return $false
|
||||
}
|
||||
$client.EndConnect($async)
|
||||
return $true
|
||||
} catch {
|
||||
return $false
|
||||
} finally {
|
||||
$client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RunningEditorUrl {
|
||||
$expectedVersion = Get-ExpectedVersion
|
||||
for ($port = $defaultPort; $port -lt ($defaultPort + 100); $port++) {
|
||||
if (!(Test-LocalPortOpen -Port $port)) {
|
||||
continue
|
||||
}
|
||||
$url = "http://127.0.0.1:$port"
|
||||
try {
|
||||
$versionPayload = Invoke-RestMethod -Uri "$url/api/version" -TimeoutSec 1 -ErrorAction Stop
|
||||
if (!$expectedVersion -or $versionPayload.version -eq $expectedVersion) {
|
||||
return "http://localhost:$port"
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
$runningUrl = Get-RunningEditorUrl
|
||||
if ($runningUrl) {
|
||||
Write-Host "EPUB review editor is already running."
|
||||
Start-Process $runningUrl
|
||||
Write-Host $runningUrl
|
||||
Write-Host "review root: $reviewRoot"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (!(Test-Path $venvPython)) {
|
||||
Write-Host "Creating local Python environment..."
|
||||
$python = Get-PythonCommand
|
||||
$venvArgs = @()
|
||||
if ($python.Args) {
|
||||
$venvArgs += $python.Args
|
||||
}
|
||||
$venvArgs += @("-m", "venv", $venvRoot)
|
||||
if ((Invoke-PythonCommand -Exe $python.Exe -Args $venvArgs) -ne 0) {
|
||||
throw "Failed to create local Python environment."
|
||||
}
|
||||
}
|
||||
|
||||
if (!(Test-FlaskInstalled)) {
|
||||
Write-Host "Installing review editor dependencies..."
|
||||
& $venvPython -m pip install -r $requirements
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to install dependencies from requirements.txt."
|
||||
}
|
||||
if (!(Test-FlaskInstalled)) {
|
||||
throw "Dependencies were installed, but Flask is still unavailable in the local Python environment."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Starting EPUB review editor..."
|
||||
& $venvPython $serverPath --review-root $reviewRoot --daemon
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to start EPUB review editor."
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Flask>=3.0,<4.0
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,514 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>双语 EPUB 审校编辑器</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v=0.13.2">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<button id="bookshelfBtn" class="bookshelfNavButton" type="button">书架</button>
|
||||
<div class="brandBlock">
|
||||
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.13.2</span></h1>
|
||||
<p id="sessionMeta">正在载入……</p>
|
||||
</div>
|
||||
<div class="modeTabs" role="tablist" aria-label="审校模式">
|
||||
<button id="readingModeBtn" class="modeButton active" type="button">阅读模式</button>
|
||||
<button id="polishModeBtn" class="modeButton" type="button">精修模式</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button id="tocPanelBtn" type="button">目录</button>
|
||||
<button id="searchPanelBtn" type="button">检索</button>
|
||||
<button id="openLibraryBtn" type="button">打开 EPUB</button>
|
||||
<button id="reviewToolsBtn" type="button">审校工具</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="bookshelfScreen" id="bookshelfScreen" hidden>
|
||||
<div class="libraryShell">
|
||||
<aside class="librarySidebar" aria-label="书架分类">
|
||||
<div class="libraryBrandRow">
|
||||
<button id="libraryMenuBtn" class="iconTextButton" type="button">☰</button>
|
||||
<strong>Starrea</strong>
|
||||
</div>
|
||||
<label class="librarySearchLabel">
|
||||
<span>搜索</span>
|
||||
<input id="bookshelfSearchInput" type="search" placeholder="搜索书名、作者、系列">
|
||||
</label>
|
||||
<nav class="libraryFacetList" id="bookshelfFacetList"></nav>
|
||||
<div class="librarySidebarFooter">
|
||||
<button id="bookshelfRefreshBtn" type="button">刷新</button>
|
||||
<button id="bookshelfOpenUploadBtn" class="primary" type="button">导入 EPUB</button>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="bookshelfPanel">
|
||||
<div class="bookshelfHeader">
|
||||
<div>
|
||||
<h2 id="bookshelfTitle">全部书籍</h2>
|
||||
<p id="bookshelfDescription">按 EPUB 元数据建立书架,封面悬停可直接进入审校或翻译。</p>
|
||||
</div>
|
||||
<div class="bookshelfActions">
|
||||
<button id="seriesConfigBtn" type="button" disabled>系列设置</button>
|
||||
<button id="bookshelfOpenUploadBtnInline" class="primary" type="button">打开新的 EPUB</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bookshelfMeta" id="bookshelfMeta">正在读取书架……</div>
|
||||
<div class="bookshelfGrid" id="bookshelfList"></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="seriesConfigDrawer" id="seriesConfigDrawer" hidden>
|
||||
<div class="seriesConfigHeader">
|
||||
<div>
|
||||
<h2 id="seriesConfigTitle">系列设置</h2>
|
||||
<p id="seriesConfigMeta">同系列书籍会优先共用这里的提示词和术语表。</p>
|
||||
</div>
|
||||
<button id="closeSeriesConfigBtn" type="button">关闭</button>
|
||||
</div>
|
||||
<label>
|
||||
术语表路径
|
||||
<input id="seriesGlossaryPathInput" type="text" placeholder="mingcibiao.json 的完整路径或项目内相对路径">
|
||||
</label>
|
||||
<label>
|
||||
翻译内容提示词
|
||||
<textarea id="seriesTranslationPromptInput" rows="5"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
格式与标点提示词
|
||||
<textarea id="seriesFormatPromptInput" rows="5"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
角色状态与口吻提示词
|
||||
<textarea id="seriesCharacterPromptInput" rows="4"></textarea>
|
||||
</label>
|
||||
<div class="seriesConfigActions">
|
||||
<button id="saveSeriesConfigBtn" class="primary" type="button">保存系列设置</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="translationScreen" id="translationScreen" hidden>
|
||||
<section class="translationPanel">
|
||||
<div class="translationHeader">
|
||||
<div>
|
||||
<h2>AI 翻译 EPUB</h2>
|
||||
<p id="translationBookMeta">选择书架中的 EPUB 后设置范围、输出格式和必要提示词。</p>
|
||||
</div>
|
||||
<div class="translationHeaderActions">
|
||||
<button id="translationBackBookshelfBtn" type="button">返回书架</button>
|
||||
<button id="translationOpenReviewBtn" class="primary" type="button" disabled>打开输出审校</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="translationGrid">
|
||||
<form class="translationSettings" id="translationForm">
|
||||
<section class="translationCard">
|
||||
<div class="sectionTitle compactTitle">
|
||||
<h2>输出与范围</h2>
|
||||
<span id="translationSourceMeta">未读取源书信息</span>
|
||||
</div>
|
||||
<div class="translationOptions">
|
||||
<label>
|
||||
输出格式
|
||||
<select id="translationOutputModeInput">
|
||||
<option value="bilingual">中日双语 EPUB</option>
|
||||
<option value="translated">纯中文 EPUB</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
翻译范围
|
||||
<select id="translationRangeModeInput">
|
||||
<option value="limit">前 N 个正文块试译</option>
|
||||
<option value="all">全书</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
正文块数
|
||||
<input id="translationLimitInput" type="number" min="1" max="5000" value="20">
|
||||
</label>
|
||||
<label>
|
||||
Temperature
|
||||
<input id="translationTemperatureInput" type="number" min="0" max="1" step="0.1" value="0.2">
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="translationCard">
|
||||
<div class="sectionTitle compactTitle">
|
||||
<h2>API 与术语表</h2>
|
||||
<span id="translationGptStatus">未读取配置</span>
|
||||
</div>
|
||||
<div class="translationOptions apiOptions">
|
||||
<label>
|
||||
Base URL
|
||||
<input id="translationBaseUrlInput" type="url" placeholder="https://api.openai.com/v1">
|
||||
</label>
|
||||
<label>
|
||||
Model
|
||||
<input id="translationModelInput" type="text" placeholder="gpt-4o-mini">
|
||||
</label>
|
||||
<label>
|
||||
API Key
|
||||
<input id="translationApiKeyInput" type="password" placeholder="留空则保留已有密钥">
|
||||
</label>
|
||||
<label>
|
||||
术语表路径
|
||||
<input id="translationGlossaryPathInput" type="text" placeholder="mingcibiao.json 的完整路径或项目内相对路径">
|
||||
</label>
|
||||
</div>
|
||||
<div class="translationActions">
|
||||
<button id="translationSaveConfigBtn" type="button">保存 API 设置</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="translationCard">
|
||||
<div class="sectionTitle compactTitle">
|
||||
<h2>提示词</h2>
|
||||
<button id="translationResetPromptsBtn" type="button">恢复预设</button>
|
||||
</div>
|
||||
<label>
|
||||
翻译内容提示词
|
||||
<textarea id="translationPromptInput" rows="5"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
格式与标点提示词
|
||||
<textarea id="translationFormatPromptInput" rows="5"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
角色状态与口吻提示词
|
||||
<textarea id="translationCharacterPromptInput" rows="4"></textarea>
|
||||
</label>
|
||||
<div class="translationActions">
|
||||
<button id="translationStartBtn" class="primary" type="submit">开始翻译</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<aside class="translationProgressCard">
|
||||
<div class="sectionTitle compactTitle">
|
||||
<h2>翻译进度</h2>
|
||||
<span id="translationJobMeta">尚未开始</span>
|
||||
</div>
|
||||
<div class="translationProgress">
|
||||
<div class="translationProgressBar" id="translationProgressBar"></div>
|
||||
</div>
|
||||
<div class="translationProgressText" id="translationProgressText">等待开始翻译。</div>
|
||||
<div class="translationOutput" id="translationOutput" hidden></div>
|
||||
<div class="translationLogs" id="translationLogs"></div>
|
||||
</aside>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="startScreen" id="startScreen" hidden>
|
||||
<section class="startPanel">
|
||||
<div class="startHeader">
|
||||
<div>
|
||||
<h2>打开中日双语 EPUB</h2>
|
||||
<p>上传一个“日文灰字 + 中文译文紧随其后”的双语 EPUB,审校记录会保存在本机或服务器的审校目录中。</p>
|
||||
</div>
|
||||
<button id="refreshSessionsBtn" type="button">刷新会话</button>
|
||||
</div>
|
||||
|
||||
<form class="uploadBox" id="uploadForm">
|
||||
<label class="fileDrop" for="epubFileInput">
|
||||
<span class="fileDropTitle">选择 EPUB 文件</span>
|
||||
<span class="fileDropName" id="selectedFileName">尚未选择文件</span>
|
||||
<input id="epubFileInput" type="file" accept=".epub,application/epub+zip">
|
||||
</label>
|
||||
<button id="uploadBtn" class="primary" type="submit">上传并开始审校</button>
|
||||
</form>
|
||||
|
||||
<section class="sessionBrowser">
|
||||
<div class="sectionTitle compactTitle">
|
||||
<h2>已有审校会话</h2>
|
||||
<span id="sessionListMeta"></span>
|
||||
</div>
|
||||
<div class="sessionList" id="sessionList"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="layout" id="reviewLayout" hidden>
|
||||
<aside class="sidebar" id="sidebar" hidden>
|
||||
<div class="sideHeader">
|
||||
<strong id="sideTitle">目录</strong>
|
||||
<button id="closeSidebarBtn" type="button" aria-label="隐藏侧栏">隐藏</button>
|
||||
</div>
|
||||
<section class="tocPanel" id="tocPanel">
|
||||
<div class="tocToolbar" aria-label="目录操作">
|
||||
<strong>章节目录</strong>
|
||||
<div>
|
||||
<button id="expandTocBtn" type="button">展开全部</button>
|
||||
<button id="collapseTocBtn" type="button">收起</button>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="toc" id="toc" aria-label="章节目录"></nav>
|
||||
</section>
|
||||
<section class="searchPanel" id="searchPanel" hidden>
|
||||
<div class="searchbox">
|
||||
<input id="searchInput" type="search" placeholder="搜索原文、译文、备注">
|
||||
<div class="searchScope" role="group" aria-label="检索范围">
|
||||
<button id="searchScopeCurrentBtn" type="button" class="active">当前范围</button>
|
||||
<button id="searchScopeBookBtn" type="button">全书全局</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats" id="stats"></div>
|
||||
<div class="rowList" id="rowList"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="readerPane" id="readerPane">
|
||||
<div class="readerHeader">
|
||||
<div>
|
||||
<div class="chapterKicker" id="chapterKicker">目录</div>
|
||||
<h2 id="readerTitle">选择章节开始阅读</h2>
|
||||
</div>
|
||||
<div class="readerTools">
|
||||
<div class="appearanceControls" aria-label="阅读外观">
|
||||
<div class="themeSwitch" role="group" aria-label="背景模式">
|
||||
<button id="themeDefaultBtn" type="button" data-reader-theme="default">默认</button>
|
||||
<button id="themeEyeBtn" type="button" data-reader-theme="eye">护眼</button>
|
||||
<button id="themeNightBtn" type="button" data-reader-theme="night">夜间</button>
|
||||
</div>
|
||||
<label class="readerSlider">
|
||||
<span>字号</span>
|
||||
<input id="readerFontSizeInput" type="range" min="15" max="24" step="1" value="17">
|
||||
<output id="readerFontSizeValue">17</output>
|
||||
</label>
|
||||
<label class="readerSlider">
|
||||
<span>行距</span>
|
||||
<input id="readerLineHeightInput" type="range" min="1.6" max="2.4" step="0.05" value="1.95">
|
||||
<output id="readerLineHeightValue">1.95</output>
|
||||
</label>
|
||||
</div>
|
||||
<button id="prevPartBtn" type="button">上一节</button>
|
||||
<button id="nextPartBtn" type="button">下一节</button>
|
||||
</div>
|
||||
</div>
|
||||
<article class="readingFlow" id="readingFlow"></article>
|
||||
</section>
|
||||
|
||||
<section class="editorPane" id="editorPane" hidden>
|
||||
<div class="emptyState" id="emptyState">选择左侧段落开始审校。</div>
|
||||
<article class="editorCard" id="editorCard" hidden>
|
||||
<div class="rowHeader">
|
||||
<div>
|
||||
<div class="rowId" id="rowId"></div>
|
||||
<div class="rowPath" id="rowPath"></div>
|
||||
</div>
|
||||
<label class="markToggle">
|
||||
<input id="markedInput" type="checkbox">
|
||||
标记为翻译问题
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section class="pair">
|
||||
<h2>日文原文</h2>
|
||||
<div class="sourceText" id="jpText"></div>
|
||||
</section>
|
||||
|
||||
<section class="pair">
|
||||
<div class="sectionTitle">
|
||||
<h2>中文译文</h2>
|
||||
<div class="inlineTools">
|
||||
<button id="markSelectionBtn" type="button">标记选中文本</button>
|
||||
<button id="clearMarksBtn" type="button">清除本段标记</button>
|
||||
<button id="resetTextBtn" type="button">恢复本段初始译文</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cnEditor" class="cnEditor" contenteditable="true" spellcheck="false"></div>
|
||||
</section>
|
||||
|
||||
<section class="reviewFields">
|
||||
<label>
|
||||
问题类型
|
||||
<select id="issueTypeInput">
|
||||
<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>
|
||||
严重程度
|
||||
<select id="severityInput">
|
||||
<option value="">未选择</option>
|
||||
<option value="轻微">轻微</option>
|
||||
<option value="中等">中等</option>
|
||||
<option value="严重">严重</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
标签
|
||||
<input id="tagsInput" type="text" placeholder="例:术语, 对话, 阿格里皮娜">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="notes">
|
||||
<label>
|
||||
问题备注
|
||||
<textarea id="commentInput" rows="4" placeholder="写下哪里翻得不好,或你希望我之后怎么处理类似句子。"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
可沉淀为长期规则
|
||||
<textarea id="learnNoteInput" rows="3" placeholder="例:某个术语只在原文实际出现时才进入重翻提示。"></textarea>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button id="prevBtn" type="button">上一段</button>
|
||||
<button id="saveBtn" type="button">保存本段记录</button>
|
||||
<button id="nextBtn" type="button">下一段</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside class="quickEditor" id="quickEditor" hidden aria-label="审校工具面板">
|
||||
<div class="quickHeader">
|
||||
<div>
|
||||
<div class="rowId" id="quickRowId"></div>
|
||||
<div class="rowPath" id="quickRowPath"></div>
|
||||
</div>
|
||||
<button id="closeQuickEditorBtn" type="button" aria-label="关闭审校工具">关闭</button>
|
||||
</div>
|
||||
<details class="toolSection quickSourceWrap" open>
|
||||
<summary>日文原文</summary>
|
||||
<section class="quickSource" id="quickJpText"></section>
|
||||
</details>
|
||||
<details class="toolSection" open>
|
||||
<summary>当前段</summary>
|
||||
<label class="quickField">
|
||||
中文译文
|
||||
<div id="quickCnEditor" class="cnEditor quickCnEditor" contenteditable="true" spellcheck="false"></div>
|
||||
</label>
|
||||
<div class="quickControls">
|
||||
<label class="markToggle">
|
||||
<input id="quickMarkedInput" type="checkbox">
|
||||
标记问题
|
||||
</label>
|
||||
<select id="quickIssueTypeInput">
|
||||
<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>
|
||||
<select id="quickSeverityInput">
|
||||
<option value="">严重程度</option>
|
||||
<option value="轻微">轻微</option>
|
||||
<option value="中等">中等</option>
|
||||
<option value="严重">严重</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="quickField">
|
||||
标签
|
||||
<input id="quickTagsInput" type="text" placeholder="例:术语, 对话">
|
||||
</label>
|
||||
<label class="quickField">
|
||||
问题备注
|
||||
<textarea id="quickCommentInput" rows="3" placeholder="哪里翻得不好,或应该如何改。"></textarea>
|
||||
</label>
|
||||
<label class="quickField">
|
||||
可沉淀为长期规则
|
||||
<textarea id="quickLearnNoteInput" rows="2" placeholder="可复用的翻译偏好或禁忌。"></textarea>
|
||||
</label>
|
||||
<div class="quickActions">
|
||||
<button id="quickMarkSelectionBtn" type="button">标记选中文本</button>
|
||||
<button id="quickSaveBtn" type="button">保存当前段</button>
|
||||
<button id="quickOpenFullBtn" type="button">打开完整精修</button>
|
||||
</div>
|
||||
</details>
|
||||
<section class="gptBox toolSection" id="gptBox">
|
||||
<div class="gptConfigTitle">AI 重翻</div>
|
||||
<div class="gptStatus" id="gptStatus">未读取配置</div>
|
||||
<label class="quickField">
|
||||
重翻要求
|
||||
<textarea id="gptInstructionInput" rows="2" placeholder="可选:这次希望更口语、更忠实,或特别注意某个术语。"></textarea>
|
||||
</label>
|
||||
<div class="gptActions">
|
||||
<button id="retranslateBtn" type="button">重翻本段</button>
|
||||
<button id="applyRetranslationBtn" type="button" disabled>应用重翻</button>
|
||||
</div>
|
||||
<div class="gptCandidate" id="gptCandidate" hidden></div>
|
||||
</section>
|
||||
<details class="toolSection gptConfig" id="gptConfig" open>
|
||||
<summary>AI 设置与术语表</summary>
|
||||
<div class="gptConfigGroup">
|
||||
<div class="gptConfigTitle">API</div>
|
||||
<label>
|
||||
Base URL
|
||||
<input id="gptBaseUrlInput" type="url" placeholder="https://api.openai.com/v1">
|
||||
</label>
|
||||
<label>
|
||||
Model
|
||||
<input id="gptModelInput" type="text" placeholder="gpt-4o-mini">
|
||||
</label>
|
||||
<label>
|
||||
API Key
|
||||
<input id="gptApiKeyInput" type="password" placeholder="留空则保留已有密钥">
|
||||
</label>
|
||||
<label>
|
||||
术语表路径
|
||||
<input id="glossaryPathInput" type="text" placeholder="mingcibiao.json 的完整路径或项目内相对路径">
|
||||
</label>
|
||||
</div>
|
||||
<div class="gptConfigGroup">
|
||||
<div class="gptConfigTitle">提示词</div>
|
||||
<label>
|
||||
翻译内容提示词
|
||||
<textarea id="gptTranslationPromptInput" rows="5" placeholder="设置忠实度、文风、术语优先级等内容要求。"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
格式与标点提示词
|
||||
<textarea id="gptFormatPromptInput" rows="5" placeholder="设置 HTML、ruby、标点替换与输出格式规则。"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
角色状态与口吻提示词
|
||||
<textarea id="gptCharacterPromptInput" rows="4" placeholder="设置角色状态、性格口吻、对话风格判断规则。"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="gptConfigGroup glossaryGroup">
|
||||
<div class="gptConfigTitle">术语表</div>
|
||||
<div class="glossaryMeta" id="glossaryMeta">未读取术语表</div>
|
||||
<div class="glossaryTools">
|
||||
<input id="glossarySearchInput" type="search" placeholder="搜索原文、译名或备注">
|
||||
<button id="reloadGlossaryBtn" type="button">重新读取</button>
|
||||
<button id="addGlossaryEntryBtn" type="button">新增术语</button>
|
||||
<button id="saveGlossaryBtn" type="button" disabled>保存术语表</button>
|
||||
</div>
|
||||
<div class="glossaryList" id="glossaryList"></div>
|
||||
</div>
|
||||
<div class="gptConfigActions">
|
||||
<button id="resetGptPromptsBtn" type="button">恢复预设提示词</button>
|
||||
<button id="saveGptConfigBtn" type="button">保存 AI 设置</button>
|
||||
</div>
|
||||
</details>
|
||||
<details class="toolSection" open>
|
||||
<summary>反馈与交付</summary>
|
||||
<div class="deliveryActions">
|
||||
<button id="showTouchedBtn" type="button">仅看已记录</button>
|
||||
<button id="feedbackBtn" type="button">生成反馈文件</button>
|
||||
<button id="applyBtn" type="button">写回 EPUB 内容</button>
|
||||
<button id="exportBtn" type="button">导出审校 EPUB</button>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
<script src="/static/app.js?v=0.13.2"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
version = "0.13.2"
|
||||
|
||||
VERSION_RULES = {
|
||||
"PATCH": "修复 bug 或兼容性小修",
|
||||
"MINOR": "新增向后兼容能力、API 字段或可选配置",
|
||||
"MAJOR": "破坏兼容的 API、配置或行为变更",
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint:lua": "pnpm --filter @readest/readest-app lint:lua",
|
||||
"tauri": "pnpm --filter @readest/readest-app tauri",
|
||||
"dev-web": "pnpm --filter @readest/readest-app dev-web",
|
||||
"epub-reviewer:dev": "pnpm --filter @readest/readest-app epub-reviewer:dev",
|
||||
"prepare": "husky",
|
||||
"fmt:check": "pnpm --filter @readest/readest-app fmt:check",
|
||||
"clippy:check": "pnpm --filter @readest/readest-app clippy:check",
|
||||
|
||||
Reference in New Issue
Block a user