Repository: WeblateOrg/weblate

Issue:
Dynamic dictionary for Azure AI Translator
### Describe the problem

Azure AI Translator can [translate with dynamic dictionary](https://learn.microsoft.com/en-us/azure/ai-services/translator/reference/v3-0-translate#translate-with-dynamic-dictionary). This would be a great way to expose Weblate glossaries there.

### Describe the solution you'd like

Utilize `get_glossary_terms` before sending string to translate and annotate glossary terms with `<mstrans:dictionary translation="translation of phrase">phrase</mstrans:dictionary>` markup.

### Describe alternatives you've considered

_No response_

### Screenshots

_No response_

### Additional context

_No response_


Discussion:
This issue seems to be a good fit for newbie contributors. You are welcome to contribute to Weblate! Don't hesitate to ask any questions you would have while implementing this.

You can learn about how to get started in our [contributors documentation](https://docs.weblate.org/en/latest/contributing/index.html).

Resolving patch:
diff --git a/weblate/machinery/googlev3.py b/weblate/machinery/googlev3.py
--- a/weblate/machinery/googlev3.py
+++ b/weblate/machinery/googlev3.py
@@ -79,7 +79,7 @@ def download_translations(
 
     def format_replacement(self, h_start: int, h_end: int, h_text: str, h_kind: Any):
         """Generates a single replacement."""
-        return f'<span translate="no" id="{h_start}">{h_text}</span>'  # noqa: B028
+        return f'<span translate="no" id="{h_start}">{self.escape_text(h_text)}</span>'  # noqa: B028
 
     def cleanup_text(self, text, unit):
         text, replacements = super().cleanup_text(text, unit)
diff --git a/weblate/machinery/microsoft.py b/weblate/machinery/microsoft.py
--- a/weblate/machinery/microsoft.py
+++ b/weblate/machinery/microsoft.py
@@ -5,17 +5,24 @@
 from __future__ import annotations
 
 from datetime import timedelta
+from typing import Any
 
 from django.utils import timezone
 
-from .base import MachineTranslation, MachineTranslationError
+from weblate.glossary.models import get_glossary_terms
+
+from .base import (
+    MachineTranslation,
+    MachineTranslationError,
+    XMLMachineTranslationMixin,
+)
 from .forms import MicrosoftMachineryForm
 
 TOKEN_URL = "https://{0}{1}/sts/v1.0/issueToken?Subscription-Key={2}"  # noqa: S105
 TOKEN_EXPIRY = timedelta(minutes=9)
 
 
-class MicrosoftCognitiveTranslation(MachineTranslation):
+class MicrosoftCognitiveTranslation(XMLMachineTranslationMixin, MachineTranslation):
     """Microsoft Cognitive Services Translator API support."""
 
     name = "Azure AI Translator"
@@ -123,6 +130,7 @@ def download_translations(
             "from": source,
             "to": language,
             "category": "general",
+            "textType": "html",
         }
         response = self.request(
             "post", self.get_url("translate"), params=args, json=[{"Text": text[:5000]}]
@@ -136,3 +144,37 @@ def download_translations(
             "service": self.name,
             "source": text,
         }
+
+    def format_replacement(self, h_start: int, h_end: int, h_text: str, h_kind: Any):
+        """Generates a single replacement."""
+        if h_kind is None:
+            return f'<span class="notranslate" id="{h_start}">{self.escape_text(h_text)}</span>'  # noqa: B028
+        # Glossary
+        flags = h_kind.all_flags
+        if "forbidden" in flags:
+            return h_text
+        if "read-only" in flags:
+            # Use terminology format
+            return self.format_replacement(h_start, h_end, h_text, None)
+        return f'<mstrans:dictionary translation="{self.escape_text(h_kind.target)}">{self.escape_text(h_text)}</mstrans:dictionary>'
+
+    def get_highlights(self, text, unit):
+        result = list(super().get_highlights(text, unit))
+
+        for term in get_glossary_terms(unit):
+            for start, end in term.glossary_positions:
+                glossary_highlight = (start, end, text[start:end], term)
+                handled = False
+                for i, (h_start, _h_end, _h_text, _h_kind) in enumerate(result):
+                    if start < h_start:
+                        if end > h_start:
+                            # Skip as overlaps
+                            break
+                        # Insert before
+                        result.insert(i, glossary_highlight)
+                        handled = True
+                        break
+                if not handled and not result or result[-1][1] < start:
+                    result.append(glossary_highlight)
+
+        yield from result


Test patch:
diff --git a/weblate/checks/tests/test_checks.py b/weblate/checks/tests/test_checks.py
--- a/weblate/checks/tests/test_checks.py
+++ b/weblate/checks/tests/test_checks.py
@@ -38,6 +38,7 @@ def __init__(self):
         self.id = 1
         self.use_shared_tm = True
         self.name = "MockProject"
+        self.slug = "mock"
 
     def get_glossary_tsv_cache_key(self, source_language, language):
         return f"project-glossary-tsv-test-{source_language.code}-{language.code}"
@@ -46,6 +47,12 @@ def get_glossary_tsv_cache_key(self, source_language, language):
     def glossaries(self):
         return []
 
+    @property
+    def glossary_automaton(self):
+        from weblate.glossary.models import get_glossary_automaton
+
+        return get_glossary_automaton(self)
+
 
 class MockComponent:
     """Mock component object."""
@@ -114,6 +121,7 @@ def __init__(
         self.machinery = None
         self.is_source = is_source
         self.context = context
+        self.glossary_terms = None
 
     @property
     def all_flags(self):
diff --git a/weblate/machinery/tests.py b/weblate/machinery/tests.py
--- a/weblate/machinery/tests.py
+++ b/weblate/machinery/tests.py
@@ -499,13 +499,13 @@ def mock_response(self):
         responses.add(
             responses.POST,
             "https://api.cognitive.microsofttranslator.com/"
-            "translate?api-version=3.0&from=en&to=cs&category=general",
+            "translate?api-version=3.0&from=en&to=cs&category=general&textType=html",
             json=MICROSOFT_RESPONSE,
         )
         responses.add(
             responses.POST,
             "https://api.cognitive.microsofttranslator.com/"
-            "translate?api-version=3.0&from=en&to=de&category=general",
+            "translate?api-version=3.0&from=en&to=de&category=general&textType=html",
             json=MICROSOFT_RESPONSE,
         )
 
@@ -534,13 +534,13 @@ def mock_response(self):
         responses.add(
             responses.POST,
             "https://api.cognitive.microsofttranslator.com/"
-            "translate?api-version=3.0&from=en&to=cs&category=general",
+            "translate?api-version=3.0&from=en&to=cs&category=general&textType=html",
             json=MICROSOFT_RESPONSE,
         )
         responses.add(
             responses.POST,
             "https://api.cognitive.microsofttranslator.com/"
-            "translate?api-version=3.0&from=en&to=de&category=general",
+            "translate?api-version=3.0&from=en&to=de&category=general&textType=html",
             json=MICROSOFT_RESPONSE,
         )