diff --git a/rosetta/static/admin/rosetta/js/rosetta.js b/rosetta/static/admin/rosetta/js/rosetta.js index 5e28c59..784a9df 100644 --- a/rosetta/static/admin/rosetta/js/rosetta.js +++ b/rosetta/static/admin/rosetta/js/rosetta.js @@ -24,7 +24,7 @@ $(document).ready(function () { orig = unescape(orig) .replace(//g, "\n") - .replace(//, "") + .replace(//g, "") .replace(/<\/code>/g, "") .replace(/>/g, ">") .replace(/</g, "<"); diff --git a/rosetta/tests/tests.py b/rosetta/tests/tests.py index ece2fc6..a5d064f 100644 --- a/rosetta/tests/tests.py +++ b/rosetta/tests/tests.py @@ -1009,6 +1009,40 @@ def test_47_2_deeps_ajax_translation(self): ) self.assertContains(r, '"Salut tout le monde"') + @vcr.use_cassette( + "fixtures/vcr_cassettes/test_deepl_ajax_translation_with_variables.yaml", + match_on=["method", "scheme", "port", "path", "query", "raw_body"], + record_mode="once", + ) + @override_settings( + DEEPL_AUTH_KEY="FAKE", + AZURE_CLIENT_SECRET=None, + ) + def test_deepl_ajax_translation_with_variables(self): + text = "Ci sono %(items)d %(name)s disponibili." + r = self.client.get( + reverse("rosetta.translate_text") + f"?from=it&to=en&text={text}" + ) + self.assertEqual( + r.json().get("translation"), "There are %(items)d %(name)s available." + ) + + + def test_formating_text_to_and_from_deepl(self): + from ..translate_utils import format_text_to_deepl, format_text_from_deepl + + samples = [ + "Es gibt %(items)d %(name)s verfügbar.", + "Ci sono %(items)d %(name)s disponibili.", + "Há %(items)d %(name)s disponíveis.", + "Stokta %(items)d %(name)s var.", + ] + for sample in samples: + to_deepl = format_text_to_deepl(sample) + from_deepl = format_text_from_deepl(to_deepl) + back_to_deepl = format_text_to_deepl(from_deepl) + self.assertEqual(to_deepl, back_to_deepl) + @override_settings(ROSETTA_REQUIRES_AUTH=True) def test_48_requires_auth_not_respected_issue_203(self): self.client.logout() diff --git a/rosetta/translate_utils.py b/rosetta/translate_utils.py index c69cba9..d392287 100644 --- a/rosetta/translate_utils.py +++ b/rosetta/translate_utils.py @@ -1,6 +1,6 @@ import json +import re import uuid - import requests from django.conf import settings @@ -49,8 +49,38 @@ def translate(text, from_language, to_language): else: raise TranslationException("No translation API service is configured.") +def format_text_to_deepl(text): + pattern = r"%\((\w+)\)(\w)" + def replace_variable(match): + # Our pattern will always catch 2 groups, the first group being '%(' + # Second group being ')d' or ')s' + variable = match.group(1) + type_specifier = match.group(2) + if variable and type_specifier: + return f'{variable}' + else: + raise TranslationException("Badly formatted variable in translation") + + return re.sub(pattern, replace_variable, text) + +def format_text_from_deepl(text): + for g in re.finditer(r'.*?(?P[0-9a-zA-Z_]+).*?', text): + t, v = g.groups() + text = text.replace(f'{v}', f"%({v}){t}") + return text + + def translate_by_deepl(text, to_language, auth_key): + """ + This method connects to the translator Deepl API and fetches a response with translations. + :param text: The source text to be translated + :param to_language: The target language to translate the text into + Wraps variables in tags and instructs Deepl not to translate those. + Then from Deepl response, converts back these tags to django variable syntax. + %(name)s becomes name and back to %(name)s in the response text. + :return: Returns the response from the Deepl as a python object. + """ if auth_key.lower().endswith(":fx"): endpoint = "https://api-free.deepl.com" else: @@ -60,16 +90,19 @@ def translate_by_deepl(text, to_language, auth_key): f"{endpoint}/v2/translate", headers={"Authorization": f"DeepL-Auth-Key {auth_key}"}, data={ + "tag_handling": "xml", + "ignore_tags": "var", "target_lang": to_language.upper(), - "text": text, + "text": format_text_to_deepl(text), }, ) if r.status_code != 200: raise TranslationException( f"Deepl response is {r.status_code}. Please check your API key or try again later." ) + try: - return r.json().get("translations")[0].get("text") + return format_text_from_deepl(r.json().get("translations")[0].get("text")) except Exception: raise TranslationException("Deepl returned a non-JSON or unexpected response.") diff --git a/testproject/fixtures/vcr_cassettes/test_47_2_deeps_ajax_translation.yaml b/testproject/fixtures/vcr_cassettes/test_47_2_deeps_ajax_translation.yaml index bab63a5..6e1d899 100644 --- a/testproject/fixtures/vcr_cassettes/test_47_2_deeps_ajax_translation.yaml +++ b/testproject/fixtures/vcr_cassettes/test_47_2_deeps_ajax_translation.yaml @@ -1,6 +1,6 @@ interactions: - request: - body: target_lang=FR&text=hello+world + body: tag_handling=xml&ignore_tags=var&target_lang=FR&text=hello+world headers: Accept: - '*/*' diff --git a/testproject/fixtures/vcr_cassettes/test_deepl_ajax_translation_with_variables.yaml b/testproject/fixtures/vcr_cassettes/test_deepl_ajax_translation_with_variables.yaml new file mode 100644 index 0000000..7fa0c4b --- /dev/null +++ b/testproject/fixtures/vcr_cassettes/test_deepl_ajax_translation_with_variables.yaml @@ -0,0 +1,46 @@ +interactions: +- request: + body: tag_handling=xml&ignore_tags=var&target_lang=EN&text=Ci+sono+%3Cvar+type%3D%22d%22%3Eitems%3C%2Fvar%3E+%3Cvar+type%3D%22s%22%3Ename%3C%2Fvar%3E+disponibili. + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - DeepL-Auth-Key FAKE + Connection: + - keep-alive + Content-Length: + - '156' + Content-Type: + - application/x-www-form-urlencoded + User-Agent: + - python-requests/2.32.3 + method: POST + uri: https://api-free.deepl.com/v2/translate + response: + body: + string: '{"translations": [{"detected_source_language": "IT", "text": "There are items name available."}]}' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Server-Timing, X-Trace-ID + content-type: + - application/json + date: + - Mon, 07 Oct 2024 07:54:03 GMT + server-timing: + - l7_lb_tls;dur=77, l7_lb_idle;dur=2, l7_lb_receive;dur=1, l7_lb_total;dur=124 + strict-transport-security: + - max-age=63072000; includeSubDomains; preload + transfer-encoding: + - chunked + vary: + - Accept-Encoding + x-trace-id: + - 0ec04e3eef784472bedc5be15a1f8259 + status: + code: 200 + message: OK +version: 1