diff --git a/cspell.json b/cspell.json index 14ac2158202a..969a0374e8ce 100644 --- a/cspell.json +++ b/cspell.json @@ -98,6 +98,7 @@ "Charleson", "Checkmark", "checkmarked", + "chien", "Chronos", "citi", "clawback", @@ -136,6 +137,7 @@ "deeplinks", "delegators", "delish", + "describedby", "Deutsch", "devportal", "DFOLLY", @@ -295,6 +297,7 @@ "killall", "Kowalski", "Krasoń", + "labelledby", "Lagertha", "laggy", "lastiPhoneLogin", diff --git a/scripts/utils/Translator/ChatGPTTranslator.ts b/scripts/utils/Translator/ChatGPTTranslator.ts index d0ecf07f2768..7535e084c321 100644 --- a/scripts/utils/Translator/ChatGPTTranslator.ts +++ b/scripts/utils/Translator/ChatGPTTranslator.ts @@ -35,7 +35,7 @@ class ChatGPTTranslator extends Translator { userPrompt: text, }); - if (this.validateTemplatePlaceholders(text, result)) { + if (this.validateTemplatePlaceholders(text, result) && this.validateTemplateHTML(text, result)) { if (attempt > 0) { console.log(`🙃 Translation succeeded after ${attempt + 1} attempts`); } @@ -43,7 +43,7 @@ class ChatGPTTranslator extends Translator { return result; } - console.warn(`⚠️ Translation for "${text}" failed placeholder validation (attempt ${attempt + 1}/${ChatGPTTranslator.MAX_RETRIES + 1})`); + console.warn(`⚠️ Translation for "${text}" failed validation (attempt ${attempt + 1}/${ChatGPTTranslator.MAX_RETRIES + 1})`); if (attempt === ChatGPTTranslator.MAX_RETRIES) { console.error(`❌ Final attempt failed placeholder validation. Falling back to original.`); @@ -63,19 +63,6 @@ class ChatGPTTranslator extends Translator { // Should never hit this, but fallback just in case return text; } - - /** - * Validate that placeholders are all present and unchanged before and after translation. - */ - private validateTemplatePlaceholders(original: string, translated: string): boolean { - const extractPlaceholders = (s: string) => - Array.from(s.matchAll(/\$\{[^}]*}/g)) - .map((m) => m[0]) - .sort(); - const originalSpans = extractPlaceholders(original); - const translatedSpans = extractPlaceholders(translated); - return JSON.stringify(originalSpans) === JSON.stringify(translatedSpans); - } } export default ChatGPTTranslator; diff --git a/scripts/utils/Translator/Translator.ts b/scripts/utils/Translator/Translator.ts index ace23f8a6c5e..ff2f6263882d 100644 --- a/scripts/utils/Translator/Translator.ts +++ b/scripts/utils/Translator/Translator.ts @@ -1,3 +1,4 @@ +import {DomUtils, parseDocument} from 'htmlparser2'; import type {TranslationTargetLocale} from '@src/CONST/LOCALES'; /** @@ -33,6 +34,58 @@ abstract class Translator { private trimForLogs(text: string) { return `${text.slice(0, 80)}${text.length > 80 ? '...' : ''}`; } + + /** + * Validate that placeholders are all present and unchanged before and after translation. + */ + public validateTemplatePlaceholders(original: string, translated: string): boolean { + const extractPlaceholders = (s: string) => + Array.from(s.matchAll(/\$\{[^}]*}/g)) + .map((m) => m[0]) + .sort(); + const originalSpans = extractPlaceholders(original); + const translatedSpans = extractPlaceholders(translated); + return JSON.stringify(originalSpans) === JSON.stringify(translatedSpans); + } + + /** + * Validate that the HTML structure is the same before and after translation. + */ + public validateTemplateHTML(original: string, translated: string): boolean { + // Attributes that are allowed to be translated + const TRANSLATABLE_ATTRIBUTES = new Set(['alt', 'title', 'placeholder', 'aria-label', 'aria-describedby', 'aria-labelledby', 'value']); + + const parseHTMLStructure = (s: string) => { + const doc = parseDocument(s); + const elements = DomUtils.getElementsByTagName(() => true, doc, true); + + return elements.map((element) => { + const tagName = element.name.toLowerCase(); + + // Extract attributes, excluding translatable ones + const attributes: string[] = []; + if (element.attribs) { + for (const [attrName, attrValue] of Object.entries(element.attribs ?? {})) { + const normalizedAttrName = attrName.toLowerCase(); + if (!TRANSLATABLE_ATTRIBUTES.has(normalizedAttrName)) { + attributes.push(`${normalizedAttrName}="${attrValue ?? ''}"`); + } + } + } + + return { + tagName, + attributes: attributes.sort(), + }; + }); + }; + + const originalStructure = parseHTMLStructure(original); + const translatedStructure = parseHTMLStructure(translated); + + // Compare structures (tag names and non-translatable attributes) + return JSON.stringify(originalStructure) === JSON.stringify(translatedStructure); + } } export default Translator; diff --git a/tests/unit/ChatGPTTranslatorTest.ts b/tests/unit/ChatGPTTranslatorTest.ts index cfbc2671a53c..42dcc3830256 100644 --- a/tests/unit/ChatGPTTranslatorTest.ts +++ b/tests/unit/ChatGPTTranslatorTest.ts @@ -18,6 +18,10 @@ describe('ChatGPTTranslator.performTranslation', () => { const validTranslation = '[it] Hello ${name}!'; const invalidTranslation = '[it] Hello name!'; // missing ${...} + const originalHTML = 'A dog'; + const validHTMLTranslation = '[it] Un chien'; + const invalidHTMLTranslation = '[it] Un chien'; // different src + let translator: ChatGPTTranslator; beforeEach(() => { @@ -50,4 +54,16 @@ describe('ChatGPTTranslator.performTranslation', () => { expect(MockedOpenAIUtils.prototype.promptChatCompletions).toHaveBeenCalledTimes(ChatGPTTranslator.MAX_RETRIES + 1); expect(result).toBe(original); }); + + it('retries if translated HTML has incorrect attributes, then succeeds', async () => { + // First attempt returns invalid HTML format, second returns valid + (MockedOpenAIUtils.prototype.promptChatCompletions as jest.Mock).mockResolvedValueOnce(invalidHTMLTranslation).mockResolvedValueOnce(validHTMLTranslation); + + // @ts-expect-error TS2445 + const result = await translator.performTranslation(targetLang, originalHTML); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(MockedOpenAIUtils.prototype.promptChatCompletions).toHaveBeenCalledTimes(2); + expect(result).toBe(validHTMLTranslation); + }); });