Repository: mozilla/pontoon

Issue:
Pretranslation ignored a 100% match from TM
https://pontoon.mozilla.org/it/firefox-accounts/all-resources/?search=account-restored-&string=267341

For this string there was a 100% match in TM, but pre-translation used a suggestion from Google, which is unexpected.

![Screenshot 2022-12-30 alle 07 34 34](https://user-images.githubusercontent.com/3644868/210051501-8aa28117-b6a0-426c-aaee-e5242276d009.png)



Discussion:
Same happened in https://pontoon.mozilla.org/it/firefox-accounts/all-resources/?search=ready-account-ready&string=267342 (although, in the end, I decided to accept the alternative).

![Screenshot 2022-12-30 alle 07 32 26](https://user-images.githubusercontent.com/3644868/210051738-bef95b5e-8cda-4c90-9281-41c8c1a06ab5.png)


Any chance to prioritize this? It's definitely impacting the data for Italian, I've rejected several strings today out of 25, when there was a 100% match.

Resolving patch:
diff --git a/pontoon/base/templatetags/helpers.py b/pontoon/base/templatetags/helpers.py
--- a/pontoon/base/templatetags/helpers.py
+++ b/pontoon/base/templatetags/helpers.py
@@ -1,6 +1,7 @@
 import html
 import datetime
 import json
+import re
 
 import markupsafe
 from allauth.socialaccount import providers
@@ -266,6 +267,138 @@ def as_simple_translation(source):
     return _serialize_value(tree.value)
 
 
+def is_single_input_ftl_string(source):
+    """Check if fluent string is single input"""
+    return get_syntax_type(source) == "simple"
+
+
+def get_syntax_type(source):
+    translation_ast = parser.parse_entry(source)
+
+    if not translation_ast or not is_supported_message(translation_ast):
+        return "complex"
+
+    if is_simple_message(translation_ast) or is_simple_single_attribute_message(
+        translation_ast
+    ):
+        return "simple"
+
+    return "rich"
+
+
+def is_supported_message(entry):
+    if not entry or isinstance(entry, ast.Junk):
+        return False
+
+    attributes = entry.attributes
+    value = entry.value
+
+    if isinstance(
+        entry, (ast.Junk, ast.Comment, ast.GroupComment, ast.ResourceComment)
+    ):
+        return False
+
+    if value and not are_supported_elements(value.elements):
+        return False
+
+    return all(
+        attr.value and are_supported_elements(attr.value.elements)
+        for attr in attributes
+    )
+
+
+def are_supported_elements(elements):
+    return all(
+        is_simple_element(element)
+        or (
+            isinstance(element, ast.Placeable)
+            and isinstance(element.expression, ast.SelectExpression)
+            and all(
+                all(
+                    is_simple_element(variant_element)
+                    for variant_element in variant.value.elements
+                )
+                for variant in element.expression.variants
+            )
+        )
+        for element in elements
+    )
+
+
+def is_simple_message(entry):
+    value = entry.value
+    return (
+        isinstance(entry, (ast.Message, ast.Term))
+        and not entry.attributes
+        and (value and all(is_simple_element(element) for element in value.elements))
+    )
+
+
+def is_simple_element(element):
+    if isinstance(element, ast.TextElement):
+        return True
+    if isinstance(element, ast.Placeable):
+        return isinstance(
+            element.expression,
+            (
+                ast.FunctionReference,
+                ast.TermReference,
+                ast.MessageReference,
+                ast.VariableReference,
+                ast.NumberLiteral,
+                ast.StringLiteral,
+            ),
+        )
+
+    return False
+
+
+def is_simple_single_attribute_message(message):
+    return (
+        isinstance(message, ast.Message)
+        and not message.value
+        and message.attributes
+        and len(message.attributes) == 1
+        and all(
+            is_simple_element(element)
+            for element in message.attributes[0].value.elements
+        )
+    )
+
+
+def get_reconstructed_message(original, translation):
+    """Return a reconstructed Fluent message from the original message and some translated content."""
+    translation_ast = parser.parse_entry(original)
+
+    if not isinstance(translation_ast, ast.Message) and not isinstance(
+        translation_ast, ast.Term
+    ):
+        raise ValueError(f"Unexpected type in getReconstructedMessage")
+
+    key = translation_ast.id.name
+    # For Terms, the leading dash is removed in the identifier. We need to add
+    # it back manually.
+    if isinstance(translation_ast, ast.Term):
+        key = "-" + key
+
+    content = f"{key} ="
+    indent = " " * 4
+
+    if translation_ast.attributes and len(translation_ast.attributes) == 1:
+        attribute = translation_ast.attributes[0].id.name
+        content += f"\n{indent}.{attribute} ="
+        indent = indent + indent
+
+    if "\n" in translation:
+        content += "\n" + re.sub(r"^", indent, translation, flags=re.MULTILINE)
+    elif translation:
+        content += " " + translation
+    else:
+        content += ' { "" }'
+
+    return content
+
+
 @library.filter
 def linkify(source):
     """Render URLs in the string as links."""
diff --git a/pontoon/pretranslation/pretranslate.py b/pontoon/pretranslation/pretranslate.py
--- a/pontoon/pretranslation/pretranslate.py
+++ b/pontoon/pretranslation/pretranslate.py
@@ -10,6 +10,12 @@
     get_translation_memory_data,
 )
 
+from pontoon.base.templatetags.helpers import (
+    as_simple_translation,
+    is_single_input_ftl_string,
+    get_reconstructed_message,
+)
+
 
 def get_translations(entity, locale):
     """
@@ -29,9 +35,15 @@ def get_translations(entity, locale):
     strings = []
     plural_forms = range(0, locale.nplurals or 1)
 
+    entity_string = (
+        as_simple_translation(entity.string)
+        if is_single_input_ftl_string(entity.string)
+        else entity.string
+    )
+
     # Try to get matches from translation_memory
     tm_response = get_translation_memory_data(
-        text=entity.string,
+        text=entity_string,
         locale=locale,
     )
 
@@ -39,7 +51,12 @@ def get_translations(entity, locale):
 
     if tm_response:
         if entity.string_plural == "":
-            strings = [(tm_response[0]["target"], None, tm_user)]
+            translation = tm_response[0]["target"]
+
+            if entity.string != entity_string:
+                translation = get_reconstructed_message(entity.string, translation)
+
+            strings = [(translation, None, tm_user)]
         else:
             for plural_form in plural_forms:
                 strings.append((tm_response[0]["target"], plural_form, tm_user))


Test patch:
diff --git a/pontoon/base/tests/test_helpers.py b/pontoon/base/tests/test_helpers.py
--- a/pontoon/base/tests/test_helpers.py
+++ b/pontoon/base/tests/test_helpers.py
@@ -11,9 +11,15 @@
     nospam,
     to_json,
     metric_prefix,
+    get_syntax_type,
+    is_simple_single_attribute_message,
+    get_reconstructed_message,
 )
+
+from fluent.syntax import FluentParser
 from pontoon.base.utils import aware_datetime
 
+parser = FluentParser()
 
 MULTILINE_SOURCE = """key =
     Simple String
@@ -74,6 +80,168 @@
     )
 )
 
+GET_SYNTAX_TYPE_TESTS = [
+    {"input": "my-entry = Hello!", "output": "simple"},
+    {
+        "input": """
+my-entry =
+    Multi
+    line
+    value.""",
+        "output": "simple",
+    },
+    {
+        "input": 'my-entry = Today is { DATETIME($date, month: "long", year: "numeric", day: "numeric") }',
+        "output": "simple",
+    },
+    {"input": "-my-entry = Hello!", "output": "simple"},
+    {"input": "my-entry = Term { -term } Reference", "output": "simple"},
+    {"input": "my-entry = { my_id }", "output": "simple"},
+    {"input": "my-entry = { my_id.title }", "output": "simple"},
+    {"input": 'my-entry = { "" }', "output": "simple"},
+    {"input": "my-entry = { 5 }", "output": "simple"},
+    {"input": "my-entry = \n    .an-atribute = Hello!", "output": "simple"},
+    {"input": "my-entry = World\n    .an-atribute = Hello!", "output": "rich"},
+    {
+        "input": """
+my-entry =
+    .an-atribute = Hello!
+    .another-atribute = World!""",
+        "output": "rich",
+    },
+    {
+        "input": """
+my-entry =
+    { PLATFORM() ->
+        [variant] Hello!
+        *[another-variant] World!
+    }""",
+        "output": "rich",
+    },
+    {
+        "input": """
+my-entry =
+    There { $num ->
+        [one] is one email
+       *[other] are many emails
+    } for { $gender ->
+       *[masculine] him
+        [feminine] her
+    }""",
+        "output": "rich",
+    },
+    {
+        "input": """
+my-entry =
+    .label =
+        { PLATFORM() ->
+            [macos] Choose
+           *[other] Browse
+        }
+    .accesskey =
+        { PLATFORM() ->
+            [macos] e
+           *[other] o
+        }""",
+        "output": "rich",
+    },
+    {
+        "input": """
+my-entry =
+    { $gender ->
+       *[masculine]
+            { $num ->
+                [one] There is one email for her
+               *[other] There are many emails for her
+            }
+        [feminine]
+            { $num ->
+                [one] There is one email for him
+               *[other] There are many emails for him
+            }
+    }""",
+        "output": "complex",
+    },
+]
+
+SIMPLE_SINGLE_ATTRIBUTE_TESTS = OrderedDict(
+    (
+        ("single-attribute", ("my-entry =\n    .an-atribute = Hello!", True)),
+        (
+            "string-with-text",
+            ("my-entry = Something\n    .an-atribute = Hello!", False),
+        ),
+        (
+            "string-with-several-attributes",
+            (
+                "my-entry =\n    .an-atribute = Hello!\n    .two-attrites = World!",
+                False,
+            ),
+        ),
+    )
+)
+
+GET_RECONSTRUCTED_MESSAGE_TESTS = test_cases = OrderedDict(
+    [
+        (
+            "simple-message",
+            {
+                "original": "title = Marvel Cinematic Universe",
+                "translation": "Univers cinématographique Marvel",
+                "expected": "title = Univers cinématographique Marvel",
+            },
+        ),
+        (
+            "single-attribute",
+            {
+                "original": "spoilers =\n    .who-dies = Who dies?",
+                "translation": "Qui meurt ?",
+                "expected": "spoilers =\n    .who-dies = Qui meurt ?",
+            },
+        ),
+        (
+            "multiline-simple-message",
+            {
+                "original": "time-travel = They discovered Time Travel",
+                "translation": "Ils ont inventé le\nvoyage temporel",
+                "expected": "time-travel =\n    Ils ont inventé le\n    voyage temporel",
+            },
+        ),
+        (
+            "multiline-single-attribute",
+            {
+                "original": "slow-walks =\n    .title = They walk in slow motion",
+                "translation": "Ils se déplacent\nen mouvement lents",
+                "expected": "slow-walks =\n    .title =\n        Ils se déplacent\n        en mouvement lents",
+            },
+        ),
+        (
+            "leading-dash-term",
+            {
+                "original": "-my-term = MyTerm",
+                "translation": "Mon Terme",
+                "expected": "-my-term = Mon Terme",
+            },
+        ),
+        (
+            "only-first-text-element",
+            {
+                "original": "stark = Tony Stark\n    .hero = IronMan\n    .hair = black",
+                "translation": "Anthony Stark",
+                "expected": "stark = Anthony Stark",
+            },
+        ),
+        (
+            "not-duplicating-terms",
+            {
+                "original": "with-term = I am { -term }",
+                "translation": "Je suis { -term }",
+                "expected": "with-term = Je suis { -term }",
+            },
+        ),
+    ]
+)
+
 
 @pytest.mark.parametrize("k", SIMPLE_TRANSLATION_TESTS)
 def test_helper_as_simple_translation(k):
@@ -145,3 +313,25 @@ def test_helper_base_nospam_unicode(settings):
 
 def test_helper_base_nospam_escape(settings):
     assert str(nospam("<>'\"@&")) == "&lt;&gt;&#x27;&quot;&#64;&amp;"
+
+
+def test_get_syntax_type():
+    for test_case in GET_SYNTAX_TYPE_TESTS:
+        input = test_case["input"]
+        expected_output = test_case["output"]
+        assert get_syntax_type(input) == expected_output
+
+
+@pytest.mark.parametrize("k", SIMPLE_SINGLE_ATTRIBUTE_TESTS)
+def test_is_simple_single_attribute_message(k):
+    string, expected = SIMPLE_SINGLE_ATTRIBUTE_TESTS[k]
+    assert is_simple_single_attribute_message(parser.parse_entry(string)) == expected
+
+
+@pytest.mark.parametrize("k", GET_RECONSTRUCTED_MESSAGE_TESTS)
+def test_get_reconstructed_message(k):
+    result = get_reconstructed_message(
+        GET_RECONSTRUCTED_MESSAGE_TESTS[k]["original"],
+        GET_RECONSTRUCTED_MESSAGE_TESTS[k]["translation"],
+    )
+    assert result == GET_RECONSTRUCTED_MESSAGE_TESTS[k]["expected"]