Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 91 additions & 4 deletions frontend/cypress/e2e/article-operations.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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}');
Expand Down Expand Up @@ -164,6 +170,85 @@ describe('Article Operations', () => {
});
});

it('should translate orphaned article text next to media', () => {
const settingsState: Record<string, string> = {
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.<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==">',
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<string, string> = {
language: 'en-US',
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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');
});

});
80 changes: 62 additions & 18 deletions frontend/src/components/article/ArticleContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ interface SummaryResult {
error?: string;
}

interface TranslationResult {
text: string;
html: string;
failed: boolean;
}

interface Props {
article: Article;
articleContent: string;
Expand Down Expand Up @@ -199,6 +205,7 @@ const lastTranslatedArticleId = ref<number | null>(null);
const lastTranslatedContentHash = ref<string>(''); // Track translated content by hash
const translationSkipped = ref(false);
let summaryTranslationRequestId = 0;
let contentTranslationRequestId = 0;

function loadArticleScrollPositions(): Record<string, number> {
try {
Expand Down Expand Up @@ -283,9 +290,9 @@ async function translateText(
text: string,
force: boolean = false,
updateTranslationStatus: boolean = true
): Promise<{ text: string; html: string }> {
): Promise<TranslationResult> {
if (!text || !translationEnabled.value) {
return { text: '', html: '' };
return { text: '', html: '', failed: false };
}

const requestBody = {
Expand Down Expand Up @@ -317,14 +324,15 @@ async function translateText(
return {
text: data.translated_text || '',
html: data.html || '',
failed: false,
};
} else {
window.showToast(t('common.errors.translatingContent'), 'error');
}
} catch {
window.showToast(t('common.errors.translating'), 'error');
}
return { text: '', html: '' };
return { text: '', html: '', failed: true };
}

function clearTranslatedSummary() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<boolean> {
if (!translationEnabled.value || !content) {
return;
return true;
}

// Calculate content hash to detect if content has changed
Expand All @@ -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 <p> 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 = `<p>${textContent}</p>`;
}
wrapOrphanedTextNodes(proseContainer);

// Find all translatable elements
// For lists: translate individual li items, translation stays inside the same li
Expand All @@ -570,6 +599,7 @@ async function translateContentParagraphs(content: string) {

// Track which elements we've already translated to avoid duplicates
const translatedElements = new Set<HTMLElement>();
let translationFailed = false;

// Process elements level by level to handle nested structures correctly
// First, get all elements and sort them by depth (shallowest first)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down