From dadf822152e9a77921bb225be888a8e1a95e0b11 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:09:56 +0200 Subject: [PATCH] fix(Translation): don't remove HTML from source_text (#33558) --- .../doctype/translation/test_translation.py | 106 +++++++++--------- .../core/doctype/translation/translation.py | 10 +- frappe/utils/html_utils.py | 2 +- frappe/utils/translations.py | 4 - 4 files changed, 59 insertions(+), 63 deletions(-) diff --git a/frappe/core/doctype/translation/test_translation.py b/frappe/core/doctype/translation/test_translation.py index 7a28d068d3..0da71d3dc8 100644 --- a/frappe/core/doctype/translation/test_translation.py +++ b/frappe/core/doctype/translation/test_translation.py @@ -16,16 +16,20 @@ class TestTranslation(IntegrationTestCase): clear_cache() def test_doctype(self): - translation_data = get_translation_data() - for lang, (source_string, new_translation) in translation_data.items(): + doctype = "Translation" + meta = frappe.get_meta(doctype) + source_string = meta.get_label("translated_text") + + for lang in ["de", "bs", "zh", "hr", "en", "sv"]: frappe.local.lang = lang - original_translation = _(source_string) + original_translation = _(source_string, context=doctype) + new_translation = f"{original_translation} Customized" - docname = create_translation(lang, source_string, new_translation) - self.assertEqual(_(source_string), new_translation) + docname = create_translation(lang, source_string, new_translation, context=doctype) + self.assertEqual(_(source_string, context=doctype), new_translation) - frappe.delete_doc("Translation", docname) - self.assertEqual(_(source_string), original_translation) + frappe.delete_doc(doctype, docname) + self.assertEqual(_(source_string, context=doctype), original_translation) def test_parent_language(self): data = { @@ -60,37 +64,54 @@ class TestTranslation(IntegrationTestCase): source = "User" self.assertNotEqual(_(source, lang="de"), _(source, lang="es")) - def test_html_content_data_translation(self): - # ruff: noqa: RUF001 + def test_html_content_translation(self): source = """ - MacBook Air lasts up to an incredible 12 hours between charges. So from your morning coffee to - your evening commute, you can work unplugged. When it’s time to kick back and relax, - you can get up to 12 hours of iTunes movie playback. And with up to 30 days of standby time, - you can go away for weeks and pick up where you left off.Whatever the task, - fifth-generation Intel Core i5 and i7 processors with Intel HD Graphics 6000 are up to it.
- """ - + To add dynamic subject, use jinja tags like +
{{ doc.name }} Billed
+ """.strip() target = """ - MacBook Air dura hasta 12 horas increíbles entre cargas. Por lo tanto, - desde el café de la mañana hasta el viaje nocturno, puede trabajar desconectado. - Cuando es hora de descansar y relajarse, puede obtener hasta 12 horas de reproducción de películas de iTunes. - Y con hasta 30 días de tiempo de espera, puede irse por semanas y continuar donde lo dejó. Sea cual sea la tarea, - los procesadores Intel Core i5 e i7 de quinta generación con Intel HD Graphics 6000 son capaces de hacerlo. - """ + Um einen dynamischen Betreff hinzuzufügen, verwenden Sie Jinja-Tags wie +
{{ doc.name }} Abgerechnet
+ """.strip() - create_translation("es", source, target) + frappe.local.lang = "de" - source = """ - MacBook Air lasts up to an incredible 12 hours between charges. So from your morning coffee to - your evening commute, you can work unplugged. When it’s time to kick back and relax, - you can get up to 12 hours of iTunes movie playback. And with up to 30 days of standby time, - you can go away for weeks and pick up where you left off.Whatever the task, - fifth-generation Intel Core i5 and i7 processors with Intel HD Graphics 6000 are up to it.
- """ + self.assertEqual(_(source), source) - self.assertTrue(_(source), target) + create_translation("de", source, target) + + self.assertEqual(_(source), target) + + def test_translated_html_is_sanitized(self): + source = "Translation with HTML" + target = """ + Hallo + + +
Ok
+ """.strip() + + docname = create_translation("de", source, target) + translated_text = frappe.db.get_value("Translation", docname, "translated_text") + + self.assertIn('Hallo', translated_text) + self.assertIn("
Ok
", translated_text) + self.assertNotIn("onclick", translated_text) + self.assertNotIn(" - Test Data""" - html_translated_data = """ - testituloksia """ - - return { - "hr": ["Test data", "Testdaten"], - "ms": ["Test Data", "ujian Data"], - "et": ["Test Data", "testandmed"], - "es": ["Test Data", "datos de prueba"], - "en": ["Quotation", "Tax Invoice"], - "fi": [html_source_data, html_translated_data], - } - - -def create_translation(lang, source_string, new_translation) -> str: +def create_translation(lang, source_string, new_translation, context=None) -> str: doc = frappe.new_doc("Translation") doc.language = lang doc.source_text = source_string doc.translated_text = new_translation + doc.context = context doc.save() return doc.name diff --git a/frappe/core/doctype/translation/translation.py b/frappe/core/doctype/translation/translation.py index b7dc3b3e6e..811ab35ffb 100644 --- a/frappe/core/doctype/translation/translation.py +++ b/frappe/core/doctype/translation/translation.py @@ -1,12 +1,10 @@ # Copyright (c) 2015, Frappe Technologies and contributors # License: MIT. See LICENSE -import json - import frappe from frappe.model.document import Document from frappe.translate import MERGED_TRANSLATION_KEY, USER_TRANSLATION_KEY, change_translation_version -from frappe.utils import is_html, strip_html_tags +from frappe.utils import sanitize_html class Translation(Document): @@ -28,11 +26,7 @@ class Translation(Document): # end: auto-generated types def validate(self): - if is_html(self.source_text): - self.remove_html_from_source() - - def remove_html_from_source(self): - self.source_text = strip_html_tags(self.source_text).strip() + self.translated_text = sanitize_html(self.translated_text) def on_update(self): clear_user_translation_cache(self.language) diff --git a/frappe/utils/html_utils.py b/frappe/utils/html_utils.py index 4e8a828e1e..c0ca52e147 100644 --- a/frappe/utils/html_utils.py +++ b/frappe/utils/html_utils.py @@ -174,7 +174,7 @@ def sanitize_html(html, linkify=False, always_sanitize=False, disallowed_tags=No attributes = {"*": acceptable_attributes, "svg": svg_attributes} - # returns html with escaped tags, escaped orphan >, <, etc. + # returns sanitized HTML with unsafe tags and attributes removed escaped_html = nh3.clean( html, tags=tags, diff --git a/frappe/utils/translations.py b/frappe/utils/translations.py index 4c9bc3f4b2..019ea0a670 100644 --- a/frappe/utils/translations.py +++ b/frappe/utils/translations.py @@ -8,7 +8,6 @@ def _(msg: str, lang: str | None = None, context: str | None = None) -> str: _('Change', context='Coins') """ from frappe.translate import get_all_translations - from frappe.utils import is_html, strip_html_tags if not hasattr(frappe.local, "lang"): frappe.local.lang = lang or "en" @@ -20,9 +19,6 @@ def _(msg: str, lang: str | None = None, context: str | None = None) -> str: non_translated_string = msg - if is_html(msg): - msg = strip_html_tags(msg) - # msg should always be unicode msg = frappe.as_unicode(msg).strip()