diff --git a/apps/readest-app/.claude/memory/epub-review-editor-migration.md b/apps/readest-app/.claude/memory/epub-review-editor-migration.md deleted file mode 100644 index daa52dcd..00000000 --- a/apps/readest-app/.claude/memory/epub-review-editor-migration.md +++ /dev/null @@ -1,33 +0,0 @@ -# EPUB review editor migration - -Date: 2026-07-08 - -This fork includes a desktop 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`. -- Desktop launcher command is `launch_epub_review_editor`. -- 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 now works in Tauri desktop through the Rust command and keeps local `dev-web` as a fallback. -- `/review-editor` is the desktop feature-block page for both 校对 and 翻译. It now uses native React blocks as the main work surface and calls the local sidecar APIs directly. -- Readest EPUB bookshelf context menus can open the reviewer in 校对 or 翻译 mode. The native file path is handed to the Tauri command, which creates/reuses a sidecar session and returns `sessionId`; the Readest page should use `session_id`, not keep `epub_path` in the URL. -- The old `static/index.html` UI remains bundled only as standalone/debug fallback. Do not treat an iframe of the old UI as the desktop migration acceptance path. - -Important boundary: -- This is not a full native rewrite yet. The migrated tool still uses the proven Flask backend from the previous long-term translation project. -- The production desktop path still depends on local Python/venv/pip as a temporary sidecar runtime. Next step should package a controlled runtime or convert the hot APIs to native Rust/Tauri commands. -- 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`. -- If React blocks cannot fetch the sidecar, check restricted CORS in `server.py`; allowed origins are local Readest dev and Tauri origins only. - -Next recommended steps: -1. Package the Python sidecar/runtime or replace it with native Tauri commands so desktop users do not need a system Python installation. -2. Add a launch token / origin guard for the loopback sidecar API before wider distribution. -3. Gradually port long-tail review-editor surfaces into React: glossary editing, full bookshelf classification UI, richer chapter navigation, and reading-position restore. - -Desktop latest-version entrypoint: -- `scripts/open-readest-latest.ps1` and `scripts/open-readest-latest.cmd` are the stable desktop shortcut targets. They fetch `akai-tools/codex/desktop-review-editor-blocks`, fast-forward only on a clean worktree, update submodules/dependencies when needed, then start `pnpm --filter @readest/readest-app tauri dev`. Do not point the user shortcut directly at `target/debug/Readest.exe`, because that can launch stale code or miss the Next/Tauri dev server. diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index bb99808a..67248471 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -8,7 +8,6 @@ "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", diff --git a/apps/readest-app/scripts/epub-reviewer-dev.mjs b/apps/readest-app/scripts/epub-reviewer-dev.mjs deleted file mode 100644 index a22601dc..00000000 --- a/apps/readest-app/scripts/epub-reviewer-dev.mjs +++ /dev/null @@ -1,139 +0,0 @@ -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); -}); diff --git a/apps/readest-app/src-tauri/src/review_editor.rs b/apps/readest-app/src-tauri/src/review_editor.rs index dcb7f97d..b769cf2b 100644 --- a/apps/readest-app/src-tauri/src/review_editor.rs +++ b/apps/readest-app/src-tauri/src/review_editor.rs @@ -112,7 +112,6 @@ fn launch_epub_review_editor_sync( "--port".to_string(), DEFAULT_PORT.to_string(), "--daemon".to_string(), - "--no-browser".to_string(), ], Some(&tool_root), )?; diff --git a/apps/readest-app/src-tauri/tauri.conf.json b/apps/readest-app/src-tauri/tauri.conf.json index fbbc8709..787235d4 100644 --- a/apps/readest-app/src-tauri/tauri.conf.json +++ b/apps/readest-app/src-tauri/tauri.conf.json @@ -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 http://127.0.0.1:* https://*.stripe.com", + "frame-src": "'self' blob: asset: http://asset.localhost 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": { @@ -52,11 +52,7 @@ "resources": { "../tools/epub-review-editor/server.py": "tools/epub-review-editor/server.py", "../tools/epub-review-editor/version.py": "tools/epub-review-editor/version.py", - "../tools/epub-review-editor/requirements.txt": "tools/epub-review-editor/requirements.txt", - "../tools/epub-review-editor/README.md": "tools/epub-review-editor/README.md", - "../tools/epub-review-editor/MAINTENANCE.md": "tools/epub-review-editor/MAINTENANCE.md", - "../tools/epub-review-editor/READEST_MIGRATION.md": "tools/epub-review-editor/READEST_MIGRATION.md", - "../tools/epub-review-editor/static/*": "tools/epub-review-editor/static/" + "../tools/epub-review-editor/requirements.txt": "tools/epub-review-editor/requirements.txt" }, "windows": { "webviewInstallMode": { diff --git a/apps/readest-app/src-tauri/tauri.windows.conf.json b/apps/readest-app/src-tauri/tauri.windows.conf.json index 325aaeb6..20c1ff39 100644 --- a/apps/readest-app/src-tauri/tauri.windows.conf.json +++ b/apps/readest-app/src-tauri/tauri.windows.conf.json @@ -4,11 +4,7 @@ "../extensions/windows-thumbnail/target/windows_thumbnail.dll": "readest_thumbnail.dll", "../tools/epub-review-editor/server.py": "tools/epub-review-editor/server.py", "../tools/epub-review-editor/version.py": "tools/epub-review-editor/version.py", - "../tools/epub-review-editor/requirements.txt": "tools/epub-review-editor/requirements.txt", - "../tools/epub-review-editor/README.md": "tools/epub-review-editor/README.md", - "../tools/epub-review-editor/MAINTENANCE.md": "tools/epub-review-editor/MAINTENANCE.md", - "../tools/epub-review-editor/READEST_MIGRATION.md": "tools/epub-review-editor/READEST_MIGRATION.md", - "../tools/epub-review-editor/static/*": "tools/epub-review-editor/static/" + "../tools/epub-review-editor/requirements.txt": "tools/epub-review-editor/requirements.txt" } } } diff --git a/apps/readest-app/src/__tests__/app/library/book-context-menu.test.ts b/apps/readest-app/src/__tests__/app/library/book-context-menu.test.ts index cf10aae3..1503dfd8 100644 --- a/apps/readest-app/src/__tests__/app/library/book-context-menu.test.ts +++ b/apps/readest-app/src/__tests__/app/library/book-context-menu.test.ts @@ -22,8 +22,7 @@ describe('getBookContextMenuItemIds', () => { 'markFinished', 'markAbandoned', 'showDetails', - 'reviewInEpubEditor', - 'translateInEpubEditor', + 'bilingual', 'showInFinder', 'searchGoodreads', 'upload', @@ -41,8 +40,7 @@ describe('getBookContextMenuItemIds', () => { 'markAbandoned', 'clearStatus', 'showDetails', - 'reviewInEpubEditor', - 'translateInEpubEditor', + 'bilingual', 'showInFinder', 'searchGoodreads', 'upload', @@ -60,8 +58,7 @@ describe('getBookContextMenuItemIds', () => { 'markAbandoned', 'clearStatus', 'showDetails', - 'reviewInEpubEditor', - 'translateInEpubEditor', + 'bilingual', 'showInFinder', 'searchGoodreads', 'upload', @@ -78,8 +75,7 @@ describe('getBookContextMenuItemIds', () => { 'markFinished', 'clearStatus', 'showDetails', - 'reviewInEpubEditor', - 'translateInEpubEditor', + 'bilingual', 'showInFinder', 'searchGoodreads', 'upload', @@ -96,8 +92,7 @@ describe('getBookContextMenuItemIds', () => { 'markFinished', 'markAbandoned', 'showDetails', - 'reviewInEpubEditor', - 'translateInEpubEditor', + 'bilingual', 'showInFinder', 'searchGoodreads', 'download', @@ -114,8 +109,7 @@ describe('getBookContextMenuItemIds', () => { 'markFinished', 'markAbandoned', 'showDetails', - 'reviewInEpubEditor', - 'translateInEpubEditor', + 'bilingual', 'showInFinder', 'searchGoodreads', 'delete', diff --git a/apps/readest-app/src/__tests__/middleware.test.ts b/apps/readest-app/src/__tests__/middleware.test.ts index 4741dd3d..b475dd75 100644 --- a/apps/readest-app/src/__tests__/middleware.test.ts +++ b/apps/readest-app/src/__tests__/middleware.test.ts @@ -18,7 +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'); + expect(coep('/reader')).toBe('require-corp'); // Must not be caught by a naive startsWith('/s'). expect(coep('/settings')).toBe('require-corp'); expect(coep('/search')).toBe('require-corp'); diff --git a/apps/readest-app/src/app/api/review-editor/launch/route.ts b/apps/readest-app/src/app/api/review-editor/launch/route.ts index b6c37d23..816b01af 100644 --- a/apps/readest-app/src/app/api/review-editor/launch/route.ts +++ b/apps/readest-app/src/app/api/review-editor/launch/route.ts @@ -264,7 +264,6 @@ async function launchEditor(): Promise { '--port', String(DEFAULT_PORT), '--daemon', - '--no-browser', ], { cwd: bundledToolRoot, timeout: 45_000 }, ); diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index 4f2f652b..f1508c04 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -3,7 +3,6 @@ import { useCallback, useState } from 'react'; import { useEnv } from '@/context/EnvContext'; import { useSettingsStore } from '@/store/settingsStore'; import { useTranslation } from '@/hooks/useTranslation'; -import { useAppRouter } from '@/hooks/useAppRouter'; import { useLongPress } from '@/hooks/useLongPress'; import { Menu as TauriMenu } from '@tauri-apps/api/menu'; import { revealItemInDir } from '@tauri-apps/plugin-opener'; @@ -19,7 +18,6 @@ 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'; import { Book, BooksGroup, ReadingStatus } from '@/types/book'; -import { navigateToReviewEditor } from '@/utils/nav'; import { getBookContextMenuItemIds, type BookContextMenuItemId, @@ -143,10 +141,9 @@ const BookshelfItem: React.FC = ({ handleUpdateReadingStatus, }) => { const _ = useTranslation(); - const router = useAppRouter(); const { appService } = useEnv(); const { settings } = useSettingsStore(); - const { openBook, makeBookAvailable } = useOpenBook({ setLoading, handleBookDownload }); + const { openBook } = useOpenBook({ setLoading, handleBookDownload }); const [webContextMenu, setWebContextMenu] = useState(null); const showBookDetailsModal = useCallback(async (book: Book) => { @@ -165,25 +162,6 @@ const BookshelfItem: React.FC = ({ [isSelectMode, openBook, toggleSelection], ); - const openBookInReviewEditor = useCallback( - async (book: Book, mode: 'review' | 'translate') => { - const available = await makeBookAvailable(book); - if (!available) return; - const epubPath = await appService?.resolveNativeBookFilePath(book); - if (!epubPath) { - eventDispatcher.dispatch('toast', { - message: _('Book file is not available locally'), - type: 'warning', - }); - return; - } - sessionStorage.setItem('reviewEditorLaunchContext', JSON.stringify({ mode, epubPath })); - const params = new URLSearchParams({ mode }); - navigateToReviewEditor(router, params); - }, - [_, appService, makeBookAvailable, router], - ); - const handleGroupClick = useCallback( (group: BooksGroup) => { if (isSelectMode) { @@ -252,18 +230,6 @@ const BookshelfItem: React.FC = ({ showBookDetailsModal(book); }, }, - reviewInEpubEditor: { - text: '用 EPUB 审校器校对', - action: async () => { - await openBookInReviewEditor(book, 'review'); - }, - }, - translateInEpubEditor: { - text: '用 EPUB 审校器翻译', - action: async () => { - await openBookInReviewEditor(book, 'translate'); - }, - }, bilingual: { text: _('Bilingual'), action: async () => { diff --git a/apps/readest-app/src/app/library/components/SettingsMenu.tsx b/apps/readest-app/src/app/library/components/SettingsMenu.tsx index 78d2d075..8dd10e50 100644 --- a/apps/readest-app/src/app/library/components/SettingsMenu.tsx +++ b/apps/readest-app/src/app/library/components/SettingsMenu.tsx @@ -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, navigateToReviewEditor } from '@/utils/nav'; +import { navigateToLogin, navigateToProfile } from '@/utils/nav'; import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window'; import { setAboutDialogVisible } from '@/components/AboutWindow'; import { setMigrateDataDirDialogVisible } from '@/app/library/components/MigrateDataWindow'; @@ -209,11 +209,6 @@ const SettingsMenu: React.FC = ({ setCacheManagerDialogVisible(true); }; - const handleOpenReviewEditor = () => { - navigateToReviewEditor(router); - setIsDropdownOpen?.(false); - }; - const handleRefreshMetadata = async () => { if (!appService || isRefreshingMetadata) return; setIsRefreshingMetadata(true); @@ -442,10 +437,6 @@ const SettingsMenu: React.FC = ({
    - {(isTauriAppPlatform() || - (process.env['NODE_ENV'] === 'development' && isWebAppPlatform())) && ( - - )} {appService?.canCustomizeRootDir && ( diff --git a/apps/readest-app/src/app/library/utils/libraryUtils.ts b/apps/readest-app/src/app/library/utils/libraryUtils.ts index 09a26092..3d9147d2 100644 --- a/apps/readest-app/src/app/library/utils/libraryUtils.ts +++ b/apps/readest-app/src/app/library/utils/libraryUtils.ts @@ -651,8 +651,6 @@ export type BookContextMenuItemId = | 'markAbandoned' | 'clearStatus' | 'showDetails' - | 'reviewInEpubEditor' - | 'translateInEpubEditor' | 'bilingual' | 'showInFinder' | 'searchGoodreads' @@ -755,7 +753,7 @@ export const getBookContextMenuItemIds = (book: Book): BookContextMenuItemId[] = } ids.push('showDetails'); if (book.format?.toUpperCase() === 'EPUB') { - ids.push('bilingual', 'reviewInEpubEditor', 'translateInEpubEditor'); + ids.push('bilingual'); } ids.push('showInFinder', 'searchGoodreads'); if (book.uploadedAt && !book.downloadedAt) ids.push('download'); diff --git a/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx b/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx deleted file mode 100644 index e440b0c5..00000000 --- a/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx +++ /dev/null @@ -1,1797 +0,0 @@ -'use client'; - -import { invoke } from '@tauri-apps/api/core'; -import clsx from 'clsx'; -import DOMPurify from 'dompurify'; -import { - ArrowLeft, - BookOpenCheck, - Check, - Download, - ExternalLink, - FileText, - Languages, - RefreshCw, - Rocket, - Save, - Sparkles, - Wrench, -} from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import type { Dispatch, ReactNode, SetStateAction } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { isTauriAppPlatform } from '@/services/environment'; -import { openExternalUrl } from '@/utils/open'; - -type LaunchState = 'idle' | 'launching' | 'ready' | 'failed'; -type LaunchMode = 'review' | 'translate'; - -type LaunchResponse = { - ok: boolean; - url?: string; - reused?: boolean; - reviewRoot?: string; - version?: string; - sessionId?: string | null; - error?: string; -}; - -type LaunchContext = { - mode?: LaunchMode; - epubPath?: string; - sessionId?: string; -}; - -type SessionSummary = { - id: string; - source_name?: string; - title?: string; - source_epub?: string; - row_count?: number; - touched_count?: number; - marked_count?: number; - feedback_md?: string; - feedback_jsonl?: string; - latest_export?: string; - series_id?: string; - series?: string; - metadata?: Record; -}; - -type SessionPayload = { - has_session: boolean; - id?: string; - title?: string; - source_name?: string; - source_epub?: string; - row_count?: number; - touched_count?: number; - marked_count?: number; - feedback_md?: string; - feedback_jsonl?: string; - review_root?: string; - error?: string; -}; - -type ReviewRow = { - id: string; - file: string; - file_label?: string; - document_title?: string; - file_order?: number; - file_row_index?: number; - ja_p_index?: number; - cn_p_index?: number; - jp_html: string; - jp_text: string; - cn_html: string; - cn_text?: string; - current_html: string; - marked?: boolean; - issue_type?: string; - severity?: string; - comment?: string; - learn_note?: string; - tags?: string; - edited?: boolean; - updated_at?: string; -}; - -type RowsPayload = { - rows: ReviewRow[]; -}; - -type StructurePayload = { - chapters?: Array<{ - id: string; - title?: string; - kind?: string; - row_count?: number; - parts?: Array<{ id: string; title?: string; source_title?: string; file?: string }>; - items?: Array<{ id: string; title?: string; kind?: string; file?: string }>; - }>; -}; - -type GptConfig = { - configured?: boolean; - base_url?: string; - model?: string; - key_source?: string; - updated_at?: string; - translation_prompt?: string; - format_prompt?: string; - character_prompt?: string; - glossary_path?: string; - prompt_defaults?: { - translation_prompt?: string; - format_prompt?: string; - character_prompt?: string; - }; -}; - -type TranslationDefaultsPayload = { - version?: string; - gpt?: GptConfig; - defaults?: { - output_mode?: string; - range_mode?: string; - limit?: number; - temperature?: number; - translation_prompt?: string; - format_prompt?: string; - character_prompt?: string; - glossary_path?: string; - }; - prompt_defaults?: GptConfig['prompt_defaults']; -}; - -type TranslationSourcePayload = { - session: SessionSummary; - series_config?: { - series_id?: string; - translation_prompt?: string; - format_prompt?: string; - character_prompt?: string; - glossary_path?: string; - }; - source_count: number; - stats?: Record; - files?: Array<{ file: string; count: number }>; - sample?: Array<{ id: string; file: string; p_index: number; text: string }>; -}; - -type TranslationJob = { - id?: string; - status?: string; - output_epub?: string; - output_session_id?: string; - error?: string; - logs?: Array<{ ts?: string; level?: string; message?: string }>; - progress?: { - current?: number; - total?: number; - percent?: number; - failed?: number; - message?: string; - current_file?: string; - current_item_id?: string; - }; - settings_summary?: Record; -}; - -type TranslationJobPayload = { - status?: string; - job_id?: string; - job?: TranslationJob; - error?: string; -}; - -type EditState = { - current_html: string; - marked: boolean; - issue_type: string; - severity: string; - tags: string; - comment: string; - learn_note: string; -}; - -type TranslationFormState = { - base_url: string; - model: string; - api_key: string; - glossary_path: string; - translation_prompt: string; - format_prompt: string; - character_prompt: string; - output_mode: 'bilingual' | 'translated'; - range_mode: 'limit' | 'all'; - limit: number; - temperature: number; - use_series_config: boolean; -}; - -const featureBlocks: Array<{ - mode: LaunchMode; - title: string; - description: string; - icon: typeof BookOpenCheck; -}> = [ - { - mode: 'review', - title: '校对', - description: '逐段读取、修改译文、记录问题、单段重翻与导出 EPUB。', - icon: BookOpenCheck, - }, - { - mode: 'translate', - title: '翻译', - description: '设置 API、提示词、术语表、范围与输出格式,启动整书翻译。', - icon: Languages, - }, -]; - -const emptyEditState: EditState = { - current_html: '', - marked: false, - issue_type: '', - severity: '', - tags: '', - comment: '', - learn_note: '', -}; - -const emptyTranslationForm: TranslationFormState = { - base_url: '', - model: '', - api_key: '', - glossary_path: '', - translation_prompt: '', - format_prompt: '', - character_prompt: '', - output_mode: 'bilingual', - range_mode: 'limit', - limit: 20, - temperature: 0.2, - use_series_config: false, -}; - -const cnHtmlPurifyOptions = { - ALLOWED_TAGS: [ - 'ruby', - 'rb', - 'rt', - 'rp', - 'span', - 'mark', - 'br', - 'em', - 'strong', - 'b', - 'i', - 'u', - 's', - 'sub', - 'sup', - ], - ALLOWED_ATTR: ['class', 'title'], - ALLOW_DATA_ATTR: false, -}; - -const LaunchButton = ({ - onClick, - disabled, - children, - variant = 'primary', -}: { - onClick: () => void; - disabled?: boolean; - children: ReactNode; - variant?: 'primary' | 'ghost'; -}) => ( - -); - -const ToolbarButton = ({ - onClick, - disabled, - children, - icon, - variant = 'ghost', -}: { - onClick: () => void; - disabled?: boolean; - children: ReactNode; - icon?: ReactNode; - variant?: 'primary' | 'ghost'; -}) => ( - -); - -const sanitizeInlineHtml = (html: string) => DOMPurify.sanitize(html || '', cnHtmlPurifyOptions); - -const stripHtml = (html: string) => { - if (typeof window === 'undefined') return html.replace(/<[^>]*>/g, ''); - const div = document.createElement('div'); - div.innerHTML = sanitizeInlineHtml(html); - return div.textContent || div.innerText || ''; -}; - -const formatPercent = (value: number) => - `${Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0)).toFixed(value % 1 ? 1 : 0)}%`; - -const getSessionTitle = (session: SessionSummary | SessionPayload | null) => - session?.title || session?.source_name || session?.id || '未选择 EPUB'; - -async function sidecarApi(baseUrl: string, path: string, options: RequestInit = {}): Promise { - const url = new URL(path, baseUrl); - const headers = - options.body === undefined - ? options.headers - : { - 'Content-Type': 'application/json', - ...(options.headers || {}), - }; - const response = await fetch(url.toString(), { - ...options, - headers, - }); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(String(data.error || `HTTP ${response.status}`)); - } - return data as T; -} - -const openSessionBody = (sessionId: string, activate = true) => - JSON.stringify({ session_id: sessionId, activate }); - -const sessionFromPathBody = (epubPath: string, activate = true) => - JSON.stringify({ epub_path: epubPath, activate }); - -function readLaunchContextFromBrowser(): LaunchContext { - if (typeof window === 'undefined') return {}; - const params = new URLSearchParams(window.location.search); - let context: LaunchContext = {}; - try { - context = JSON.parse(sessionStorage.getItem('reviewEditorLaunchContext') || '{}'); - } catch (_error) { - context = {}; - } - if (params.has('epub_path')) { - context.epubPath = params.get('epub_path') || undefined; - } - if (params.has('session_id')) { - context.sessionId = params.get('session_id') || undefined; - } - const requestedMode = params.get('mode') || context.mode; - if (requestedMode === 'translate' || requestedMode === 'review') { - context.mode = requestedMode; - } - return context; -} - -function rowGroupTitle(row: ReviewRow) { - return row.document_title || row.file_label || row.file; -} - -function rowMatchesScope(row: ReviewRow, scope: string) { - return scope === 'all' || row.file === scope || rowGroupTitle(row) === scope; -} - -function updateUrlSession( - router: ReturnType, - mode: LaunchMode, - sessionId?: string | null, -) { - if (typeof window === 'undefined') return; - const params = new URLSearchParams(window.location.search); - params.delete('epub_path'); - params.set('mode', mode); - if (sessionId) { - params.set('session_id', sessionId); - } else { - params.delete('session_id'); - } - router.replace(`/review-editor?${params.toString()}`); -} - -function GptConfigPanel({ - form, - setForm, - onSave, - disabled, - compact = false, -}: { - form: TranslationFormState; - setForm: Dispatch>; - onSave: () => Promise; - disabled?: boolean; - compact?: boolean; -}) { - const [open, setOpen] = useState(!compact); - const [saving, setSaving] = useState(false); - const [message, setMessage] = useState(''); - - const save = async () => { - setSaving(true); - setMessage(''); - try { - await onSave(); - setMessage('已保存 API 与提示词设置。'); - } catch (error) { - setMessage(error instanceof Error ? error.message : String(error)); - } finally { - setSaving(false); - } - }; - - return ( -
    - - {open ? ( -
    -
    - - -
    - - - - - -
    - -
    - - -
    -
    -
    -
    -

    AI 翻译 EPUB

    -

    选择书架中的 EPUB 后设置范围、输出格式和必要提示词。

    -
    -
    - - -
    -
    - -
    -
    -
    -
    -

    输出与范围

    - 未读取源书信息 -
    -
    - - - - -
    -
    - -
    -
    -

    API 与术语表

    - 未读取配置 -
    -
    - - - - -
    -
    - -
    -
    - -
    -
    -

    提示词

    - -
    - - - -
    - -
    -
    -
    - - -
    -
    -
    - -
    -
    -
    -
    -

    打开中日双语 EPUB

    -

    上传一个“日文灰字 + 中文译文紧随其后”的双语 EPUB,审校记录会保存在本机或服务器的审校目录中。

    -
    - -
    - -
    - - -
    - -
    -
    -

    已有审校会话

    - -
    -
    -
    -
    -
    - -
    - - -
    -
    -
    -
    目录
    -

    选择章节开始阅读

    -
    -
    -
    -
    - - - -
    - - -
    - - -
    -
    -
    -
    - - -
    - - - - - - - diff --git a/apps/readest-app/tools/epub-review-editor/static/style.css b/apps/readest-app/tools/epub-review-editor/static/style.css deleted file mode 100644 index 35717637..00000000 --- a/apps/readest-app/tools/epub-review-editor/static/style.css +++ /dev/null @@ -1,2023 +0,0 @@ -:root { - color-scheme: light; - --bg: #f4f6f8; - --panel: #ffffff; - --panel-soft: #f8fafc; - --ink: #222426; - --muted: #68707a; - --line: #d8dee6; - --line-soft: #e7edf3; - --accent: #1d6f5f; - --accent-strong: #164d43; - --accent-soft: #e3f0ec; - --warn: #a85f00; - --bad: #a43b31; - --source: #73777f; - --mark: #fff0a6; - --shadow: 0 18px 42px rgba(25, 34, 45, 0.18); - --hover-bg: #eef3f6; - --badge-bg: #e8edf2; - --success-soft: #e9f5ee; - --marked-soft: #fff0d5; - --toast-bg: #1f2933; - --topbar-offset: 78px; - --topbar-live-height: 78px; - --reader-bg: #f4f6f8; - --reader-paper: #ffffff; - --reader-ink: #222426; - --reader-source: #73777f; - --reader-border: #e7edf3; - --reader-font-size: 17px; - --reader-line-height: 1.95; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - background: var(--bg); - color: var(--ink); - font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', sans-serif; - font-size: 15px; - line-height: 1.65; -} - -body[data-reader-theme='eye'] { - --reader-bg: #edf4ed; - --reader-paper: #fbfff8; - --reader-ink: #243127; - --reader-source: #6a7468; - --reader-border: #d7e4d4; - --bg: #edf4ed; -} - -body[data-reader-theme='night'] { - color-scheme: dark; - --reader-bg: #171614; - --reader-paper: #211f1b; - --reader-ink: #e8e0d2; - --reader-source: #aaa192; - --reader-border: #38342d; - --bg: #171614; - --panel: #24221e; - --panel-soft: #2c2924; - --ink: #ece5d8; - --muted: #aaa192; - --line: #3c382f; - --line-soft: #332f28; - --accent: #80bfa8; - --accent-strong: #b2dbc8; - --accent-soft: #243d34; - --source: #aaa192; - --mark: #6b5b1a; - --hover-bg: #302d27; - --badge-bg: #353128; - --success-soft: #243d34; - --marked-soft: #4a381e; - --toast-bg: #11100e; - --shadow: 0 18px 42px rgba(0, 0, 0, 0.42); -} - -button, -input, -select, -textarea { - font: inherit; -} - -.topbar { - height: var(--topbar-offset); - padding: 10px 18px; - display: grid; - grid-template-columns: auto minmax(240px, 1fr) auto auto; - align-items: center; - gap: 14px; - background: var(--panel); - border-bottom: 1px solid var(--line); -} - -body:not(.startOpen) .topbar { - position: fixed; - top: 0; - left: 0; - right: 0; - z-index: 14; - transform: translateY(calc(8px - var(--topbar-offset))); - transition: - transform 160ms ease, - box-shadow 160ms ease; - box-shadow: 0 8px 24px rgba(25, 34, 45, 0); -} - -body:not(.startOpen).chromeVisible .topbar, -body:not(.startOpen) .topbar:hover, -body:not(.startOpen) .topbar:focus-within { - transform: translateY(0); - box-shadow: var(--shadow); -} - -.brandBlock { - min-width: 0; -} - -.bookshelfNavButton { - font-weight: 700; - border-color: var(--accent); - color: var(--accent-strong); - background: var(--accent-soft); -} - -.bookshelfNavButton.active { - background: var(--accent); - border-color: var(--accent); - color: #ffffff; -} - -h1 { - margin: 0; - font-size: 19px; - font-weight: 700; -} - -.versionBadge { - display: inline-flex; - align-items: center; - min-height: 20px; - margin-left: 6px; - padding: 0 6px; - border-radius: 6px; - background: var(--badge-bg); - color: var(--muted); - font-size: 12px; - font-weight: 600; - vertical-align: middle; -} - -#sessionMeta { - margin: 2px 0 0; - color: var(--muted); - font-size: 12px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.modeTabs { - display: inline-grid; - grid-template-columns: 1fr 1fr; - border: 1px solid var(--line); - border-radius: 8px; - overflow: hidden; - background: var(--panel-soft); -} - -.modeButton { - border: 0; - border-radius: 0; - background: transparent; - min-width: 86px; -} - -.modeButton.active { - background: var(--accent); - color: #ffffff; -} - -.toolbar button.active { - border-color: var(--accent); - background: var(--accent-soft); - color: var(--accent-strong); -} - -.toolbar { - display: flex; - gap: 8px; - flex-wrap: wrap; - justify-content: flex-end; -} - -button { - border: 1px solid var(--line); - background: var(--panel); - color: var(--ink); - min-height: 34px; - padding: 5px 11px; - border-radius: 6px; - cursor: pointer; -} - -button:hover { - border-color: var(--accent); -} - -button:disabled { - cursor: not-allowed; - opacity: 0.55; -} - -button.primary, -#saveBtn, -#quickSaveBtn, -#exportBtn { - background: var(--accent); - border-color: var(--accent); - color: #ffffff; -} - -button.primary:hover, -#saveBtn:hover, -#quickSaveBtn:hover, -#exportBtn:hover { - background: var(--accent-strong); -} - -.startScreen, -.bookshelfScreen, -.translationScreen { - min-height: calc(100vh - var(--topbar-offset)); - padding: 28px; - background: var(--bg); -} - -.startPanel, -.translationPanel { - max-width: 980px; - margin: 0 auto; - display: grid; - gap: 16px; -} - -.libraryShell { - width: min(1480px, 100%); - min-height: calc(100vh - var(--topbar-offset) - 56px); - margin: 0 auto; - display: grid; - grid-template-columns: 248px minmax(0, 1fr); - gap: 28px; -} - -.librarySidebar { - display: grid; - grid-template-rows: auto auto minmax(0, 1fr) auto; - gap: 14px; - min-height: 0; - padding: 8px 0; -} - -.libraryBrandRow { - display: flex; - align-items: center; - gap: 10px; - height: 36px; - font-weight: 800; -} - -.iconTextButton { - width: 32px; - height: 32px; - padding: 0; - border: 0; - background: transparent; - font-size: 18px; -} - -.librarySearchLabel { - display: grid; - gap: 6px; - color: var(--muted); - font-size: 12px; - font-weight: 700; -} - -.librarySearchLabel input { - width: 100%; - border: 1px solid var(--line); - border-radius: 5px; - padding: 8px 10px; - background: var(--panel); - color: var(--ink); -} - -.libraryFacetList { - display: grid; - gap: 10px; - overflow: auto; - padding-right: 4px; -} - -.facetGroup { - display: grid; - gap: 4px; -} - -.facetGroupTitle { - padding: 10px 8px 2px; - color: var(--muted); - font-size: 12px; - font-weight: 800; -} - -.facetButton { - width: 100%; - min-height: 36px; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--ink); - text-align: left; -} - -.facetButton span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.facetButton b { - min-width: 24px; - border-radius: 999px; - padding: 2px 7px; - background: var(--line-soft); - color: var(--muted); - text-align: center; - font-size: 11px; -} - -.facetButton:hover, -.facetButton.active { - background: #e8edf2; -} - -.facetButton.active { - color: var(--accent-strong); - font-weight: 800; -} - -.facetButton.active b { - background: var(--accent); - color: #ffffff; -} - -.facetEmpty { - padding: 0 8px 6px; - color: var(--muted); - font-size: 12px; -} - -.librarySidebarFooter { - display: grid; - grid-template-columns: 1fr; - gap: 8px; -} - -.bookshelfPanel { - min-width: 0; - display: grid; - gap: 18px; - align-content: start; -} - -.startHeader, -.uploadBox, -.sessionBrowser, -.translationHeader, -.emptyBookshelf { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 8px; - padding: 18px; -} - -.startHeader, -.translationHeader { - display: flex; - justify-content: space-between; - gap: 16px; - align-items: flex-start; -} - -.bookshelfHeader { - display: flex; - justify-content: space-between; - gap: 16px; - align-items: flex-end; - padding: 4px 0 2px; -} - -.bookshelfMeta { - color: var(--muted); - font-size: 13px; -} - -.startHeader h2, -.bookshelfHeader h2, -.translationHeader h2 { - margin: 0 0 4px; - font-size: 21px; -} - -.startHeader p, -.bookshelfHeader p, -.translationHeader p { - margin: 0; - color: var(--muted); - max-width: 680px; -} - -.bookshelfActions, -.translationHeaderActions, -.translationActions { - display: flex; - gap: 8px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.bookshelfGrid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 30px 32px; - align-items: start; -} - -.bookshelfItem { - display: grid; - gap: 10px; - border: 0; - padding: 0; - background: transparent; - cursor: pointer; - min-width: 0; -} - -.bookshelfItem:hover, -.bookshelfItem:focus-visible { - outline: 0; -} - -.bookshelfItem.active { - color: var(--accent-strong); -} - -.bookCover { - position: relative; - aspect-ratio: 2 / 3; - overflow: hidden; - border-radius: 6px; - background: #dfe6ee; - box-shadow: 0 10px 22px rgba(18, 28, 38, 0.18); -} - -.bookCover img { - width: 100%; - height: 100%; - display: block; - object-fit: cover; -} - -.bookSpine { - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: linear-gradient(180deg, #45615b, #213d38); - color: #ffffff; - font-size: 34px; - font-weight: 700; -} - -.bookOverlay { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - justify-content: flex-end; - gap: 8px; - padding: 12px; - background: rgba(18, 24, 31, 0.74); - color: #ffffff; - opacity: 0; - transition: opacity 140ms ease; -} - -.bookCover:hover .bookOverlay, -.bookshelfItem:focus-within .bookOverlay, -.bookshelfItem:focus-visible .bookOverlay { - opacity: 1; -} - -.bookOverlay h3, -.bookCaption h3 { - margin: 0; - font-size: 14px; - line-height: 1.45; -} - -.bookOverlay h3 { - display: -webkit-box; - overflow: hidden; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; -} - -.bookOverlay > p { - display: -webkit-box; - overflow: hidden; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -.bookOverlay p, -.bookCaption p { - margin: 0; - font-size: 13px; -} - -.bookFacts { - display: flex; - flex-wrap: wrap; - gap: 8px; - font-size: 12px; -} - -.overlayFacts { - color: rgba(255, 255, 255, 0.82); -} - -.bookActions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; -} - -.bookActions button { - min-width: 0; - border-color: rgba(255, 255, 255, 0.5); - background: rgba(255, 255, 255, 0.12); - color: #ffffff; - padding: 7px 8px; -} - -.bookActions [data-open-translation] { - border-color: #ffffff; - background: #ffffff; - color: #18202a; -} - -.bookActions .dangerAction { - grid-column: 1 / -1; - border-color: rgba(255, 151, 151, 0.85); - background: rgba(168, 44, 44, 0.78); - color: #ffffff; -} - -.bookCaption { - display: grid; - gap: 2px; - text-align: left; -} - -.bookCaption h3 { - display: -webkit-box; - overflow: hidden; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -.bookCaption p { - color: var(--muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.translationPanel { - max-width: 1180px; -} - -.translationGrid { - display: grid; - grid-template-columns: minmax(0, 1fr) 340px; - gap: 16px; - align-items: start; -} - -.translationSettings, -.translationCard, -.translationProgressCard { - display: grid; - gap: 12px; -} - -.translationCard, -.translationProgressCard { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 8px; - padding: 16px; -} - -.translationOptions { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; -} - -.translationOptions.apiOptions { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.translationCard label { - display: grid; - gap: 5px; - color: var(--muted); - font-size: 12px; - font-weight: 700; -} - -.translationCard input, -.translationCard select, -.translationCard textarea { - width: 100%; - min-width: 0; - border: 1px solid var(--line); - border-radius: 6px; - padding: 7px 9px; - background: var(--panel); - color: var(--ink); - font-weight: 400; -} - -.translationCard textarea { - resize: vertical; - font-size: 13px; - line-height: 1.55; -} - -.translationOutput { - border: 1px solid var(--line-soft); - border-radius: 6px; - padding: 10px; - background: var(--panel-soft); -} - -.translationOutput p { - margin: 0; - color: var(--ink); - overflow-wrap: anywhere; -} - -.translationProgressCard { - position: sticky; - top: 18px; -} - -.translationProgress { - height: 12px; - overflow: hidden; - border-radius: 999px; - background: var(--line-soft); -} - -.translationProgressBar { - width: 0; - height: 100%; - border-radius: inherit; - background: var(--accent); - transition: width 180ms ease; -} - -.translationProgressText { - white-space: pre-wrap; - color: var(--muted); - font-size: 13px; -} - -.translationLogs { - display: grid; - gap: 8px; - max-height: 420px; - overflow: auto; -} - -.translationLog { - border-left: 3px solid var(--line); - padding: 6px 8px; - background: var(--panel-soft); -} - -.translationLog.warning { - border-left-color: var(--warn); -} - -.translationLog.error { - border-left-color: var(--bad); -} - -.translationLog span { - display: block; - color: var(--muted); - font-size: 11px; -} - -.translationLog p { - margin: 2px 0 0; - overflow-wrap: anywhere; -} - -.seriesConfigDrawer { - position: fixed; - z-index: 60; - top: 88px; - right: 18px; - bottom: 18px; - width: min(520px, calc(100vw - 36px)); - display: grid; - grid-template-rows: auto auto auto auto auto auto; - gap: 12px; - padding: 18px; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--panel); - box-shadow: var(--shadow); - overflow: auto; -} - -.seriesConfigDrawer[hidden] { - display: none; -} - -.seriesConfigHeader { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: flex-start; -} - -.seriesConfigHeader h2 { - margin: 0 0 4px; - font-size: 18px; -} - -.seriesConfigHeader p { - margin: 0; - color: var(--muted); - font-size: 13px; -} - -.seriesConfigDrawer label { - display: grid; - gap: 6px; - color: var(--muted); - font-size: 12px; - font-weight: 800; -} - -.seriesConfigDrawer input, -.seriesConfigDrawer textarea { - width: 100%; - border: 1px solid var(--line); - border-radius: 6px; - padding: 8px 10px; - background: var(--panel); - color: var(--ink); - font-weight: 400; -} - -.seriesConfigDrawer textarea { - resize: vertical; - line-height: 1.55; -} - -.seriesConfigActions { - display: flex; - justify-content: flex-end; -} - -.emptyBookshelf { - grid-column: 1 / -1; - display: grid; - gap: 10px; - justify-items: start; -} - -.emptyBookshelf h3 { - margin: 0; - font-size: 17px; -} - -.emptyBookshelf p { - margin: 0; - color: var(--muted); -} - -.uploadBox { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 12px; - align-items: stretch; -} - -.fileDrop { - min-width: 0; - display: grid; - gap: 4px; - border: 1px dashed #aeb8c4; - border-radius: 8px; - padding: 14px; - background: var(--panel-soft); - cursor: pointer; -} - -.fileDrop:hover { - border-color: var(--accent); -} - -.fileDrop input { - position: absolute; - opacity: 0; - pointer-events: none; -} - -.fileDrop:focus-within { - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(29, 111, 95, 0.12); -} - -.fileDropTitle { - font-weight: 700; -} - -.fileDropName { - color: var(--muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.compactTitle { - margin-bottom: 12px; -} - -.compactTitle span { - color: var(--muted); - font-size: 12px; -} - -.sessionList { - display: grid; - gap: 10px; -} - -.sessionItem { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 12px; - align-items: center; - border: 1px solid var(--line-soft); - border-radius: 8px; - padding: 12px; - background: var(--panel-soft); -} - -.sessionItem.active { - border-color: var(--accent); - background: var(--accent-soft); -} - -.sessionInfo { - min-width: 0; -} - -.sessionInfo h3 { - margin: 0 0 2px; - font-size: 15px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sessionInfo p { - margin: 0 0 6px; - color: var(--muted); - font-size: 12px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sessionFacts { - display: flex; - flex-wrap: wrap; - gap: 8px; - color: var(--muted); - font-size: 12px; -} - -.emptySession { - color: var(--muted); - border: 1px dashed var(--line); - border-radius: 8px; - padding: 18px; - text-align: center; - background: var(--panel-soft); -} - -.layout { - height: 100vh; - display: block; - min-width: 0; - position: relative; -} - -.sidebar { - position: fixed; - top: 8px; - left: 0; - bottom: 0; - z-index: 24; - width: min(380px, calc(100vw - 24px)); - border-right: 1px solid var(--line); - background: var(--panel-soft); - min-height: 0; - display: flex; - flex-direction: column; - box-shadow: var(--shadow); -} - -.sideHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px; - border-bottom: 1px solid var(--line); - background: var(--panel); -} - -.sideHeader button { - min-height: 28px; - padding: 2px 8px; - color: var(--muted); -} - -.tocPanel, -.searchPanel { - min-height: 0; - display: flex; - flex: 1; - flex-direction: column; -} - -.searchbox { - padding: 12px; - border-bottom: 1px solid var(--line); - display: grid; - gap: 8px; -} - -.searchbox input { - width: 100%; - min-height: 36px; - border: 1px solid var(--line); - border-radius: 6px; - padding: 6px 10px; - background: var(--panel); -} - -.searchScope { - display: grid; - grid-template-columns: 1fr 1fr; - border: 1px solid var(--line); - border-radius: 6px; - overflow: hidden; - background: var(--panel); -} - -.searchScope button { - min-height: 30px; - border: 0; - border-radius: 0; - background: transparent; - color: var(--muted); - padding: 3px 8px; - font-size: 13px; -} - -.searchScope button.active { - background: var(--accent); - color: #ffffff; -} - -.stats { - padding: 8px 12px; - color: var(--muted); - font-size: 12px; - border-bottom: 1px solid var(--line); -} - -.tocToolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 8px 12px 4px; - color: var(--ink); - font-size: 13px; -} - -.tocToolbar div { - display: flex; - gap: 4px; -} - -.tocToolbar button { - min-height: 26px; - border: 1px solid var(--line); - border-radius: 6px; - background: var(--panel); - color: var(--muted); - padding: 2px 8px; - font-size: 12px; -} - -.toc { - min-height: 0; - flex: 1; - overflow: auto; - padding: 4px 0 8px; -} - -.tocChapter { - margin: 0 8px 6px; -} - -.tocChapterRow { - width: 100%; - display: grid; - grid-template-columns: 24px minmax(0, 1fr); - align-items: center; - border-radius: 6px; -} - -.tocToggle { - width: 24px; - height: 34px; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--muted); - padding: 0; - line-height: 1; -} - -.tocToggle:disabled { - opacity: 1; - color: var(--accent); - font-size: 12px; - font-weight: 700; -} - -.tocChapterButton, -.tocPartButton, -.tocImageButton { - width: 100%; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - align-items: center; - text-align: left; - border: 0; - border-radius: 6px; - background: transparent; - padding: 7px 8px; -} - -.tocChapterButton { - font-weight: 700; -} - -.tocChapter.imageChapter .tocChapterButton { - font-weight: 600; -} - -.tocPartButton, -.tocImageButton { - margin-left: 24px; - width: calc(100% - 24px); - color: var(--muted); -} - -.tocImageButton { - padding-left: 14px; - font-size: 13px; -} - -.tocImageButton .tocTitle::before { - content: '插图 · '; - color: var(--accent); - font-weight: 600; -} - -.tocChapterRow:hover, -.tocChapterRow.active, -.tocChapterButton:hover, -.tocToggle:hover, -.tocPartButton:hover, -.tocImageButton:hover { - background: var(--hover-bg); -} - -.tocChapterRow.active, -.tocChapterButton.active, -.tocPartButton.active, -.tocImageButton.active { - background: var(--accent-soft); - color: var(--accent-strong); -} - -.tocTitle { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tocCount { - color: var(--muted); - font-weight: 400; - font-size: 12px; - white-space: nowrap; -} - -.rowList { - overflow: auto; - min-height: 0; - flex: 1; -} - -.rowItem { - width: 100%; - display: block; - text-align: left; - border: 0; - border-bottom: 1px solid var(--line-soft); - border-radius: 0; - background: transparent; - padding: 10px 12px; -} - -.rowItem:hover { - background: var(--hover-bg); -} - -.rowItem.active { - background: var(--accent-soft); - border-left: 4px solid var(--accent); - padding-left: 8px; -} - -.rowItem.touched .rowBadge { - background: var(--success-soft); - color: var(--accent-strong); -} - -.rowItem.marked .rowBadge { - background: var(--marked-soft); - color: var(--warn); -} - -.emptyRowItem { - color: var(--muted); - cursor: default; -} - -.emptyRowItem:hover { - background: transparent; -} - -.imageRowItem { - cursor: default; -} - -.imageRowItem:hover { - background: var(--accent-soft); -} - -.rowMeta { - display: flex; - align-items: center; - gap: 8px; - color: var(--muted); - font-size: 12px; - margin-bottom: 4px; -} - -.rowBadge { - display: inline-flex; - align-items: center; - min-height: 20px; - padding: 0 6px; - border-radius: 999px; - background: var(--badge-bg); -} - -.rowPreview { - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -.readerPane, -.editorPane { - min-width: 0; - min-height: 0; - overflow: auto; -} - -.readerPane { - height: 100vh; - padding: 0; - background: var(--reader-bg); - color: var(--reader-ink); -} - -.readerPane, -.editorPane { - transition: margin-left 160ms ease; -} - -.readerHeader { - position: fixed; - top: var(--topbar-live-height); - left: 0; - right: 0; - z-index: 22; - display: flex; - justify-content: space-between; - gap: 16px; - align-items: center; - padding: 16px 28px; - background: var(--reader-paper); - background: color-mix(in srgb, var(--reader-paper) 94%, transparent); - border-bottom: 1px solid var(--line); - transform: translateY(calc(-100% - var(--topbar-live-height) + 8px)); - opacity: 0; - pointer-events: none; - transition: - transform 160ms ease, - opacity 160ms ease, - box-shadow 160ms ease; -} - -body.startOpen .readerHeader, -body.chromeVisible .readerHeader, -.readerHeader:hover, -.readerHeader:focus-within { - transform: translateY(0); - opacity: 1; - pointer-events: auto; - box-shadow: 0 10px 26px rgba(25, 34, 45, 0.12); -} - -.chapterKicker { - color: var(--muted); - font-size: 12px; - margin-bottom: 2px; -} - -#readerTitle { - margin: 0; - font-size: 20px; - line-height: 1.35; -} - -.readerTools { - display: flex; - gap: 8px; - flex-shrink: 0; - flex-wrap: wrap; - justify-content: flex-end; - align-items: center; -} - -.appearanceControls { - display: flex; - gap: 10px; - align-items: center; - flex-wrap: wrap; - padding-right: 8px; - border-right: 1px solid var(--line); -} - -.themeSwitch { - display: inline-grid; - grid-template-columns: repeat(3, minmax(42px, auto)); - border: 1px solid var(--line); - border-radius: 6px; - overflow: hidden; - background: var(--panel); -} - -.themeSwitch button { - min-height: 30px; - border: 0; - border-radius: 0; - background: transparent; - color: var(--muted); - padding: 3px 9px; - font-size: 12px; -} - -.themeSwitch button.active { - background: var(--accent); - color: #ffffff; -} - -.readerSlider { - display: inline-grid; - grid-template-columns: auto 78px 34px; - gap: 6px; - align-items: center; - color: var(--muted); - font-size: 12px; - white-space: nowrap; -} - -.readerSlider input { - width: 78px; - accent-color: var(--accent); -} - -.readerSlider output { - min-width: 30px; - color: var(--ink); - text-align: right; - font-variant-numeric: tabular-nums; -} - -.readingFlow { - max-width: 900px; - margin: 0 auto; - padding: 36px 42px 100px; - background: var(--reader-paper); - min-height: 100vh; - border-left: 1px solid var(--reader-border); - border-right: 1px solid var(--reader-border); -} - -.readBlock { - position: relative; - padding: 18px 0 22px 12px; - border-left: 4px solid transparent; - cursor: pointer; -} - -.partDivider { - margin: 26px 0 14px; - padding: 12px 0 8px; - border-bottom: 2px solid var(--line); - color: var(--accent-strong); - font-size: 17px; -} - -.readBlock:hover { - background: linear-gradient(90deg, rgba(29, 111, 95, 0.055), transparent 42%); -} - -.readBlock.active { - border-left-color: var(--accent); -} - -.readBlock.marked { - border-left-color: var(--warn); -} - -.readSource { - color: var(--reader-source); - font-size: calc(var(--reader-font-size) - 4px); - line-height: calc(var(--reader-line-height) - 0.2); - margin-bottom: 8px; -} - -.readCn { - color: var(--reader-ink); - font-size: var(--reader-font-size); - line-height: var(--reader-line-height); - letter-spacing: 0; -} - -.readCn mark, -.readCn .review-mark, -.cnEditor mark, -.cnEditor .review-mark { - background: var(--mark); - color: inherit; -} - -.imageChapterFigure { - margin: 0 auto 28px; - padding: 0 0 22px; - border-bottom: 1px solid var(--line-soft); - text-align: center; -} - -.inlineImageFigure { - margin: 18px auto 30px; - padding-top: 8px; -} - -.imageChapterFigure img { - display: block; - max-width: 100%; - max-height: calc(100vh - 190px); - width: auto; - height: auto; - margin: 0 auto; - object-fit: contain; -} - -.imageChapterFigure figcaption { - display: flex; - justify-content: center; - gap: 10px; - flex-wrap: wrap; - margin-top: 10px; - color: var(--muted); - font-size: 12px; -} - -.editorPane { - height: 100vh; - padding: 18px; -} - -.emptyState { - height: 100%; - min-height: 240px; - display: grid; - place-items: center; - color: var(--muted); -} - -.editorCard { - max-width: 1040px; - margin: 0 auto; -} - -.rowHeader { - display: flex; - justify-content: space-between; - gap: 18px; - align-items: flex-start; - margin-bottom: 14px; -} - -.rowId { - font-weight: 700; - font-size: 18px; -} - -.rowPath { - color: var(--muted); - font-size: 12px; - word-break: break-all; -} - -.markToggle { - white-space: nowrap; - display: flex; - align-items: center; - gap: 8px; - color: var(--warn); - font-weight: 600; -} - -.pair, -.reviewFields, -.notes, -.actions { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 8px; - padding: 14px; - margin-bottom: 12px; -} - -.pair h2, -.sectionTitle h2 { - margin: 0 0 8px; - font-size: 15px; -} - -.sourceText, -.quickSource { - color: var(--source); - white-space: pre-wrap; - background: var(--panel-soft); - border: 1px solid var(--line-soft); - border-radius: 6px; - padding: 12px; -} - -.sectionTitle { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - margin-bottom: 8px; -} - -.inlineTools { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 8px; -} - -.cnEditor { - min-height: 160px; - background: var(--panel); - border: 1px solid var(--line); - border-radius: 6px; - padding: 12px; - outline: none; - white-space: pre-wrap; -} - -.cnEditor:focus { - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(29, 111, 95, 0.12); -} - -.reviewFields { - display: grid; - grid-template-columns: 1fr 1fr 2fr; - gap: 12px; -} - -label { - display: flex; - flex-direction: column; - gap: 5px; - font-weight: 600; -} - -label input, -label select, -label textarea { - font-weight: 400; -} - -input, -select, -textarea { - border: 1px solid var(--line); - border-radius: 6px; - background: var(--panel); - padding: 7px 9px; - color: var(--ink); -} - -textarea { - resize: vertical; -} - -.notes { - display: grid; - grid-template-columns: 1fr; - gap: 12px; -} - -.actions { - display: flex; - justify-content: center; - gap: 10px; -} - -.quickEditor { - position: fixed; - top: 8px; - right: 0; - bottom: 0; - width: min(520px, calc(100vw - 36px)); - z-index: 26; - display: flex; - flex-direction: column; - gap: 10px; - overflow: auto; - background: var(--panel); - border: 1px solid var(--line); - border-top: 0; - border-right: 0; - border-bottom: 0; - border-radius: 0; - box-shadow: var(--shadow); - padding: 14px; -} - -.quickHeader { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - border-bottom: 1px solid var(--line-soft); - padding-bottom: 10px; -} - -.quickSourceWrap { - border: 1px solid var(--line-soft); - border-radius: 6px; - background: var(--panel-soft); -} - -.toolSection { - border: 1px solid var(--line-soft); - border-radius: 8px; - background: var(--panel); - padding: 10px; -} - -.toolSection > summary, -.quickSourceWrap summary { - cursor: pointer; - padding: 2px 2px 8px; - color: var(--source); - font-weight: 700; -} - -.toolSection[open] > summary { - border-bottom: 1px solid var(--line-soft); - margin-bottom: 10px; -} - -.quickSource { - max-height: none; - overflow: visible; - font-size: 13px; - border: 0; - border-radius: 0; - background: transparent; -} - -.quickField { - display: flex; - flex-direction: column; - gap: 5px; -} - -.quickCnEditor { - min-height: 150px; - max-height: 260px; - overflow: auto; -} - -.quickControls { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: 8px; - align-items: center; -} - -.quickActions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 2px; -} - -.gptBox { - border: 1px solid var(--line); - border-radius: 8px; - padding: 12px; - background: var(--panel-soft); -} - -.gptStatus { - color: var(--muted); - font-size: 12px; - margin-bottom: 8px; -} - -.gptConfig { - display: grid; - gap: 10px; - margin-bottom: 10px; -} - -.gptConfig[hidden] { - display: none !important; -} - -details.gptConfig { - margin-bottom: 0; -} - -.gptConfigGroup { - display: grid; - gap: 8px; - padding-top: 10px; - border-top: 1px solid var(--line-soft); -} - -.gptConfigGroup:first-child { - padding-top: 0; - border-top: 0; -} - -.gptConfigTitle { - color: var(--muted); - font-size: 12px; - font-weight: 700; -} - -.gptConfigActions { - display: flex; - justify-content: flex-end; - gap: 8px; - flex-wrap: wrap; -} - -.gptConfig textarea { - min-height: 112px; - font-size: 13px; - line-height: 1.55; -} - -.glossaryMeta { - color: var(--muted); - font-size: 12px; - line-height: 1.5; - overflow-wrap: anywhere; -} - -.glossaryTools { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto auto; - gap: 8px; - align-items: center; -} - -.glossaryList { - display: grid; - gap: 6px; - max-height: 280px; - overflow: auto; - padding-right: 2px; -} - -.glossaryRow { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) auto; - gap: 6px; - align-items: center; -} - -.glossaryRow input { - min-width: 0; - font-size: 12px; -} - -.glossaryDelete { - padding-inline: 8px; -} - -.glossaryEmpty { - padding: 10px; - border: 1px dashed #cbd5df; - border-radius: 6px; - color: var(--muted); - font-size: 12px; - background: var(--panel); -} - -.gptActions { - display: flex; - gap: 8px; - justify-content: flex-end; - margin-top: 8px; -} - -.deliveryActions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; -} - -.gptCandidate { - margin-top: 10px; - padding: 10px; - border: 1px dashed var(--accent); - border-radius: 6px; - background: var(--panel); - max-height: 180px; - overflow: auto; - white-space: pre-wrap; -} - -.gptCandidateMeta { - margin-bottom: 8px; - padding-bottom: 8px; - border-bottom: 1px solid var(--line-soft); - color: var(--muted); - font-size: 12px; - line-height: 1.45; -} - -.toast { - position: fixed; - right: 16px; - bottom: 16px; - max-width: min(560px, calc(100vw - 32px)); - padding: 12px 14px; - background: var(--toast-bg); - color: #ffffff; - border-radius: 8px; - box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22); - z-index: 30; - white-space: pre-wrap; -} - -[hidden] { - display: none !important; -} - -@media (min-width: 1041px) { - body.sidebarOpen .readerPane, - body.sidebarOpen .editorPane { - margin-left: 380px; - } - - body.sidebarOpen .sidebar { - box-shadow: none; - } - - body.sidebarOpen .readerHeader { - left: 380px; - } - - body.quickOpen .readerHeader { - right: min(520px, calc(100vw - 36px)); - } -} - -@media (min-width: 1320px) { - body.quickOpen .readingFlow { - margin-left: 56px; - margin-right: 560px; - max-width: 840px; - } -} - -@media (max-width: 1040px) { - .topbar { - height: auto; - grid-template-columns: 1fr; - align-items: stretch; - } - - body:not(.startOpen) .topbar { - transform: translateY(calc(-100% + 8px)); - } - - #sessionMeta { - white-space: normal; - } - - .toolbar { - justify-content: flex-start; - } - - .layout { - height: auto; - min-height: 100vh; - } - - .sidebar { - top: 0; - width: min(360px, calc(100vw - 18px)); - max-height: none; - border-right: 0; - } - - .readerPane, - .editorPane { - height: auto; - min-height: 70vh; - } - - .readingFlow { - border: 0; - padding: 18px 18px 80px; - } - - .readerHeader { - top: var(--topbar-live-height); - padding: 12px 16px; - } - - .appearanceControls { - width: 100%; - padding-right: 0; - padding-bottom: 8px; - border-right: 0; - border-bottom: 1px solid var(--line); - } - - .reviewFields, - .quickControls { - grid-template-columns: 1fr; - } - - .quickEditor { - top: auto; - left: 10px; - right: 10px; - bottom: 10px; - width: auto; - max-height: 72vh; - border: 1px solid var(--line); - border-radius: 8px; - } - - .startScreen, - .bookshelfScreen, - .translationScreen { - padding: 14px; - } - - .startHeader, - .translationHeader, - .uploadBox, - .sessionItem, - .deliveryActions { - grid-template-columns: 1fr; - } - - .libraryShell { - grid-template-columns: 1fr; - gap: 18px; - } - - .librarySidebar { - position: static; - grid-template-rows: auto; - } - - .bookshelfHeader { - display: grid; - gap: 12px; - } - - .bookshelfActions, - .translationHeaderActions, - .bookshelfActions button, - .translationHeaderActions button { - width: 100%; - } - - .bookshelfGrid { - grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); - gap: 22px 18px; - } - - .bookOverlay { - position: absolute; - opacity: 1; - min-height: 0; - background: rgba(18, 24, 31, 0.68); - } - - .translationGrid { - grid-template-columns: 1fr; - } - - .translationProgressCard { - position: static; - } - - .translationOptions, - .translationOptions.apiOptions { - grid-template-columns: 1fr; - } - - .seriesConfigDrawer { - top: 12px; - right: 12px; - bottom: 12px; - width: calc(100vw - 24px); - } -} - -@media (max-width: 680px) { - .glossaryTools, - .glossaryRow { - grid-template-columns: 1fr; - } -} diff --git a/apps/readest-app/tools/epub-review-editor/test_inline_review_session_scope.py b/apps/readest-app/tools/epub-review-editor/test_inline_review_session_scope.py index 69f902e9..2bad4169 100644 --- a/apps/readest-app/tools/epub-review-editor/test_inline_review_session_scope.py +++ b/apps/readest-app/tools/epub-review-editor/test_inline_review_session_scope.py @@ -18,6 +18,14 @@ def write_json(path: Path, data): class InlineReviewSessionScopeTest(unittest.TestCase): + def test_sidecar_has_no_legacy_standalone_page(self): + with tempfile.TemporaryDirectory() as temp_dir: + app = server.create_app(Path(temp_dir)) + + response = app.test_client().get("/") + + self.assertEqual(response.status_code, 404) + def test_sanitize_cn_html_keeps_reader_review_inline_markup(self): sanitized = server.sanitize_cn_html( '文字注音' diff --git a/apps/readest-app/tools/epub-review-editor/version.py b/apps/readest-app/tools/epub-review-editor/version.py index 2f7fd6da..4dc88c86 100644 --- a/apps/readest-app/tools/epub-review-editor/version.py +++ b/apps/readest-app/tools/epub-review-editor/version.py @@ -1,4 +1,4 @@ -version = "0.16.5" +version = "1.0.0" VERSION_RULES = { "PATCH": "修复 bug 或兼容性小修", diff --git a/package.json b/package.json index 34bbef02..a9ae377c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "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", diff --git a/scripts/open-readest-latest.ps1 b/scripts/open-readest-latest.ps1 index 3ccfce58..94a6c96a 100644 --- a/scripts/open-readest-latest.ps1 +++ b/scripts/open-readest-latest.ps1 @@ -1,6 +1,6 @@ param( - [string]$Remote = "akai-tools", - [string]$Branch = "codex/desktop-review-editor-blocks", + [string]$Remote = "origin", + [string]$Branch = "codex/readest-inline-review-mode", [switch]$SkipPull, [switch]$CheckOnly, [switch]$KeepRunning @@ -57,7 +57,7 @@ function Stop-OldReadestDev { $_.ProcessId -ne $currentPid -and $_.CommandLine -and $_.CommandLine.ToLowerInvariant().Contains($repoLower) -and - ($_.CommandLine -match "tauri\s+dev|next\s+dev|@readest/readest-app|epub-reviewer:dev") + ($_.CommandLine -match "tauri\s+dev|next\s+dev|@readest/readest-app") } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue