From 48383b3db6042bffb220d315de325179e6d2cdcd Mon Sep 17 00:00:00 2001 From: akai Date: Fri, 10 Jul 2026 11:56:25 +0800 Subject: [PATCH] fix: harden inline review web workflow --- .../components/ReviewModeController.test.ts | 31 +++- .../services/reviewEditorService.test.ts | 132 ++++++++++++++++++ .../__tests__/store/review-mode-store.test.ts | 13 ++ .../src/app/api/review-editor/launch/route.ts | 8 +- .../app/reader/components/ReaderContent.tsx | 30 +++- .../components/ReviewModeController.tsx | 28 +++- .../reader/components/ReviewModeToggler.tsx | 31 +++- .../src/app/reader/components/ReviewPanel.tsx | 74 ++++++++-- .../src/services/reviewEditorService.ts | 64 +++++++-- apps/readest-app/src/store/reviewModeStore.ts | 27 +++- .../tools/epub-review-editor/MAINTENANCE.md | 7 +- .../tools/epub-review-editor/README.md | 8 +- .../tools/epub-review-editor/server.py | 62 +++++--- .../test_inline_review_session_scope.py | 121 ++++++++++++++++ .../tools/epub-review-editor/version.py | 2 +- 15 files changed, 579 insertions(+), 59 deletions(-) diff --git a/apps/readest-app/src/__tests__/components/ReviewModeController.test.ts b/apps/readest-app/src/__tests__/components/ReviewModeController.test.ts index 29a2bf3f..f8a155a3 100644 --- a/apps/readest-app/src/__tests__/components/ReviewModeController.test.ts +++ b/apps/readest-app/src/__tests__/components/ReviewModeController.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ appService: null }), +})); import { __testing } from '@/app/reader/components/ReviewModeController'; import type { BookDoc } from '@/libs/document'; @@ -144,4 +148,29 @@ describe('ReviewModeController marks', () => { expect(target.classList.contains('readest-review-active')).toBe(false); expect(doc.querySelectorAll('p')[3]?.classList.contains('readest-review-active')).toBe(true); }); + + test('clears the target paragraph when a saved translation is empty', () => { + const doc = document.implementation.createHTMLDocument('chapter'); + doc.body.innerHTML = '

原文

旧译文

'; + + __testing.applyReviewMarks( + doc, + 'chapter.xhtml', + [ + { + id: 'R00001', + file: 'chapter.xhtml', + ja_p_index: 0, + cn_p_index: 1, + jp_html: '原文', + jp_text: '原文', + cn_html: '旧译文', + current_html: '', + }, + ], + 'R00001', + ); + + expect(doc.querySelectorAll('p')[1]?.innerHTML).toBe(''); + }); }); diff --git a/apps/readest-app/src/__tests__/services/reviewEditorService.test.ts b/apps/readest-app/src/__tests__/services/reviewEditorService.test.ts index b7b06234..5ee74670 100644 --- a/apps/readest-app/src/__tests__/services/reviewEditorService.test.ts +++ b/apps/readest-app/src/__tests__/services/reviewEditorService.test.ts @@ -1,9 +1,14 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; +import type { ReviewRow } from '@/services/reviewEditorService'; import { + canLaunchInlineReviewEditor, createReviewSessionFromPath, + downloadReviewedEpub, exportReviewedEpub, generateReviewFeedback, + isReviewEditDirty, + launchInlineReviewEditor, loadInlineReviewData, loadReviewGlossary, saveReviewGptConfig, @@ -38,6 +43,133 @@ const firstFetchUrl = (fetchMock: ReturnType) => { }; describe('reviewEditorService', () => { + test('allows the inline review entry on desktop and local dev-web, but not hosted web', () => { + expect(canLaunchInlineReviewEditor({ isDesktopApp: true }, false)).toBe(true); + expect(canLaunchInlineReviewEditor({ isDesktopApp: false }, true)).toBe(true); + expect(canLaunchInlineReviewEditor({ isDesktopApp: false }, false)).toBe(false); + }); + + test('rejects a launch response when the Next route returns an HTTP error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json( + { ok: false, error: '本地 Web 审校入口未启用' }, + { status: 404 }, + ), + ), + ); + + await expect( + launchInlineReviewEditor( + { + resolveNativeBookFilePath: async () => 'C:/books/demo.epub', + } as never, + { format: 'EPUB' } as never, + false, + ), + ).rejects.toThrow('本地 Web 审校入口未启用'); + }); + + test('uploads the current web book to the local sidecar before creating a session', async () => { + const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => { + const url = input.toString(); + if (url === '/api/review-editor/launch') { + return Response.json({ ok: true, url: 'http://127.0.0.1:5177' }); + } + return Response.json({ id: 'session-web' }); + }); + vi.stubGlobal('fetch', fetchMock); + + const launched = await launchInlineReviewEditor( + { + resolveNativeBookFilePath: async () => null, + loadBookContent: async () => ({ + file: new File(['epub-bytes'], 'demo.epub', { type: 'application/epub+zip' }), + }), + } as never, + { format: 'EPUB', title: 'Demo book', hash: 'book-hash' } as never, + false, + ); + + expect(launched.sessionId).toBe('session-web'); + expect(fetchMock).toHaveBeenCalledTimes(2); + const uploadCall = fetchMock.mock.calls[1] as + | [string | URL | Request, RequestInit | undefined] + | undefined; + expect(uploadCall?.[0]).toBe('http://127.0.0.1:5177/api/upload'); + expect(uploadCall?.[1]?.body).toBeInstanceOf(FormData); + expect((uploadCall?.[1]?.body as FormData).get('activate')).toBe('false'); + expect((uploadCall?.[1]?.body as FormData).get('book_key')).toBe('book-hash'); + }); + + test('downloads an exported EPUB in web mode with its server filename', async () => { + const click = vi.fn(); + const remove = vi.fn(); + const anchor = { click, remove, href: '', download: '' }; + vi.spyOn(document, 'createElement').mockReturnValue(anchor as never); + vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node); + + await downloadReviewedEpub( + 'http://127.0.0.1:5177/downloads/export/reviewed.epub?session_id=session-a', + ); + + expect(anchor.href).toContain('/downloads/export/reviewed.epub'); + expect(anchor.download).toBe('reviewed.epub'); + expect(click).toHaveBeenCalledOnce(); + expect(remove).toHaveBeenCalledOnce(); + }); + + test('detects unsaved row fields against the selected row baseline', () => { + const row = { + current_html: '译文', + cn_html: '译文', + marked: false, + issue_type: '', + severity: '', + tags: '', + comment: '', + learn_note: '', + } as ReviewRow; + + expect( + isReviewEditDirty(row, { + current_html: '译文', + marked: false, + issue_type: '', + severity: '', + tags: '', + comment: '', + learn_note: '', + }), + ).toBe(false); + expect( + isReviewEditDirty(row, { + current_html: '修改后译文', + marked: false, + issue_type: '', + severity: '', + tags: '', + comment: '', + learn_note: '', + }), + ).toBe(true); + expect( + isReviewEditDirty( + { ...row, current_html: '' }, + { + current_html: '', + marked: false, + issue_type: '', + severity: '', + tags: '', + comment: '', + learn_note: '', + }, + ), + ).toBe(false); + }); + test('adds session_id to sidecar API calls without mutating endpoint paths', async () => { const fetchMock = stubJsonFetch(); diff --git a/apps/readest-app/src/__tests__/store/review-mode-store.test.ts b/apps/readest-app/src/__tests__/store/review-mode-store.test.ts index 8ade035d..bd6a22ad 100644 --- a/apps/readest-app/src/__tests__/store/review-mode-store.test.ts +++ b/apps/readest-app/src/__tests__/store/review-mode-store.test.ts @@ -48,6 +48,19 @@ describe('reviewModeStore', () => { expect(state.books['book-a']?.selectedRowId).toBe('R00001'); }); + test('reopens the panel for the selected row without clearing its unsaved flag', () => { + useReviewModeStore.getState().setBookEnabled('book-a', true); + useReviewModeStore.getState().selectRow('book-a', 'R00001'); + useReviewModeStore.getState().setBookUnsavedEdit('book-a', true); + useReviewModeStore.getState().setPanelVisible(false); + + useReviewModeStore.getState().selectRow('book-a', 'R00001'); + + const state = useReviewModeStore.getState(); + expect(state.isPanelVisible).toBe(true); + expect(state.books['book-a']?.hasUnsavedEdit).toBe(true); + }); + test('updates one review row after save', () => { useReviewModeStore.getState().setBookData('book-a', { rows: [ 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 816b01af..1fcbb906 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 @@ -128,10 +128,16 @@ const requestJson = (port: number, pathname: string): Promise { const expectedVersion = readExpectedVersion(); + const expectedReviewRoot = path.resolve(reviewRoot()); for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SCAN_LIMIT; port++) { const payload = await requestJson(port, '/api/version'); if (!payload) continue; - if (!expectedVersion || payload['version'] === expectedVersion) { + const runningReviewRoot = + typeof payload['review_root'] === 'string' ? path.resolve(payload['review_root']) : ''; + if ( + (!expectedVersion || payload['version'] === expectedVersion) && + runningReviewRoot === expectedReviewRoot + ) { return `http://127.0.0.1:${port}`; } } diff --git a/apps/readest-app/src/app/reader/components/ReaderContent.tsx b/apps/readest-app/src/app/reader/components/ReaderContent.tsx index 75044b5a..4b5252a7 100644 --- a/apps/readest-app/src/app/reader/components/ReaderContent.tsx +++ b/apps/readest-app/src/app/reader/components/ReaderContent.tsx @@ -212,7 +212,17 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i navigateBackToLibrary(); }; - const handleCloseReaderToLibrary = () => { + const handleCloseReaderToLibrary = async () => { + const dirtyReview = Object.values(useReviewModeStore.getState().books).some( + (book) => book.hasUnsavedEdit, + ); + if ( + dirtyReview && + appService && + !(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?')) + ) { + return; + } return handleCloseBooks(true); }; @@ -230,6 +240,16 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i // SPA navigation in the main window (or on web) keeps the webview alive: // TTS may continue headless. Non-main Tauri windows close their webview // below, but their per-window TTS dies with the window either way. + const dirtyReview = Object.values(useReviewModeStore.getState().books).some( + (book) => book.hasUnsavedEdit, + ); + if ( + dirtyReview && + appService && + !(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?')) + ) { + return; + } handleCloseBooks(true); if (isTauriAppPlatform()) { const currentWindow = getCurrentWindow(); @@ -250,6 +270,14 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i // Header X / pane close: an SPA-side close on web and the main window. // The Tauri reader-window branches below destroy their webview, which // takes the per-window TTS with it either way. + const review = useReviewModeStore.getState().books[bookKey]; + if ( + review?.hasUnsavedEdit && + appService && + !(await appService.ask('当前段落有未保存的修改,确定关闭这本书吗?')) + ) { + return; + } saveConfigAndCloseBook(bookKey, true); if (sideBarBookKey === bookKey) { setSideBarBookKey(getNextBookKey(sideBarBookKey)); diff --git a/apps/readest-app/src/app/reader/components/ReviewModeController.tsx b/apps/readest-app/src/app/reader/components/ReviewModeController.tsx index 9eea06b6..5d13d5a3 100644 --- a/apps/readest-app/src/app/reader/components/ReviewModeController.tsx +++ b/apps/readest-app/src/app/reader/components/ReviewModeController.tsx @@ -1,6 +1,7 @@ import DOMPurify from 'dompurify'; import { useEffect, useRef } from 'react'; +import { useEnv } from '@/context/EnvContext'; import type { BookDoc } from '@/libs/document'; import type { ReviewRow } from '@/services/reviewEditorService'; import { useReaderStore } from '@/store/readerStore'; @@ -147,7 +148,7 @@ const markParagraph = ( htmlElement.setAttribute(ROLE_ATTR, role); htmlElement.classList.add(role === 'source' ? 'readest-review-source' : 'readest-review-target'); htmlElement.classList.toggle('readest-review-active', active); - if (role === 'target' && row.current_html) { + if (role === 'target') { if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) { htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML); } @@ -194,6 +195,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({ bookKey, bookDoc, }) => { + const { appService } = useEnv(); const view = useReaderStore((state) => state.viewStates[bookKey]?.view); const enabled = useReviewModeStore((state) => !!state.books[bookKey]?.enabled); const rows = useReviewModeStore((state) => state.books[bookKey]?.rows ?? emptyReviewRows); @@ -205,6 +207,24 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({ selectedRowIdRef.current = selectedRowId; }, [selectedRowId]); + const selectReviewRow = async (rowId: string) => { + const state = useReviewModeStore.getState(); + const previousBookKey = state.activeBookKey; + const current = state.books[previousBookKey || '']; + if ( + current?.hasUnsavedEdit && + (state.activeBookKey !== bookKey || current.selectedRowId !== rowId) && + appService && + !(await appService.ask('当前段落有未保存的修改,确定切换到其他段落吗?')) + ) { + return; + } + if (previousBookKey && current?.hasUnsavedEdit) { + state.setBookUnsavedEdit(previousBookKey, false); + } + selectRow(bookKey, rowId); + }; + useEffect(() => { if (!view) return; @@ -235,7 +255,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({ if ('stopImmediatePropagation' in event) { event.stopImmediatePropagation(); } - selectRow(bookKey, rowId); + void selectReviewRow(rowId); }; const handleSelectionEnd = (event: Event) => { @@ -244,7 +264,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({ window.setTimeout(() => { const target = selectedReviewTarget(doc); const rowId = target?.getAttribute(ROW_ATTR); - if (rowId) selectRow(bookKey, rowId); + if (rowId) void selectReviewRow(rowId); }, 0); }; @@ -292,7 +312,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({ clearReviewMarks(doc); } }; - }, [bookDoc, bookKey, enabled, rows, selectRow, view]); + }, [appService, bookDoc, bookKey, enabled, rows, selectRow, view]); useEffect(() => { if (!view || !enabled || !rows.length) return; diff --git a/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx b/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx index 5dc594fc..7bea2bdc 100644 --- a/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx +++ b/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx @@ -8,7 +8,11 @@ import { useBookDataStore } from '@/store/bookDataStore'; import { useReaderStore } from '@/store/readerStore'; import { useReviewModeStore } from '@/store/reviewModeStore'; import { eventDispatcher } from '@/utils/event'; -import { launchInlineReviewEditor, loadInlineReviewData } from '@/services/reviewEditorService'; +import { + canLaunchInlineReviewEditor, + launchInlineReviewEditor, + loadInlineReviewData, +} from '@/services/reviewEditorService'; interface ReviewModeTogglerProps { bookKey: string; @@ -32,7 +36,7 @@ const ReviewModeToggler: React.FC = ({ bookKey }) => { const enabled = !!reviewState?.enabled; const loading = !!reviewState?.loading; - if (!appService?.isDesktopApp || bookData?.book?.format !== 'EPUB') return null; + if (!canLaunchInlineReviewEditor(appService) || bookData?.book?.format !== 'EPUB') return null; const handleToggleReviewMode = async () => { if (appService?.isMobile) { @@ -40,6 +44,13 @@ const ReviewModeToggler: React.FC = ({ bookKey }) => { } if (enabled) { + if ( + reviewState?.hasUnsavedEdit && + appService && + !(await appService.ask('当前段落有未保存的修改,确定关闭审校模式吗?')) + ) { + return; + } setBookEnabled(bookKey, false); return; } @@ -49,6 +60,20 @@ const ReviewModeToggler: React.FC = ({ bookKey }) => { return; } + const activeState = useReviewModeStore.getState(); + const activeReview = activeState.books[activeState.activeBookKey || '']; + if ( + activeState.activeBookKey !== bookKey && + activeReview?.hasUnsavedEdit && + appService && + !(await appService.ask('另一册书的当前段落有未保存修改,确定切换审校书籍吗?')) + ) { + return; + } + if (activeState.activeBookKey && activeReview?.hasUnsavedEdit) { + activeState.setBookUnsavedEdit(activeState.activeBookKey, false); + } + setActiveBookKey(bookKey); setPanelVisible(true); setBookLoading(bookKey, true); @@ -91,7 +116,7 @@ const ReviewModeToggler: React.FC = ({ bookKey }) => { onClick={handleToggleReviewMode} disabled={loading} label={enabled ? _('Disable Review Mode') : _('Review Mode')} - className={clsx(enabled && 'bg-base-300/50', loading && 'animate-pulse')} + className={clsx(enabled && 'bg-base-300/50')} /> ); }; diff --git a/apps/readest-app/src/app/reader/components/ReviewPanel.tsx b/apps/readest-app/src/app/reader/components/ReviewPanel.tsx index 107ce81f..d8426f50 100644 --- a/apps/readest-app/src/app/reader/components/ReviewPanel.tsx +++ b/apps/readest-app/src/app/reader/components/ReviewPanel.tsx @@ -29,8 +29,10 @@ import { openExternalUrl } from '@/utils/open'; import { useReviewPanelDrag } from '../hooks/useReviewPanelDrag'; import { useReviewPanelFloatingResize } from '../hooks/useReviewPanelFloatingResize'; import { + downloadReviewedEpub, exportReviewedEpub, generateReviewFeedback, + isReviewEditDirty, loadReviewGlossary, retranslateReviewRow, saveReviewGlossary, @@ -488,6 +490,7 @@ const ReviewPanel: React.FC = () => { const togglePanelPin = useReviewModeStore((state) => state.togglePanelPin); const setPanelWidth = useReviewModeStore((state) => state.setPanelWidth); const setPanelHeight = useReviewModeStore((state) => state.setPanelHeight); + const setBookUnsavedEdit = useReviewModeStore((state) => state.setBookUnsavedEdit); const updateRow = useReviewModeStore((state) => state.updateRow); const setBookData = useReviewModeStore((state) => state.setBookData); const bookState = useReviewModeStore((state) => @@ -508,6 +511,7 @@ const ReviewPanel: React.FC = () => { const [exportInfo, setExportInfo] = useState(''); const [saving, setSaving] = useState(false); const [retranslating, setRetranslating] = useState(false); + const [exporting, setExporting] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); const isMobile = useIsMobileViewport(); const panelTopInset = getPanelTopInset({ @@ -527,6 +531,19 @@ const ReviewPanel: React.FC = () => { [bookState?.rows, bookState?.selectedRowId], ); const canUseRowActions = Boolean(selectedRow && bookState?.baseUrl); + const hasUnsavedEdit = Boolean(selectedRow && isReviewEditDirty(selectedRow, edit)); + + useEffect(() => { + if (!activeBookKey) return; + setBookUnsavedEdit(activeBookKey, hasUnsavedEdit); + }, [activeBookKey, hasUnsavedEdit, setBookUnsavedEdit]); + + useEffect(() => { + if (!hasUnsavedEdit) return; + const warnBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault(); + window.addEventListener('beforeunload', warnBeforeUnload); + return () => window.removeEventListener('beforeunload', warnBeforeUnload); + }, [hasUnsavedEdit]); const bookTitle = activeBookKey ? getBookData(activeBookKey)?.book?.title || selectedRow?.document_title || '审校' @@ -544,7 +561,7 @@ const ReviewPanel: React.FC = () => { return; } setEdit({ - current_html: selectedRow.current_html || selectedRow.cn_html || '', + current_html: selectedRow.current_html ?? selectedRow.cn_html ?? '', marked: Boolean(selectedRow.marked), issue_type: selectedRow.issue_type || '', severity: selectedRow.severity || '', @@ -574,6 +591,17 @@ const ReviewPanel: React.FC = () => { togglePanelPin(); }; + const closePanel = async () => { + if ( + hasUnsavedEdit && + appService && + !(await appService.ask('当前段落有未保存的修改,确定关闭审校面板吗?')) + ) { + return; + } + setPanelVisible(false); + }; + const floatingPanelEnabled = !isPanelPinned && !isMobile; const { panelPosition, @@ -666,7 +694,7 @@ const ReviewPanel: React.FC = () => { setMessage(''); try { const payload = await saveReviewRow(baseUrl, sessionId, selectedRow.id, edit); - const savedHtml = payload.current_html || sanitizeInlineHtml(edit.current_html); + const savedHtml = payload.current_html ?? sanitizeInlineHtml(edit.current_html); const nextRow = { ...selectedRow, ...edit, @@ -725,6 +753,7 @@ const ReviewPanel: React.FC = () => { const exportEpub = async () => { if (!baseUrl) return; + setExporting(true); setExportInfo(''); setMessage(''); try { @@ -733,9 +762,14 @@ const ReviewPanel: React.FC = () => { ? new URL(payload.download_url, baseUrl).toString() : ''; setExportInfo(downloadUrl || payload.output || ''); + if (downloadUrl && !appService?.isDesktopApp) { + await downloadReviewedEpub(downloadUrl); + } setMessage(`已导出:${payload.output || downloadUrl}`); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); + } finally { + setExporting(false); } }; @@ -806,7 +840,7 @@ const ReviewPanel: React.FC = () => { {isMobile && ( setPanelVisible(false)} + onDismiss={() => void closePanel()} /> )}