Repository: WeblateOrg/weblate

Issue:
Do not allow adding plurals to non-plural formats
### Describe the issue

Presently, the API seems to allow adding plural strings even if it is not supported by the file format. I've noticed this with ARB (which uses ICU Message Format instead), but it might apply to other formats as well.

### I already tried

- [X] I've read and searched [the documentation](https://docs.weblate.org/).
- [X] I've searched for similar issues in this repository.

### Steps to reproduce the behavior

1. Use API to add new string
2. See that you can add plural even if it not supported by the file format

### Expected behavior

The validation should reject this.

### Screenshots

_No response_

### Exception traceback

_No response_

### How do you run Weblate?

weblate.org service

### Weblate versions

_No response_

### Weblate deploy checks

_No response_

### Additional context

_No response_


Discussion:
Part of this has been already addressed in 9a66b2fc7383ec45dee505aff77a9822bd8f654b.

Resolving patch:
diff --git a/weblate/formats/base.py b/weblate/formats/base.py
--- a/weblate/formats/base.py
+++ b/weblate/formats/base.py
@@ -259,6 +259,7 @@ class TranslationFormat:
     create_style = "create"
     has_multiple_strings: bool = False
     supports_explanation: bool = False
+    supports_plural: bool = False
     can_edit_base: bool = True
     strict_format_plurals: bool = False
     plural_preference: tuple[int, ...] | None = None
diff --git a/weblate/formats/ttkit.py b/weblate/formats/ttkit.py
--- a/weblate/formats/ttkit.py
+++ b/weblate/formats/ttkit.py
@@ -1055,6 +1055,7 @@ def is_readonly(self):
 class BasePoFormat(TTKitFormat, BilingualUpdateMixin):
     loader = pofile
     plural_preference = None
+    supports_plural: bool = True
 
     @classmethod
     def get_plural(cls, language, store=None):
@@ -1205,6 +1206,7 @@ class TSFormat(TTKitFormat):
     autoload = ("*.ts",)
     unit_class = TSUnit
     set_context_bilingual = False
+    supports_plural: bool = True
 
 
 class XliffFormat(TTKitFormat):
@@ -1249,6 +1251,7 @@ class PoXliffFormat(XliffFormat):
     format_id = "poxliff"
     autoload = ("*.poxliff",)
     loader = PoXliffFile
+    supports_plural: bool = True
 
 
 class PropertiesBaseFormat(TTKitFormat):
@@ -1337,6 +1340,7 @@ class GWTFormat(StringsFormat):
     new_translation = "\n"
     check_flags = ("auto-java-messageformat",)
     language_format = "linux"
+    supports_plural: bool = True
 
 
 class GWTISOFormat(GWTFormat):
@@ -1368,6 +1372,7 @@ class LaravelPhpFormat(PhpFormat):
     name = gettext_lazy("Laravel PHP strings")
     format_id = "laravel"
     loader = ("php", "LaravelPHPFile")
+    supports_plural: bool = True
 
 
 class RESXFormat(TTKitFormat):
@@ -1379,6 +1384,7 @@ class RESXFormat(TTKitFormat):
     new_translation = RESXFile.XMLskeleton
     autoload = ("*.resx",)
     language_format = "bcp"
+    supports_plural: bool = True
 
 
 class AndroidFormat(TTKitFormat):
@@ -1398,6 +1404,7 @@ class AndroidFormat(TTKitFormat):
         Plural.SOURCE_DEFAULT,
     )
     strict_format_plurals: bool = True
+    supports_plural: bool = True
 
 
 class DictStoreMixin:
@@ -1445,6 +1452,7 @@ class WebExtensionJSONFormat(JSONFormat):
     monolingual = True
     autoload = ("messages*.json",)
     unit_class = PlaceholdersJSONUnit
+    supports_plural: bool = True
 
 
 class I18NextFormat(JSONFormat):
@@ -1454,6 +1462,7 @@ class I18NextFormat(JSONFormat):
     autoload = ()
     check_flags = ("i18next-interpolation",)
     language_format: str = "bcp"
+    supports_plural: bool = True
 
 
 class I18NextV4Format(I18NextFormat):
@@ -1467,6 +1476,7 @@ class GoI18JSONFormat(JSONFormat):
     format_id = "go-i18n-json"
     loader = ("jsonl10n", "GoI18NJsonFile")
     autoload = ()
+    supports_plural: bool = True
 
 
 class GoI18V2JSONFormat(JSONFormat):
@@ -1474,6 +1484,7 @@ class GoI18V2JSONFormat(JSONFormat):
     format_id = "go-i18n-json-v2"
     loader = ("jsonl10n", "GoI18NV2JsonFile")
     autoload = ()
+    supports_plural: bool = True
 
 
 class ARBFormat(JSONFormat):
@@ -1491,6 +1502,7 @@ class GoTextFormat(JSONFormat):
     loader = ("jsonl10n", "GoTextJsonFile")
     autoload = ()
     unit_class = PlaceholdersJSONUnit
+    supports_plural: bool = True
 
 
 class CSVFormat(TTKitFormat):
@@ -1644,6 +1656,7 @@ class RubyYAMLFormat(YAMLFormat):
     format_id = "ruby-yaml"
     loader = ("yaml", "RubyYAMLFile")
     autoload = ("*.ryml", "*.yml", "*.yaml")
+    supports_plural: bool = True
 
 
 class DTDFormat(TTKitFormat):
@@ -1847,6 +1860,7 @@ class XWikiPropertiesFormat(PropertiesBaseFormat):
     can_add_unit: bool = False
     can_delete_unit: bool = False
     set_context_bilingual: bool = True
+    supports_plural: bool = True
 
     # Ensure that untranslated units are saved too as missing properties and
     # comments are preserved as in the original source file.
@@ -2009,6 +2023,7 @@ class StringsdictFormat(DictStoreMixin, TTKitFormat):
     </dict>
 </plist>
 """
+    supports_plural: bool = True
 
     @staticmethod
     def mimetype():
diff --git a/weblate/trans/models/translation.py b/weblate/trans/models/translation.py
--- a/weblate/trans/models/translation.py
+++ b/weblate/trans/models/translation.py
@@ -39,7 +39,7 @@
 from weblate.trans.models.unit import Unit
 from weblate.trans.models.variant import Variant
 from weblate.trans.signals import component_post_update, store_post_load, vcs_pre_commit
-from weblate.trans.util import join_plural, split_plural
+from weblate.trans.util import is_plural, join_plural, split_plural
 from weblate.trans.validators import validate_check_flags
 from weblate.utils.errors import report_error
 from weblate.utils.render import render_template
@@ -1370,9 +1370,13 @@ def add_unit(  # noqa: C901
     ):
         if isinstance(source, list):
             source = join_plural(source)
+
         user = request.user if request else None
         component = self.component
         add_terminology = False
+        if is_plural(source) and not component.file_format_cls.supports_plural:
+            raise ValueError("Plurals not supported by format!")
+
         if self.is_source:
             translations = (
                 self,
@@ -1612,9 +1616,14 @@ def validate_new_unit_data(
         explanation: str = "",
         state: int | None = None,
     ):
+        component = self.component
         extra = {}
         if isinstance(source, str):
             source = [source]
+        if len(source) > 1 and not component.file_format_cls.supports_plural:
+            raise ValidationError(
+                gettext("Plurals are not supported by the file format!")
+            )
         for text in chain(source, [context]):
             if any(char in text for char in CONTROLCHARS):
                 raise ValidationError(
@@ -1634,15 +1643,15 @@ def validate_new_unit_data(
                     )
                 )
         if context:
-            self.component.file_format_cls.validate_context(context)
-        if not self.component.has_template():
+            component.file_format_cls.validate_context(context)
+        if not component.has_template():
             extra["source"] = join_plural(source)
         if not auto_context and self.unit_set.filter(context=context, **extra).exists():
             raise ValidationError(gettext("This string seems to already exist."))
         # Avoid using source translations without a filename
         if not self.filename:
             try:
-                translation = self.component.translation_set.exclude(pk=self.pk)[0]
+                translation = component.translation_set.exclude(pk=self.pk)[0]
             except IndexError:
                 raise ValidationError(
                     gettext("Failed adding string, no translation found.")


Test patch:
diff --git a/weblate/api/tests.py b/weblate/api/tests.py
--- a/weblate/api/tests.py
+++ b/weblate/api/tests.py
@@ -2946,6 +2946,64 @@ def test_add_bilingual(self):
             code=200,
         )
 
+    def test_add_plural(self):
+        # Add to bilingual
+        self.component.manage_units = True
+        self.component.save()
+        self.do_request(
+            "api:translation-units",
+            {
+                "language__code": "cs",
+                "component__slug": "test",
+                "component__project__slug": "test",
+            },
+            method="post",
+            superuser=True,
+            request={
+                "source": ["Singular", "Plural"],
+                "target": ["Target 0", "Target 1"],
+                "state": "20",
+            },
+            code=200,
+        )
+
+        # Add to monolingual
+        self.create_acl()
+        self.do_request(
+            "api:translation-units",
+            {
+                "language__code": "en",
+                "component__slug": "test",
+                "component__project__slug": "acl",
+            },
+            method="post",
+            superuser=True,
+            request={
+                "key": "pluralized",
+                "value": ["Singular", "Plural"],
+            },
+            code=200,
+        )
+
+        # Not supported plurals
+        self.create_json_mono(name="other", project=self.component.project)
+        self.do_request(
+            "api:translation-units",
+            {
+                "language__code": "en",
+                "component__slug": "other",
+                "component__project__slug": "test",
+            },
+            method="post",
+            superuser=True,
+            request={
+                "key": "pluralized",
+                "value": ["Singular", "Plural"],
+                "state": "20",
+            },
+            code=400,
+        )
+
     def test_delete(self):
         start_count = Translation.objects.count()
         self.do_request(