diff --git a/frontend/cypress/e2e/article-operations.cy.ts b/frontend/cypress/e2e/article-operations.cy.ts index 29ae89cc..d5f5efc7 100644 --- a/frontend/cypress/e2e/article-operations.cy.ts +++ b/frontend/cypress/e2e/article-operations.cy.ts @@ -89,7 +89,10 @@ describe('Article Operations', () => { // Try to find mark all as read button (it might be in a context menu or toolbar) cy.get('body').then(($body) => { - if ($body.find('button').filter((i, el) => /mark.*all|全部标记/i.test(el.textContent || '')).length > 0) { + if ( + $body.find('button').filter((i, el) => /mark.*all|全部标记/i.test(el.textContent || '')) + .length > 0 + ) { cy.get('button') .contains(/mark.*all|全部标记/i) .click({ force: true }); @@ -127,7 +130,10 @@ describe('Article Operations', () => { // Find search input cy.get('body').then(($body) => { - if ($body.find('input[type="search"], input[placeholder*="search"], input[placeholder*="搜索"]').length > 0) { + if ( + $body.find('input[type="search"], input[placeholder*="search"], input[placeholder*="搜索"]') + .length > 0 + ) { cy.get('input[type="search"], input[placeholder*="search"], input[placeholder*="搜索"]') .last() .type('test{enter}'); @@ -164,6 +170,85 @@ describe('Article Operations', () => { }); }); + it('should translate orphaned article text next to media', () => { + const settingsState: Record = { + language: 'en-US', + theme: 'light', + layout_mode: 'normal', + default_view_mode: 'rendered', + translation_enabled: 'true', + translation_provider: 'ai', + translation_only_mode: 'false', + target_language: 'zh-CN', + summary_enabled: 'false', + full_text_fetch_enabled: 'false', + update_check_enabled: 'false', + }; + const feed = { + id: 1, + title: 'Translation Feed', + url: 'https://example.com/feed.xml', + category: '', + }; + const article = { + id: 1, + feed_id: 1, + feed_title: feed.title, + title: 'English title', + url: 'https://example.com/article', + published_at: '2026-04-22T00:00:00Z', + translated_title: '', + is_read: false, + is_favorite: false, + is_hidden: false, + is_read_later: false, + }; + + cy.intercept('/api/**', { statusCode: 200, body: {} }); + cy.intercept('GET', '/api/settings', { statusCode: 200, body: settingsState }); + cy.intercept('GET', '/api/feeds', { statusCode: 200, body: [feed] }).as('translationFeeds'); + cy.intercept('GET', '/api/tags', { statusCode: 200, body: [] }); + cy.intercept('GET', '/api/saved-filters', { statusCode: 200, body: [] }); + cy.intercept( + { method: 'GET', pathname: '/api/articles' }, + { statusCode: 200, body: [article] } + ).as('translationArticles'); + cy.intercept('GET', '/api/articles/unread-counts', { statusCode: 200, body: {} }); + cy.intercept('GET', '/api/articles/filter-counts', { statusCode: 200, body: {} }); + cy.intercept('GET', '/api/progress', { statusCode: 200, body: { is_running: false } }); + cy.intercept('GET', '/api/articles/content*', { + statusCode: 200, + body: { + content: + 'This text is a direct DOM text node and must still be translated.', + cached: true, + }, + }).as('translationContent'); + cy.intercept('POST', '/api/articles/translate-text', (req) => { + if (req.body.text === article.title) { + req.reply({ + statusCode: 200, + body: { translated_text: '英文标题', html: '', skipped: false }, + }); + return; + } + req.alias = 'translateArticleBody'; + expect(req.body.text).to.contain('direct DOM text node'); + req.reply({ + statusCode: 200, + body: { translated_text: '这段正文已成功翻译', html: '', skipped: false }, + }); + }); + + cy.reload(); + cy.wait('@translationFeeds'); + cy.wait('@translationArticles'); + cy.get('[data-article-id="1"]').click(); + cy.wait('@translationContent'); + cy.wait('@translateArticleBody'); + cy.contains('这段正文已成功翻译').should('be.visible'); + }); + it('should explain AI search results and keep list and card navigation in search context', () => { const settingsState: Record = { language: 'en-US', @@ -412,7 +497,10 @@ describe('Article Operations', () => { if (lastMessage === 'trigger failure') { req.reply({ statusCode: 500, - body: { error: 'Failed to get response from AI. Please try again.', session_id: sessionID }, + body: { + error: 'Failed to get response from AI. Please try again.', + session_id: sessionID, + }, }); return; } @@ -458,5 +546,4 @@ describe('Article Operations', () => { cy.contains('.chat-panel', 'trigger failure').should('be.visible'); cy.contains('Failed to get response from AI. Please try again.').should('be.visible'); }); - }); diff --git a/frontend/src/components/article/ArticleContent.vue b/frontend/src/components/article/ArticleContent.vue index 795279c7..acc513e9 100644 --- a/frontend/src/components/article/ArticleContent.vue +++ b/frontend/src/components/article/ArticleContent.vue @@ -37,6 +37,12 @@ interface SummaryResult { error?: string; } +interface TranslationResult { + text: string; + html: string; + failed: boolean; +} + interface Props { article: Article; articleContent: string; @@ -199,6 +205,7 @@ const lastTranslatedArticleId = ref(null); const lastTranslatedContentHash = ref(''); // Track translated content by hash const translationSkipped = ref(false); let summaryTranslationRequestId = 0; +let contentTranslationRequestId = 0; function loadArticleScrollPositions(): Record { try { @@ -283,9 +290,9 @@ async function translateText( text: string, force: boolean = false, updateTranslationStatus: boolean = true -): Promise<{ text: string; html: string }> { +): Promise { if (!text || !translationEnabled.value) { - return { text: '', html: '' }; + return { text: '', html: '', failed: false }; } const requestBody = { @@ -317,6 +324,7 @@ async function translateText( return { text: data.translated_text || '', html: data.html || '', + failed: false, }; } else { window.showToast(t('common.errors.translatingContent'), 'error'); @@ -324,7 +332,7 @@ async function translateText( } catch { window.showToast(t('common.errors.translating'), 'error'); } - return { text: '', html: '' }; + return { text: '', html: '', failed: true }; } function clearTranslatedSummary() { @@ -363,9 +371,11 @@ async function translateSummary(result: SummaryResult | null) { // Force translate content async function forceTranslateContent() { - if (!props.articleContent) return; + if (!displayContent.value) return; - await translateContentParagraphs(props.articleContent); + lastTranslatedArticleId.value = null; + lastTranslatedContentHash.value = ''; + await translateContentParagraphs(displayContent.value); } // Fetch full article content from the original URL @@ -504,10 +514,28 @@ function simpleHash(str: string): string { return hash.toString(36); } +// RSS content can contain direct text nodes next to media or other HTML. The +// translation pipeline works on semantic text elements, so wrap those orphaned +// text nodes without changing the stored article HTML. +function wrapOrphanedTextNodes(container: Element): void { + const blockContainers = [ + container, + ...Array.from(container.querySelectorAll('div,section,article')), + ]; + blockContainers.forEach((block) => { + Array.from(block.childNodes).forEach((node) => { + if (node.nodeType !== Node.TEXT_NODE || !node.textContent?.trim()) return; + const paragraph = document.createElement('p'); + paragraph.textContent = node.textContent; + block.replaceChild(paragraph, node); + }); + }); +} + // Translate content paragraphs while preserving inline elements (formulas, code, images) -async function translateContentParagraphs(content: string) { +async function translateContentParagraphs(content: string): Promise { if (!translationEnabled.value || !content) { - return; + return true; } // Calculate content hash to detect if content has changed @@ -519,34 +547,35 @@ async function translateContentParagraphs(content: string) { lastTranslatedArticleId.value === props.article?.id && lastTranslatedContentHash.value === contentHash ) { - return; + return true; } isTranslatingContent.value = true; - lastTranslatedArticleId.value = props.article?.id || null; + const articleID = props.article?.id || null; + const requestID = ++contentTranslationRequestId; + const requestIsCurrent = () => + requestID === contentTranslationRequestId && props.article?.id === articleID; + lastTranslatedArticleId.value = articleID; lastTranslatedContentHash.value = contentHash; // Wait for content to render await nextTick(); + if (!requestIsCurrent()) return false; // Find all text elements in the prose content - const proseContainer = document.querySelector('.prose-content'); + const proseContainer = articleScrollContainer.value?.querySelector('.prose-content'); if (!proseContainer) { isTranslatingContent.value = false; - return; + lastTranslatedArticleId.value = null; + lastTranslatedContentHash.value = ''; + return false; } // Remove any existing translations first const existingTranslations = proseContainer.querySelectorAll('.translation-text'); existingTranslations.forEach((el) => el.remove()); - // Check if content is plain text (no HTML tags) and wrap it in

tags - // This handles cases where article content is stored as plain text without HTML structure - const hasHTMLTags = /<[^>]+>/.test(proseContainer.innerHTML); - if (!hasHTMLTags && proseContainer.textContent && proseContainer.textContent.trim().length > 0) { - const textContent = proseContainer.innerHTML; - proseContainer.innerHTML = `

${textContent}

`; - } + wrapOrphanedTextNodes(proseContainer); // Find all translatable elements // For lists: translate individual li items, translation stays inside the same li @@ -570,6 +599,7 @@ async function translateContentParagraphs(content: string) { // Track which elements we've already translated to avoid duplicates const translatedElements = new Set(); + let translationFailed = false; // Process elements level by level to handle nested structures correctly // First, get all elements and sort them by depth (shallowest first) @@ -664,6 +694,11 @@ async function translateContentParagraphs(content: string) { // Translate the text (with placeholders and link markers) const translation = await translateText(textWithPlaceholders); + if (!requestIsCurrent()) return false; + if (translation.failed) { + translationFailed = true; + continue; + } const translatedText = translation.text; // Skip if translation is same as original or empty @@ -709,6 +744,7 @@ async function translateContentParagraphs(content: string) { // Re-apply rendering enhancements to translation elements (for math formulas) await nextTick(); + if (!requestIsCurrent()) return false; proseContainer.querySelectorAll('.translation-text').forEach((el) => { renderMathFormulas(el as HTMLElement); highlightCodeBlocks(el as HTMLElement); @@ -719,6 +755,11 @@ async function translateContentParagraphs(content: string) { await reattachContentInteractions(); isTranslatingContent.value = false; + if (translationFailed) { + lastTranslatedArticleId.value = null; + lastTranslatedContentHash.value = ''; + } + return !translationFailed; } async function reattachContentInteractions() { @@ -883,7 +924,10 @@ watch( summaryResult.value = null; clearTranslatedSummary(); translatedTitle.value = ''; + contentTranslationRequestId += 1; + isTranslatingContent.value = false; lastTranslatedArticleId.value = null; // Reset translation tracking + lastTranslatedContentHash.value = ''; fullArticleContent.value = ''; // Reset full article content when switching articles if (props.article) {