forked from akai/readest
fix: harden inline review web workflow
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/context/EnvContext', () => ({
|
||||
useEnv: () => ({ appService: null }),
|
||||
}));
|
||||
|
||||
import { __testing } from '@/app/reader/components/ReviewModeController';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
@@ -144,4 +148,29 @@ describe('ReviewModeController marks', () => {
|
||||
expect(target.classList.contains('readest-review-active')).toBe(false);
|
||||
expect(doc.querySelectorAll('p')[3]?.classList.contains('readest-review-active')).toBe(true);
|
||||
});
|
||||
|
||||
test('clears the target paragraph when a saved translation is empty', () => {
|
||||
const doc = document.implementation.createHTMLDocument('chapter');
|
||||
doc.body.innerHTML = '<p>原文</p><p>旧译文</p>';
|
||||
|
||||
__testing.applyReviewMarks(
|
||||
doc,
|
||||
'chapter.xhtml',
|
||||
[
|
||||
{
|
||||
id: 'R00001',
|
||||
file: 'chapter.xhtml',
|
||||
ja_p_index: 0,
|
||||
cn_p_index: 1,
|
||||
jp_html: '原文',
|
||||
jp_text: '原文',
|
||||
cn_html: '旧译文',
|
||||
current_html: '',
|
||||
},
|
||||
],
|
||||
'R00001',
|
||||
);
|
||||
|
||||
expect(doc.querySelectorAll('p')[1]?.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof vi.fn>) => {
|
||||
};
|
||||
|
||||
describe('reviewEditorService', () => {
|
||||
test('allows the inline review entry on desktop and local dev-web, but not hosted web', () => {
|
||||
expect(canLaunchInlineReviewEditor({ isDesktopApp: true }, false)).toBe(true);
|
||||
expect(canLaunchInlineReviewEditor({ isDesktopApp: false }, true)).toBe(true);
|
||||
expect(canLaunchInlineReviewEditor({ isDesktopApp: false }, false)).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a launch response when the Next route returns an HTTP error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
Response.json(
|
||||
{ ok: false, error: '本地 Web 审校入口未启用' },
|
||||
{ status: 404 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
launchInlineReviewEditor(
|
||||
{
|
||||
resolveNativeBookFilePath: async () => 'C:/books/demo.epub',
|
||||
} as never,
|
||||
{ format: 'EPUB' } as never,
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow('本地 Web 审校入口未启用');
|
||||
});
|
||||
|
||||
test('uploads the current web book to the local sidecar before creating a session', async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/review-editor/launch') {
|
||||
return Response.json({ ok: true, url: 'http://127.0.0.1:5177' });
|
||||
}
|
||||
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();
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -128,10 +128,16 @@ const requestJson = (port: number, pathname: string): Promise<Record<string, unk
|
||||
|
||||
const findRunningEditor = async () => {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +212,17 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
navigateBackToLibrary();
|
||||
};
|
||||
|
||||
const handleCloseReaderToLibrary = () => {
|
||||
const handleCloseReaderToLibrary = async () => {
|
||||
const dirtyReview = Object.values(useReviewModeStore.getState().books).some(
|
||||
(book) => book.hasUnsavedEdit,
|
||||
);
|
||||
if (
|
||||
dirtyReview &&
|
||||
appService &&
|
||||
!(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return handleCloseBooks(true);
|
||||
};
|
||||
|
||||
@@ -230,6 +240,16 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
// SPA navigation in the main window (or on web) keeps the webview alive:
|
||||
// TTS may continue headless. Non-main Tauri windows close their webview
|
||||
// below, but their per-window TTS dies with the window either way.
|
||||
const dirtyReview = Object.values(useReviewModeStore.getState().books).some(
|
||||
(book) => book.hasUnsavedEdit,
|
||||
);
|
||||
if (
|
||||
dirtyReview &&
|
||||
appService &&
|
||||
!(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleCloseBooks(true);
|
||||
if (isTauriAppPlatform()) {
|
||||
const currentWindow = getCurrentWindow();
|
||||
@@ -250,6 +270,14 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
// Header X / pane close: an SPA-side close on web and the main window.
|
||||
// The Tauri reader-window branches below destroy their webview, which
|
||||
// takes the per-window TTS with it either way.
|
||||
const review = useReviewModeStore.getState().books[bookKey];
|
||||
if (
|
||||
review?.hasUnsavedEdit &&
|
||||
appService &&
|
||||
!(await appService.ask('当前段落有未保存的修改,确定关闭这本书吗?'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
saveConfigAndCloseBook(bookKey, true);
|
||||
if (sideBarBookKey === bookKey) {
|
||||
setSideBarBookKey(getNextBookKey(sideBarBookKey));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
import type { ReviewRow } from '@/services/reviewEditorService';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
@@ -147,7 +148,7 @@ const markParagraph = (
|
||||
htmlElement.setAttribute(ROLE_ATTR, role);
|
||||
htmlElement.classList.add(role === 'source' ? 'readest-review-source' : 'readest-review-target');
|
||||
htmlElement.classList.toggle('readest-review-active', active);
|
||||
if (role === 'target' && row.current_html) {
|
||||
if (role === 'target') {
|
||||
if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) {
|
||||
htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML);
|
||||
}
|
||||
@@ -194,6 +195,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
|
||||
bookKey,
|
||||
bookDoc,
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const view = useReaderStore((state) => state.viewStates[bookKey]?.view);
|
||||
const enabled = useReviewModeStore((state) => !!state.books[bookKey]?.enabled);
|
||||
const rows = useReviewModeStore((state) => state.books[bookKey]?.rows ?? emptyReviewRows);
|
||||
@@ -205,6 +207,24 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
|
||||
selectedRowIdRef.current = selectedRowId;
|
||||
}, [selectedRowId]);
|
||||
|
||||
const selectReviewRow = async (rowId: string) => {
|
||||
const state = useReviewModeStore.getState();
|
||||
const previousBookKey = state.activeBookKey;
|
||||
const current = state.books[previousBookKey || ''];
|
||||
if (
|
||||
current?.hasUnsavedEdit &&
|
||||
(state.activeBookKey !== bookKey || current.selectedRowId !== rowId) &&
|
||||
appService &&
|
||||
!(await appService.ask('当前段落有未保存的修改,确定切换到其他段落吗?'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (previousBookKey && current?.hasUnsavedEdit) {
|
||||
state.setBookUnsavedEdit(previousBookKey, false);
|
||||
}
|
||||
selectRow(bookKey, rowId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!view) return;
|
||||
|
||||
@@ -235,7 +255,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
|
||||
if ('stopImmediatePropagation' in event) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
selectRow(bookKey, rowId);
|
||||
void selectReviewRow(rowId);
|
||||
};
|
||||
|
||||
const handleSelectionEnd = (event: Event) => {
|
||||
@@ -244,7 +264,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
|
||||
window.setTimeout(() => {
|
||||
const target = selectedReviewTarget(doc);
|
||||
const rowId = target?.getAttribute(ROW_ATTR);
|
||||
if (rowId) selectRow(bookKey, rowId);
|
||||
if (rowId) void selectReviewRow(rowId);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
@@ -292,7 +312,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
|
||||
clearReviewMarks(doc);
|
||||
}
|
||||
};
|
||||
}, [bookDoc, bookKey, enabled, rows, selectRow, view]);
|
||||
}, [appService, bookDoc, bookKey, enabled, rows, selectRow, view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!view || !enabled || !rows.length) return;
|
||||
|
||||
@@ -8,7 +8,11 @@ import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useReviewModeStore } from '@/store/reviewModeStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { launchInlineReviewEditor, loadInlineReviewData } from '@/services/reviewEditorService';
|
||||
import {
|
||||
canLaunchInlineReviewEditor,
|
||||
launchInlineReviewEditor,
|
||||
loadInlineReviewData,
|
||||
} from '@/services/reviewEditorService';
|
||||
|
||||
interface ReviewModeTogglerProps {
|
||||
bookKey: string;
|
||||
@@ -32,7 +36,7 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
|
||||
const enabled = !!reviewState?.enabled;
|
||||
const loading = !!reviewState?.loading;
|
||||
|
||||
if (!appService?.isDesktopApp || bookData?.book?.format !== 'EPUB') return null;
|
||||
if (!canLaunchInlineReviewEditor(appService) || bookData?.book?.format !== 'EPUB') return null;
|
||||
|
||||
const handleToggleReviewMode = async () => {
|
||||
if (appService?.isMobile) {
|
||||
@@ -40,6 +44,13 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
if (
|
||||
reviewState?.hasUnsavedEdit &&
|
||||
appService &&
|
||||
!(await appService.ask('当前段落有未保存的修改,确定关闭审校模式吗?'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setBookEnabled(bookKey, false);
|
||||
return;
|
||||
}
|
||||
@@ -49,6 +60,20 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeState = useReviewModeStore.getState();
|
||||
const activeReview = activeState.books[activeState.activeBookKey || ''];
|
||||
if (
|
||||
activeState.activeBookKey !== bookKey &&
|
||||
activeReview?.hasUnsavedEdit &&
|
||||
appService &&
|
||||
!(await appService.ask('另一册书的当前段落有未保存修改,确定切换审校书籍吗?'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (activeState.activeBookKey && activeReview?.hasUnsavedEdit) {
|
||||
activeState.setBookUnsavedEdit(activeState.activeBookKey, false);
|
||||
}
|
||||
|
||||
setActiveBookKey(bookKey);
|
||||
setPanelVisible(true);
|
||||
setBookLoading(bookKey, true);
|
||||
@@ -91,7 +116,7 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
|
||||
onClick={handleToggleReviewMode}
|
||||
disabled={loading}
|
||||
label={enabled ? _('Disable Review Mode') : _('Review Mode')}
|
||||
className={clsx(enabled && 'bg-base-300/50', loading && 'animate-pulse')}
|
||||
className={clsx(enabled && 'bg-base-300/50')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
<Overlay
|
||||
className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/50 sm:bg-black/20')}
|
||||
onDismiss={() => setPanelVisible(false)}
|
||||
onDismiss={() => void closePanel()}
|
||||
/>
|
||||
)}
|
||||
<aside
|
||||
@@ -893,7 +927,7 @@ const ReviewPanel: React.FC = () => {
|
||||
type='button'
|
||||
className='btn btn-ghost h-8 min-h-8 w-8 shrink-0 rounded-full p-0'
|
||||
title={_('Close')}
|
||||
onClick={() => setPanelVisible(false)}
|
||||
onClick={() => void closePanel()}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</button>
|
||||
@@ -907,7 +941,10 @@ const ReviewPanel: React.FC = () => {
|
||||
</div>
|
||||
) : null}
|
||||
{bookState.error ? (
|
||||
<div className='eink-bordered border-error bg-base-100 text-error rounded-md border p-3 text-sm'>
|
||||
<div
|
||||
className='eink-bordered border-error bg-base-100 text-error rounded-md border p-3 text-sm'
|
||||
role='alert'
|
||||
>
|
||||
{bookState.error}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -916,22 +953,29 @@ const ReviewPanel: React.FC = () => {
|
||||
<PanelButton
|
||||
onClick={generateFeedback}
|
||||
icon={<FileText className='h-4 w-4' />}
|
||||
disabled={!baseUrl}
|
||||
disabled={!baseUrl || hasUnsavedEdit}
|
||||
>
|
||||
生成反馈
|
||||
</PanelButton>
|
||||
<PanelButton
|
||||
onClick={exportEpub}
|
||||
icon={<Download className='h-4 w-4' />}
|
||||
disabled={!baseUrl}
|
||||
disabled={!baseUrl || exporting || hasUnsavedEdit}
|
||||
variant='primary'
|
||||
>
|
||||
导出 EPUB
|
||||
{exporting ? '导出中' : '导出 EPUB'}
|
||||
</PanelButton>
|
||||
</div>
|
||||
{hasUnsavedEdit ? (
|
||||
<p className='text-base-content/70 text-xs'>请先保存当前段落,再生成反馈或导出。</p>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
|
||||
<div
|
||||
className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'
|
||||
role='status'
|
||||
aria-live='polite'
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -939,9 +983,17 @@ const ReviewPanel: React.FC = () => {
|
||||
<button
|
||||
type='button'
|
||||
className='eink-bordered border-base-300 bg-base-100 hover:bg-base-200 rounded-md border p-3 text-start text-sm'
|
||||
onClick={() => openExternalUrl(exportInfo)}
|
||||
onClick={() => {
|
||||
if (appService?.isDesktopApp) {
|
||||
openExternalUrl(exportInfo);
|
||||
} else {
|
||||
void downloadReviewedEpub(exportInfo);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='font-semibold'>打开导出文件</span>
|
||||
<span className='font-semibold'>
|
||||
{appService?.isDesktopApp ? '在浏览器下载导出文件' : '重新下载导出文件'}
|
||||
</span>
|
||||
<span className='text-base-content/60 mt-1 block break-all text-xs'>
|
||||
{exportInfo}
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import type { Book } from '@/types/book';
|
||||
import type { AppService } from '@/types/system';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
|
||||
export type ReviewEditorLaunchResponse = {
|
||||
ok: boolean;
|
||||
@@ -97,6 +97,15 @@ export type ReviewEditPayload = {
|
||||
learn_note: string;
|
||||
};
|
||||
|
||||
export const isReviewEditDirty = (row: ReviewRow, edit: ReviewEditPayload) =>
|
||||
edit.current_html !== (row.current_html ?? row.cn_html ?? '') ||
|
||||
edit.marked !== Boolean(row.marked) ||
|
||||
edit.issue_type !== (row.issue_type || '') ||
|
||||
edit.severity !== (row.severity || '') ||
|
||||
edit.tags !== (row.tags || '') ||
|
||||
edit.comment !== (row.comment || '') ||
|
||||
edit.learn_note !== (row.learn_note || '');
|
||||
|
||||
export type ReviewSaveRowPayload = {
|
||||
status?: string;
|
||||
row_id?: string;
|
||||
@@ -134,6 +143,12 @@ const ensureBaseUrl = (baseUrl: string) => {
|
||||
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
};
|
||||
|
||||
export const canLaunchInlineReviewEditor = (
|
||||
appService: Pick<AppService, 'isDesktopApp'> | null | undefined,
|
||||
localWebDevelopment =
|
||||
process.env['NODE_ENV'] === 'development' && isWebAppPlatform(),
|
||||
) => Boolean(appService?.isDesktopApp || localWebDevelopment);
|
||||
|
||||
export async function sidecarApi<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
@@ -177,20 +192,40 @@ export async function createReviewSessionFromPath(
|
||||
});
|
||||
}
|
||||
|
||||
async function createReviewSessionFromBook(
|
||||
baseUrl: string,
|
||||
appService: AppService,
|
||||
book: Book,
|
||||
): Promise<ReviewSessionSummary> {
|
||||
const { file } = await appService.loadBookContent(book);
|
||||
const formData = new FormData();
|
||||
formData.append('epub', file, file.name || `${book.title || 'book'}.epub`);
|
||||
formData.append('activate', 'false');
|
||||
formData.append('book_key', book.hash);
|
||||
const response = await fetch(new URL('/api/upload', ensureBaseUrl(baseUrl)).toString(), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(String(data.error || `HTTP ${response.status}`));
|
||||
}
|
||||
return data as ReviewSessionSummary;
|
||||
}
|
||||
|
||||
export async function launchInlineReviewEditor(
|
||||
appService: AppService | null | undefined,
|
||||
book: Book,
|
||||
tauriPlatform = isTauriAppPlatform(),
|
||||
): Promise<ReviewEditorLaunchResponse & { url: string; sessionId: string | null }> {
|
||||
if (!appService) throw new Error('Readest 服务尚未初始化');
|
||||
if (book.format !== 'EPUB') throw new Error('审校模式目前只支持 EPUB 书籍');
|
||||
|
||||
const epubPath = await appService.resolveNativeBookFilePath(book);
|
||||
if (!epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径');
|
||||
const epubPath = tauriPlatform ? await appService.resolveNativeBookFilePath(book) : null;
|
||||
if (tauriPlatform && !epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径');
|
||||
|
||||
const launch = isTauriAppPlatform()
|
||||
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', {
|
||||
epubPath,
|
||||
})
|
||||
const launch = tauriPlatform
|
||||
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', { epubPath })
|
||||
: await fetch('/api/review-editor/launch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -204,7 +239,9 @@ export async function launchInlineReviewEditor(
|
||||
|
||||
let sessionId = launch.sessionId || null;
|
||||
if (!sessionId) {
|
||||
const createdSession = await createReviewSessionFromPath(launch.url, epubPath);
|
||||
const createdSession = epubPath
|
||||
? await createReviewSessionFromPath(launch.url, epubPath)
|
||||
: await createReviewSessionFromBook(launch.url, appService, book);
|
||||
sessionId = createdSession.id || null;
|
||||
}
|
||||
|
||||
@@ -354,3 +391,14 @@ export async function exportReviewedEpub(
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadReviewedEpub(downloadUrl: string): Promise<void> {
|
||||
const url = new URL(downloadUrl);
|
||||
const filename = decodeURIComponent(url.pathname.split('/').pop() || 'reviewed.epub');
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url.toString();
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export type ReviewBookState = {
|
||||
session: ReviewSessionPayload | null;
|
||||
gptConfig: ReviewGptConfig | null;
|
||||
selectedRowId: string;
|
||||
hasUnsavedEdit: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_PANEL_WIDTH = '32%';
|
||||
@@ -49,6 +50,7 @@ type ReviewModeStore = {
|
||||
data: Partial<Omit<ReviewBookState, 'enabled' | 'loading' | 'error'>>,
|
||||
) => void;
|
||||
selectRow: (bookKey: string, rowId: string) => void;
|
||||
setBookUnsavedEdit: (bookKey: string, hasUnsavedEdit: boolean) => void;
|
||||
updateRow: (bookKey: string, row: ReviewRow) => void;
|
||||
clearBook: (bookKey: string) => void;
|
||||
};
|
||||
@@ -63,6 +65,7 @@ const emptyBookState = (): ReviewBookState => ({
|
||||
session: null,
|
||||
gptConfig: null,
|
||||
selectedRowId: '',
|
||||
hasUnsavedEdit: false,
|
||||
});
|
||||
|
||||
const formatSizedValue = (value: number, unit: '%' | 'vh') =>
|
||||
@@ -153,7 +156,10 @@ export const useReviewModeStore = create<ReviewModeStore>()(
|
||||
: state.activeBookKey === bookKey
|
||||
? false
|
||||
: state.isPanelVisible,
|
||||
books: withBookState(state.books, bookKey, { enabled }),
|
||||
books: withBookState(state.books, bookKey, {
|
||||
enabled,
|
||||
...(!enabled ? { hasUnsavedEdit: false } : {}),
|
||||
}),
|
||||
})),
|
||||
setBookData: (bookKey, data) =>
|
||||
set((state) => ({
|
||||
@@ -164,12 +170,21 @@ export const useReviewModeStore = create<ReviewModeStore>()(
|
||||
}),
|
||||
})),
|
||||
selectRow: (bookKey, rowId) =>
|
||||
set((state) => {
|
||||
if (state.activeBookKey === bookKey && state.books[bookKey]?.selectedRowId === rowId) {
|
||||
return state.isPanelVisible ? state : { isPanelVisible: true };
|
||||
}
|
||||
return {
|
||||
activeBookKey: bookKey,
|
||||
isPanelVisible: true,
|
||||
books: withBookState(state.books, bookKey, {
|
||||
selectedRowId: rowId,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
setBookUnsavedEdit: (bookKey, hasUnsavedEdit) =>
|
||||
set((state) => ({
|
||||
activeBookKey: bookKey,
|
||||
isPanelVisible: true,
|
||||
books: withBookState(state.books, bookKey, {
|
||||
selectedRowId: rowId,
|
||||
}),
|
||||
books: withBookState(state.books, bookKey, { hasUnsavedEdit }),
|
||||
})),
|
||||
updateRow: (bookKey, row) =>
|
||||
set((state) => {
|
||||
|
||||
@@ -68,6 +68,7 @@ Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧
|
||||
|
||||
- `GET /api/version`
|
||||
- `POST /api/session/from-path`
|
||||
- `POST /api/upload`(本地 `dev-web` 使用 multipart 上传,内嵌调用必须传 `activate=false` 与稳定 `book_key`)
|
||||
- `GET /api/session?session_id=...`
|
||||
- `GET /api/rows?session_id=...`
|
||||
- `POST /api/row/<row_id>?session_id=...`
|
||||
@@ -80,7 +81,9 @@ Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧
|
||||
|
||||
中文 HTML 清洗必须允许必要的 `ruby/rb/rt/rp/mark/span` 内联标签并移除脚本。保存响应应返回清洗后的 `current_html`,前端只能用清洗结果更新 store 和正文预览。
|
||||
|
||||
sidecar 只能监听本机 loopback。Readest/Tauri 本机来源可获得受限 CORS;不得使用 `Access-Control-Allow-Origin: *`。Cross-Origin Resource Policy 必须允许 Readest 页面直接读取 API 与下载资源。
|
||||
sidecar 只能监听本机 loopback,CLI 必须拒绝 `0.0.0.0`、`::` 等非 loopback 地址。Readest/Tauri 本机来源可获得受限 CORS;本地 `dev-web` 端口可能动态变化,应按 loopback origin 校验而非固定端口。不得使用 `Access-Control-Allow-Origin: *`。Cross-Origin Resource Policy 必须允许 Readest 页面直接读取 API 与下载资源。
|
||||
|
||||
桌面内嵌模式通过本机 EPUB 路径创建 session;本地 `dev-web` 中书籍位于 IndexedDB,不能把逻辑路径交给 Python,必须读取当前 `File` 后上传到 `/api/upload`。上传必须携带 Readest 书籍 hash 作为稳定 `book_key`,让同一本书再次开启审校时复用原 session。部署版 Web 没有本地 Node/Python/文件系统链路,当前不在支持范围内。
|
||||
|
||||
## 前端验收标准
|
||||
|
||||
@@ -105,7 +108,7 @@ sidecar 只能监听本机 loopback。Readest/Tauri 本机来源可获得受限
|
||||
- MINOR:向后兼容的能力或 API 扩展
|
||||
- MAJOR:删除入口、破坏 API 或改变持久化行为
|
||||
|
||||
发版时同步 `version.py`、`README.md` 和本文件。`1.0.0` 删除了旧独立 UI 和启动方式,是破坏兼容的边界收敛。
|
||||
发版时同步 `version.py`、`README.md` 和本文件。`1.0.0` 删除了旧独立 UI 和启动方式,是破坏兼容的边界收敛。`1.0.1` 修复本地 `dev-web` 上传/下载、会话隔离、译文恢复与空译文预览,并增加未保存修改提醒、动态端口 CORS 和 loopback 监听约束。
|
||||
|
||||
## 回归清单
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Readest 内嵌 EPUB 审校后端
|
||||
|
||||
当前版本:`1.0.0`
|
||||
当前版本:`1.0.1`
|
||||
|
||||
本目录只为 Readest 阅读页内的审校模式提供本地 sidecar API。打开 EPUB 后,点击阅读页顶栏的“校”按钮即可启动或复用服务,并在原阅读界面中选择中日文段落进行审校。
|
||||
|
||||
@@ -29,7 +29,9 @@ Web 开发端:
|
||||
pnpm --filter @readest/readest-app dev-web
|
||||
```
|
||||
|
||||
在 Readest 中打开 EPUB,再点击阅读页顶栏“校”。桌面端通过 Tauri command `launch_epub_review_editor` 启动 sidecar;本地 Web 开发端通过 `POST /api/review-editor/launch` 启动。两者都会注册当前 EPUB 的 session,前端后续请求必须携带该 `session_id`。
|
||||
在 Readest 中打开 EPUB,再点击阅读页顶栏“校”。桌面端通过 Tauri command `launch_epub_review_editor` 启动 sidecar,并用本机路径注册 session;本地 Web 开发端通过 `POST /api/review-editor/launch` 启动,再以 `POST /api/upload` 上传 IndexedDB 中的当前 EPUB,且使用 `activate=false` 保持多书会话隔离。前端后续请求必须携带返回的 `session_id`。
|
||||
|
||||
该 Web 链路仅支持本机 `dev-web`,依赖本机 Next.js Node 进程和 Python sidecar。部署到 Cloudflare 等托管环境的 Readest Web 不支持当前内嵌审校器。
|
||||
|
||||
## 审校能力
|
||||
|
||||
@@ -68,6 +70,7 @@ API Key 只写入本地运行配置,接口不得回传密钥,也不得写入
|
||||
|
||||
- `GET /api/version`
|
||||
- `POST /api/session/from-path`
|
||||
- `POST /api/upload`(本地 `dev-web`,multipart,`activate=false`,携带稳定 `book_key` 复用会话)
|
||||
- `GET /api/session?session_id=...`
|
||||
- `GET /api/rows?session_id=...`
|
||||
- `POST /api/row/<row_id>?session_id=...`
|
||||
@@ -84,3 +87,4 @@ API Key 只写入本地运行配置,接口不得回传密钥,也不得写入
|
||||
- `0.16.0`:新增 Readest 阅读页内嵌审校模式。
|
||||
- `0.16.1` 至 `0.16.5`:完善停靠/浮动布局、宽高调整、注音注释快捷操作和响应式工程结构。
|
||||
- `1.0.0`:删除旧独立审校页面、书库入口、静态浏览器前端及独立启动链路,只保留内嵌审校所需的共享 sidecar API。
|
||||
- `1.0.1`:修复本地 `dev-web` EPUB 上传与下载、会话隔离、恢复初始译文写回、空译文预览、未保存修改提醒及动态开发端口 CORS;限制 sidecar 仅监听 loopback。
|
||||
|
||||
@@ -35,7 +35,7 @@ except ImportError:
|
||||
"MINOR": "新增向后兼容能力、API 字段或可选配置",
|
||||
"MAJOR": "破坏兼容的 API、配置或行为变更",
|
||||
}
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
|
||||
|
||||
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
|
||||
@@ -139,7 +139,21 @@ def find_free_port(start: int, host: str = "127.0.0.1") -> int:
|
||||
|
||||
|
||||
def display_host(host: str) -> str:
|
||||
return "localhost" if host in {"127.0.0.1", "0.0.0.0", "::"} else host
|
||||
return "localhost" if host in {"127.0.0.1", "::1"} else host
|
||||
|
||||
|
||||
def is_loopback_host(host: str) -> bool:
|
||||
return host.strip().lower() in {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
|
||||
def is_allowed_local_origin(origin: str) -> bool:
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(origin)
|
||||
return parsed.scheme in {"http", "https", "tauri"} and is_loopback_host(
|
||||
parsed.hostname or ""
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def read_json(path: Path, default: Any) -> Any:
|
||||
@@ -1444,14 +1458,20 @@ def validate_epub_file(path: Path) -> None:
|
||||
raise ValueError("文件不是有效的 EPUB/ZIP 包")
|
||||
|
||||
|
||||
def copy_upload_to_review_root(file_storage: Any, review_root: Path) -> Path:
|
||||
def copy_upload_to_review_root(
|
||||
file_storage: Any, review_root: Path, book_key: str = ""
|
||||
) -> Path:
|
||||
filename = safe_slug(Path(file_storage.filename or "uploaded.epub").name, max_len=140)
|
||||
if not filename.lower().endswith(".epub"):
|
||||
filename = f"{filename}.epub"
|
||||
upload_dir = review_root / UPLOAD_DIR_NAME
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = safe_slug(Path(filename).stem, max_len=100)
|
||||
target = upload_dir / f"{dt.datetime.now().strftime('%Y%m%d_%H%M%S')}_{stem}.epub"
|
||||
if book_key:
|
||||
safe_book_key = safe_slug(book_key, max_len=120)
|
||||
target = upload_dir / f"inline_{safe_book_key}.epub"
|
||||
else:
|
||||
target = upload_dir / f"{dt.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}_{stem}.epub"
|
||||
try:
|
||||
file_storage.save(target)
|
||||
validate_epub_file(target)
|
||||
@@ -2636,9 +2656,11 @@ def write_feedback_summary(session_root: Path) -> Path:
|
||||
|
||||
def apply_edits_to_xhtml(session_root: Path) -> list[str]:
|
||||
rows = merge_rows(session_root)
|
||||
edited_rows = [row for row in rows if row["edited"]]
|
||||
state = read_json(session_root / "review_state" / "state.json", {"edits": {}})
|
||||
saved_row_ids = set(state.get("edits", {})) if isinstance(state.get("edits"), dict) else set()
|
||||
saved_rows = [row for row in rows if row["id"] in saved_row_ids]
|
||||
by_file: dict[str, list[dict[str, Any]]] = {}
|
||||
for row in edited_rows:
|
||||
for row in saved_rows:
|
||||
by_file.setdefault(row["file"], []).append(row)
|
||||
modified = []
|
||||
for entry_name, items in by_file.items():
|
||||
@@ -3296,17 +3318,8 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
def add_local_embedding_headers(response):
|
||||
# The Readest clients call the loopback API directly. Keep CORS limited
|
||||
# to known local app origins instead of opening the sidecar to the web.
|
||||
allowed_origins = {
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:3001",
|
||||
"http://127.0.0.1:3001",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost",
|
||||
"tauri://localhost",
|
||||
}
|
||||
origin = request.headers.get("Origin", "")
|
||||
if origin in allowed_origins:
|
||||
if is_allowed_local_origin(origin):
|
||||
response.headers["Access-Control-Allow-Origin"] = origin
|
||||
response.headers["Access-Control-Allow-Credentials"] = "false"
|
||||
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
||||
@@ -3397,15 +3410,23 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
if file_storage is None or not file_storage.filename:
|
||||
return jsonify({"error": "请选择一个 .epub 文件"}), 400
|
||||
reset_upload = str(request.form.get("reset", "")).lower() in {"1", "true", "yes", "on"}
|
||||
book_key = str(request.form.get("book_key", "")).strip()
|
||||
activate_upload = str(request.form.get("activate", "true")).lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
try:
|
||||
epub_path = copy_upload_to_review_root(file_storage, review_root)
|
||||
epub_path = copy_upload_to_review_root(file_storage, review_root, book_key)
|
||||
session_root = session_root_for(review_root, epub_path)
|
||||
with app.config["LOCK"]:
|
||||
if reset_upload and session_root.exists():
|
||||
shutil.rmtree(session_root)
|
||||
ensure_state(session_root, epub_path, reset=reset_upload)
|
||||
write_session_manifest(session_root, epub_path, source_name=file_storage.filename)
|
||||
set_active_session(session_root, epub_path)
|
||||
if activate_upload:
|
||||
set_active_session(session_root, epub_path)
|
||||
summary = summarize_session(session_root)
|
||||
summary["has_session"] = True
|
||||
return jsonify(summary)
|
||||
@@ -4080,7 +4101,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Run the local Readest inline review API.")
|
||||
parser.add_argument("epub", nargs="?", help="Optional EPUB path used to initialize a review session.")
|
||||
parser.add_argument("--review-root", default=DEFAULT_REVIEW_ROOT, help="Directory for review sessions.")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Use 0.0.0.0 for trusted LAN/server access.")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="Loopback host to bind.")
|
||||
parser.add_argument("--port", type=int, default=5177, help="Port, auto-advances if busy.")
|
||||
parser.add_argument("--daemon", action="store_true", help="Start in background and print URL.")
|
||||
parser.add_argument("--reset", action="store_true", help="Re-extract the EPUB and discard prior review state.")
|
||||
@@ -4089,6 +4110,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if not is_loopback_host(args.host):
|
||||
print("Readest inline review API only supports loopback hosts.", file=sys.stderr)
|
||||
return 1
|
||||
epub_path = Path(args.epub).resolve() if args.epub else None
|
||||
if epub_path is not None:
|
||||
if not epub_path.exists():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -18,6 +19,22 @@ def write_json(path: Path, data):
|
||||
|
||||
|
||||
class InlineReviewSessionScopeTest(unittest.TestCase):
|
||||
def test_cli_rejects_non_loopback_host(self):
|
||||
self.assertNotEqual(server.main(["--host", "0.0.0.0"]), 0)
|
||||
|
||||
def test_local_dev_web_origin_on_dynamic_port_receives_cors_headers(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
app = server.create_app(Path(temp_dir))
|
||||
|
||||
response = app.test_client().get(
|
||||
"/api/version", headers={"Origin": "http://127.0.0.1:3017"}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
response.headers.get("Access-Control-Allow-Origin"),
|
||||
"http://127.0.0.1:3017",
|
||||
)
|
||||
|
||||
def test_sidecar_has_no_legacy_standalone_page(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
app = server.create_app(Path(temp_dir))
|
||||
@@ -39,6 +56,110 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
|
||||
'<span class="review-annotation">(注:注释)</span>',
|
||||
)
|
||||
|
||||
def test_saved_row_restores_initial_html_after_previous_apply(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
session_root = Path(temp_dir)
|
||||
review_state = session_root / "review_state"
|
||||
extracted = session_root / "extracted" / "Text"
|
||||
review_state.mkdir(parents=True)
|
||||
extracted.mkdir(parents=True)
|
||||
row = {
|
||||
"id": "R00001",
|
||||
"file": "Text/chapter.xhtml",
|
||||
"file_label": "chapter.xhtml",
|
||||
"ja_p_index": 0,
|
||||
"cn_p_index": 1,
|
||||
"jp_html": "原文",
|
||||
"jp_text": "原文",
|
||||
"cn_html": "初始译文",
|
||||
"cn_text": "初始译文",
|
||||
}
|
||||
write_json(review_state / "rows.json", [row])
|
||||
write_json(
|
||||
server.rows_meta_path(session_root),
|
||||
{"parser_version": server.ROWS_PARSER_VERSION, "row_count": 1},
|
||||
)
|
||||
write_json(
|
||||
review_state / "state.json",
|
||||
{"edits": {"R00001": {"current_html": "初始译文"}}},
|
||||
)
|
||||
xhtml_path = extracted / "chapter.xhtml"
|
||||
xhtml_path.write_text(
|
||||
'<html><body><p class="sourceText">原文</p><p>先前修改</p></body></html>',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
modified = server.apply_edits_to_xhtml(session_root)
|
||||
|
||||
self.assertEqual(modified, ["Text/chapter.xhtml"])
|
||||
self.assertIn("<p>初始译文</p>", xhtml_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(
|
||||
json.loads((review_state / "rows.json").read_text(encoding="utf-8")),
|
||||
[row],
|
||||
)
|
||||
|
||||
def test_upload_can_create_a_session_without_changing_the_active_session(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
review_root = Path(temp_dir)
|
||||
first_epub = review_root / "first.epub"
|
||||
self.write_minimal_epub(first_epub)
|
||||
first_session = self.make_session(
|
||||
review_root, "first", first_epub, review_root / "glossary.json"
|
||||
)
|
||||
app = server.create_app(review_root=review_root, initial_epub_path=first_epub)
|
||||
client = app.test_client()
|
||||
|
||||
response = client.post(
|
||||
"/api/upload",
|
||||
data={
|
||||
"epub": (io.BytesIO(first_epub.read_bytes()), "uploaded.epub"),
|
||||
"activate": "false",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
uploaded_id = response.get_json()["id"]
|
||||
active_session_id = client.get("/api/sessions").get_json()["active_session_id"]
|
||||
self.assertNotEqual(active_session_id, uploaded_id)
|
||||
self.assertIn("first", active_session_id)
|
||||
scoped_session = client.get(f"/api/session?session_id={uploaded_id}")
|
||||
self.assertEqual(scoped_session.status_code, 200)
|
||||
self.assertEqual(scoped_session.get_json()["id"], uploaded_id)
|
||||
|
||||
def test_inline_upload_reuses_the_book_session(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
review_root = Path(temp_dir)
|
||||
epub = review_root / "book.epub"
|
||||
self.write_minimal_epub(epub)
|
||||
app = server.create_app(review_root=review_root)
|
||||
client = app.test_client()
|
||||
|
||||
def upload(filename: str):
|
||||
return client.post(
|
||||
"/api/upload",
|
||||
data={
|
||||
"epub": (io.BytesIO(epub.read_bytes()), filename),
|
||||
"activate": "false",
|
||||
"book_key": "stable-book-hash",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
|
||||
first = upload("book.epub")
|
||||
second = upload("renamed-book.epub")
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(second.status_code, 200)
|
||||
self.assertEqual(first.get_json()["id"], second.get_json()["id"])
|
||||
|
||||
@staticmethod
|
||||
def write_minimal_epub(path: Path) -> None:
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr("mimetype", "application/epub+zip")
|
||||
|
||||
def make_session(
|
||||
self,
|
||||
review_root: Path,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
|
||||
VERSION_RULES = {
|
||||
"PATCH": "修复 bug 或兼容性小修",
|
||||
|
||||
Reference in New Issue
Block a user