diff --git a/docs/formats/i18next.rst b/docs/formats/i18next.rst index 4fe2f886f74b..9edc768ca24c 100644 --- a/docs/formats/i18next.rst +++ b/docs/formats/i18next.rst @@ -41,7 +41,7 @@ with (what is most often the) English strings. Example file: -.. literalinclude:: ../../weblate/trans/tests/data/en.i18next.json +.. literalinclude:: ../../weblate/trans/tests/data/en.i18nextv4.json :language: json Weblate configuration diff --git a/docs/user/files.rst b/docs/user/files.rst index 6bbf15231845..79492ff1773c 100644 --- a/docs/user/files.rst +++ b/docs/user/files.rst @@ -37,9 +37,12 @@ are available via the :guilabel:`Files` ↓ :guilabel:`Customize download` menu: * Excel Open XML (``xlsx``) * JSON (only available for monolingual translations) (``json``) * JSON nested structure file (only available for monolingual translations) (``json-nested``) +* I18Next V4 file (``i18nextv4``) * Android String Resource (only available for monolingual translations) (``aresource``) * iOS strings (only available for monolingual translations) (``strings``) +For JSON based format, key separator set in component settings will be used. + .. hint:: The content available in the converted files differs based on file format @@ -83,7 +86,8 @@ Supported file formats Any file in a supported file format can be uploaded, but it is still recommended to use the same file format as the one used for translation, otherwise some -features might not be translated properly. +features might not be translated properly. For JSON based file formats, custom key separator +could be set in component settings or during setting up the component. .. seealso:: diff --git a/weblate/formats/auto.py b/weblate/formats/auto.py index f1e91b98e171..a833438c0847 100644 --- a/weblate/formats/auto.py +++ b/weblate/formats/auto.py @@ -15,11 +15,13 @@ from weblate.formats.helpers import NamedBytesIO from weblate.formats.models import FILE_FORMATS from weblate.formats.ttkit import TTKitFormat +from weblate.trans.wrappers import file_format_custom_key_separator_wrapper if TYPE_CHECKING: from collections.abc import Generator from weblate.formats.base import TranslationFormat + from weblate.trans.models.component import Component def detect_filename(filename: str) -> type[TranslationFormat] | None: @@ -81,13 +83,17 @@ def params_iter( def try_load( filename: str, content: bytes, - original_format: type[TranslationFormat] | None, + component: Component | None, template_store: TranslationFormat | None, is_template: bool = False, ) -> TranslationFormat: """Try to load file by guessing type.""" failure = None - for file_format in formats_iter(filename, original_format): + for file_format in formats_iter(filename, component.file_format_cls): + file_format = file_format_custom_key_separator_wrapper( + file_format, component.key_separator + ) + for kwargs, validate in params_iter(file_format, template_store, is_template): handle = NamedBytesIO(filename, content) try: diff --git a/weblate/formats/exporters.py b/weblate/formats/exporters.py index 828f7b4cb2c6..595a4531ae99 100644 --- a/weblate/formats/exporters.py +++ b/weblate/formats/exporters.py @@ -17,7 +17,11 @@ from translate.misc.multistring import multistring from translate.storage.aresource import AndroidResourceFile from translate.storage.csvl10n import csvfile -from translate.storage.jsonl10n import JsonFile, JsonNestedFile +from translate.storage.jsonl10n import ( + I18NextV4File, + JsonFile, + JsonNestedFile, +) from translate.storage.mo import mofile from translate.storage.po import pofile from translate.storage.poxliff import PoXliffFile @@ -460,6 +464,12 @@ class JSONNestedExporter(JSONExporter): storage_class = JsonNestedFile +class I18NextV4Exporter(JSONExporter): + name = "i18nextv4" + verbose = gettext_lazy("i18next v4 file") + storage_class = I18NextV4File + + class AndroidResourceExporter(XMLFilterMixin, MonolingualExporter): storage_class = AndroidResourceFile name = "aresource" diff --git a/weblate/formats/models.py b/weblate/formats/models.py index 102c41129f82..b880e5e1bb15 100644 --- a/weblate/formats/models.py +++ b/weblate/formats/models.py @@ -68,6 +68,7 @@ class FormatsConf(AppConf): "weblate.formats.exporters.JSONNestedExporter", "weblate.formats.exporters.AndroidResourceExporter", "weblate.formats.exporters.StringsExporter", + "weblate.formats.exporters.I18NextV4Exporter", ) FORMATS = ( diff --git a/weblate/formats/tests/test_exporters.py b/weblate/formats/tests/test_exporters.py index f70dcca6fa87..5c825e2ec353 100644 --- a/weblate/formats/tests/test_exporters.py +++ b/weblate/formats/tests/test_exporters.py @@ -7,6 +7,7 @@ AndroidResourceExporter, BaseExporter, CSVExporter, + I18NextV4Exporter, JSONExporter, JSONNestedExporter, MoExporter, @@ -28,6 +29,7 @@ Unit, ) from weblate.trans.tests.test_models import BaseTestCase +from weblate.trans.wrappers import exporter_custom_key_separator_wrapper from weblate.utils.state import STATE_EMPTY, STATE_TRANSLATED @@ -300,6 +302,26 @@ class JSONNestedExporterTest(JSONExporterTest): _class = JSONNestedExporter +class I18NextExporterTest(JSONExporterTest): + _class = I18NextV4Exporter + + def check_plurals(self, result) -> None: + self.assertIn(b"_one", result) + self.assertIn(b"_other", result) + + +class CustomKeySeparatorI18NextExporterTest(I18NextExporterTest): + _class = exporter_custom_key_separator_wrapper(I18NextExporterTest._class, ";") + + def test_default_key_separator_skip(self) -> None: + output = self.check_unit(context="bar.foo", target="any") + self.assertIn(b'"bar.foo"', output) + + def test_custom_key_separator(self) -> None: + output = self.check_unit(context="bar;foo", target="any") + self.assertNotIn(b'"bar"\n\t\t"foo"', output) + + class StringsExporterTest(PoExporterTest): _class = StringsExporter _has_comments = False diff --git a/weblate/formats/tests/test_formats.py b/weblate/formats/tests/test_formats.py index 791b993092e7..024a03a2728d 100644 --- a/weblate/formats/tests/test_formats.py +++ b/weblate/formats/tests/test_formats.py @@ -28,6 +28,7 @@ GoI18JSONFormat, GoI18V2JSONFormat, GWTFormat, + I18NextV4Format, INIFormat, InnoSetupINIFormat, JoomlaFormat, @@ -56,6 +57,7 @@ from weblate.lang.models import Language, Plural from weblate.trans.tests.test_views import FixtureTestCase from weblate.trans.tests.utils import TempDirMixin, get_test_file +from weblate.trans.wrappers import file_format_custom_key_separator_wrapper from weblate.utils.state import STATE_FUZZY, STATE_TRANSLATED TEST_PO = get_test_file("cs.po") @@ -68,6 +70,7 @@ TEST_GO18N_V2_JSON = get_test_file("cs-go18n-v2.json") TEST_NESTED_JSON = get_test_file("cs-nested.json") TEST_WEBEXT_JSON = get_test_file("cs-webext.json") +TEST_I18NEXTV4_JSON = get_test_file("en.i18nextv4.json") TEST_PHP = get_test_file("cs.php") TEST_LARAVEL = get_test_file("laravel.php") TEST_JOOMLA = get_test_file("cs.joomla.ini") @@ -535,6 +538,26 @@ class WebExtesionJSONFormatTest(JSONFormatTest): MONOLINGUAL = True +class I18NextV4JSONFormatTest(JSONFormatTest): + FORMAT = I18NextV4Format + FILE = TEST_I18NEXTV4_JSON + COUNT = 3 + MASK = "i18nextv4/*.json" + EXPECTED_PATH = "i18nextv4/cs-CZ.json" + FIND_CONTEXT = "hello.world" + FIND_MATCH = "Hello" + NEW_UNIT_MATCH = b'\n "key": "Source string"\n' + MONOLINGUAL = True + + +class CustomKeySeparatorI18NextV4JSONFormatTest(I18NextV4JSONFormatTest): + FORMAT = file_format_custom_key_separator_wrapper( + I18NextV4JSONFormatTest.FORMAT, ";" + ) + FIND_CONTEXT = "hello;world" + FIND_MATCH = "Hello" + + class GoI18NV1JSONFormatTest(JSONFormatTest): FORMAT = GoI18JSONFormat FILE = TEST_GO18N_V1_JSON diff --git a/weblate/formats/ttkit.py b/weblate/formats/ttkit.py index 105977eaf179..c219aaff19a5 100644 --- a/weblate/formats/ttkit.py +++ b/weblate/formats/ttkit.py @@ -45,6 +45,7 @@ ) from weblate.lang.data import FORMULA_WITH_ZERO, ZERO_PLURAL_TYPES from weblate.lang.models import Plural +from weblate.trans.defines import DEFAULT_KEY_SEPARATOR from weblate.trans.util import ( get_clean_env, get_string, @@ -927,10 +928,12 @@ def is_readonly(self) -> bool: class JSONUnit(MonolingualSimpleUnit): + KEY_SEPARATOR = DEFAULT_KEY_SEPARATOR + @cached_property def context(self): context = super().context - if context.startswith("."): + if context.startswith(self.KEY_SEPARATOR): return context[1:] return context diff --git a/weblate/trans/defines.py b/weblate/trans/defines.py index 7c6c9a7ec984..098cbb1d2478 100644 --- a/weblate/trans/defines.py +++ b/weblate/trans/defines.py @@ -34,3 +34,6 @@ # Maximal categories depth CATEGORY_DEPTH = 3 + +# Default key separator for component +DEFAULT_KEY_SEPARATOR = "." diff --git a/weblate/trans/discovery.py b/weblate/trans/discovery.py index bdfeb9fcd7b3..2d91f73e8919 100644 --- a/weblate/trans/discovery.py +++ b/weblate/trans/discovery.py @@ -44,6 +44,7 @@ "commit_pending_age", "edit_template", "manage_units", + "key_separator", "variant_regex", "category_id", ) diff --git a/weblate/trans/forms.py b/weblate/trans/forms.py index 5709449c4ec0..4c3367833d24 100644 --- a/weblate/trans/forms.py +++ b/weblate/trans/forms.py @@ -1492,6 +1492,7 @@ class Meta: "auto_lock_error", "links", "manage_units", + "key_separator", "is_glossary", "glossary_color", ) @@ -1551,6 +1552,7 @@ def __init__(self, request, *args, **kwargs) -> None: gettext("Translation settings"), "allow_translation_propagation", "manage_units", + "key_separator", "check_flags", "variant_regex", "enforced_checks", @@ -1674,6 +1676,7 @@ class Meta: "push_branch", "repoweb", "file_format", + "key_separator", "filemask", "template", "edit_template", @@ -1827,6 +1830,13 @@ class ComponentScratchCreateForm(ComponentProjectForm): ), ) + key_separator = forms.CharField( + label=Component.key_separator.field.verbose_name, + max_length=Component.key_separator.field.max_length, + help_text=Component.key_separator.field.help_text, + initial=Component.key_separator.field.default, + ) + def __init__(self, *args, **kwargs) -> None: kwargs["auto_id"] = "id_scratchcreate_%s" super().__init__(*args, **kwargs) diff --git a/weblate/trans/migrations/0015_component_key_separator.py b/weblate/trans/migrations/0015_component_key_separator.py new file mode 100644 index 000000000000..666a6aec7091 --- /dev/null +++ b/weblate/trans/migrations/0015_component_key_separator.py @@ -0,0 +1,26 @@ +# Copyright © Michal Karol +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Generated by Django 5.0.4 on 2024-04-05 10:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("trans", "0014_alter_component_repoweb_alter_project_web"), + ] + + operations = [ + migrations.AddField( + model_name="component", + name="key_separator", + field=models.CharField( + default=".", + help_text="Customize key separator for JSON based formats.", + max_length=1, + verbose_name="Key separator", + ), + ), + ] diff --git a/weblate/trans/models/component.py b/weblate/trans/models/component.py index 30a6945b2abe..e856d98882bd 100644 --- a/weblate/trans/models/component.py +++ b/weblate/trans/models/component.py @@ -38,6 +38,7 @@ from weblate.trans.defines import ( BRANCH_LENGTH, COMPONENT_NAME_LENGTH, + DEFAULT_KEY_SEPARATOR, FILENAME_LENGTH, PROJECT_NAME_LENGTH, REPO_LENGTH, @@ -72,6 +73,7 @@ validate_filemask, validate_language_code, ) +from weblate.trans.wrappers import file_format_custom_key_separator_wrapper from weblate.utils import messages from weblate.utils.celery import get_task_progress, is_task_ready from weblate.utils.colors import COLOR_CHOICES @@ -596,6 +598,12 @@ class Component(models.Model, PathMixin, CacheKeyMixin, ComponentCategoryMixin): "probably want to keep it disabled." ), ) + key_separator = models.CharField( + verbose_name=gettext_lazy("Key separator"), + max_length=1, + default=DEFAULT_KEY_SEPARATOR, + help_text=gettext_lazy("Customize key separator for JSON based formats."), + ) # VCS config merge_style = models.CharField( @@ -1013,6 +1021,7 @@ def create_glossary(self) -> None: has_template=False, allow_translation_propagation=False, license=self.license, + key_separator=self.key_separator, ) @cached_property @@ -3253,7 +3262,9 @@ def file_format_flags(self): def file_format_cls(self): """Return file format object.""" if self._file_format is None or self._file_format.name != self.file_format: - self._file_format = FILE_FORMATS[self.file_format] + self._file_format = file_format_custom_key_separator_wrapper( + FILE_FORMATS[self.file_format], self.key_separator + ) return self._file_format def has_template(self): diff --git a/weblate/trans/models/project.py b/weblate/trans/models/project.py index 587a407548dd..4700e96c35b5 100644 --- a/weblate/trans/models/project.py +++ b/weblate/trans/models/project.py @@ -25,7 +25,7 @@ from weblate.formats.models import FILE_FORMATS from weblate.lang.models import Language from weblate.memory.tasks import import_memory -from weblate.trans.defines import PROJECT_NAME_LENGTH +from weblate.trans.defines import DEFAULT_KEY_SEPARATOR, PROJECT_NAME_LENGTH from weblate.trans.mixins import CacheKeyMixin, PathMixin from weblate.utils.data import data_dir from weblate.utils.site import get_site_url @@ -552,6 +552,7 @@ def scratch_create_component( file_format: str, has_template: bool | None = None, is_glossary: bool = False, + key_separator: str = DEFAULT_KEY_SEPARATOR, **kwargs, ): format_cls = FILE_FORMATS[file_format] @@ -571,6 +572,7 @@ def scratch_create_component( "source_language": source_language, "manage_units": True, "is_glossary": is_glossary, + "key_separator": key_separator, } ) # Create component diff --git a/weblate/trans/models/translation.py b/weblate/trans/models/translation.py index c4fcbcaa3f13..12d9548b5c86 100644 --- a/weblate/trans/models/translation.py +++ b/weblate/trans/models/translation.py @@ -1243,7 +1243,7 @@ def handle_upload( # noqa: C901 template_store = try_load( fileobj.name, filecopy, - component.file_format_cls, + component, None, is_template=True, ) @@ -1256,7 +1256,7 @@ def handle_upload( # noqa: C901 store = try_load( fileobj.name, filecopy, - component.file_format_cls, + component, template_store, ) if isinstance(store, component.file_format_cls): diff --git a/weblate/trans/tests/data/en.i18next.json b/weblate/trans/tests/data/en.i18next.json deleted file mode 100644 index 34749b0448c6..000000000000 --- a/weblate/trans/tests/data/en.i18next.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "hello": "Hello", - "apple": "I have an apple", - "apple_plural": "I have {{count}} apples", - "apple_negative": "I have no apples" -} diff --git a/weblate/trans/tests/data/en.i18nextv4.json b/weblate/trans/tests/data/en.i18nextv4.json new file mode 100644 index 000000000000..0702c778b638 --- /dev/null +++ b/weblate/trans/tests/data/en.i18nextv4.json @@ -0,0 +1,10 @@ +{ + "main": { + "nested": "Test nested keys in i18next" + }, + "plural_one": "Testing plural one", + "plural_other": "Testing plural other", + "hello": { + "world": "Hello" + } +} diff --git a/weblate/trans/tests/test_categories.py b/weblate/trans/tests/test_categories.py index c4251efb3c12..452062d9dd1d 100644 --- a/weblate/trans/tests/test_categories.py +++ b/weblate/trans/tests/test_categories.py @@ -219,6 +219,7 @@ def test_create(self) -> None: "language_regex": "^[^.]+$", "source_language": get_default_lang(), "category": category.pk, + "key_separator": ".", }, ) self.assertEqual(response.status_code, 302) diff --git a/weblate/trans/tests/test_create.py b/weblate/trans/tests/test_create.py index 5504425ea412..bc6c48d24d66 100644 --- a/weblate/trans/tests/test_create.py +++ b/weblate/trans/tests/test_create.py @@ -103,6 +103,7 @@ def client_create_component(self, result, **kwargs): "new_lang": "add", "language_regex": "^[^.]+$", "source_language": get_default_lang(), + "key_separator": ".", } params.update(kwargs) with override_settings(CREATE_GLOSSARIES=self.CREATE_GLOSSARIES): @@ -383,6 +384,7 @@ def create(): "project": self.project.pk, "file_format": "po-mono", "source_language": get_default_lang(), + "key_separator": ".", }, follow=True, ) @@ -416,6 +418,7 @@ def test_create_scratch_android(self) -> None: "project": self.project.pk, "file_format": "aresource", "source_language": get_default_lang(), + "key_separator": ".", }, follow=True, ) @@ -437,6 +440,7 @@ def test_create_scratch_bilingual(self) -> None: "project": self.project.pk, "file_format": "po", "source_language": get_default_lang(), + "key_separator": ".", }, follow=True, ) @@ -458,6 +462,7 @@ def test_create_scratch_strings(self) -> None: "project": self.project.pk, "file_format": "strings", "source_language": get_default_lang(), + "key_separator": ".", }, follow=True, ) diff --git a/weblate/trans/tests/test_wrappers.py b/weblate/trans/tests/test_wrappers.py new file mode 100644 index 000000000000..bd643f13ded4 --- /dev/null +++ b/weblate/trans/tests/test_wrappers.py @@ -0,0 +1,98 @@ +# Copyright © Michal Karol +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Test for custom key separator wrapper.""" + +from unittest import TestCase + +from weblate.formats.exporters import ( + BaseExporter, + CSVExporter, + I18NextV4Exporter, + JSONExporter, + PoExporter, + XliffExporter, +) +from weblate.formats.ttkit import ( + CSVFormat, + GoI18JSONFormat, + I18NextFormat, + JSONFormat, + PoFormat, + TranslationFormat, + XliffFormat, +) +from weblate.trans.defines import DEFAULT_KEY_SEPARATOR +from weblate.trans.wrappers import ( + exporter_custom_key_separator_wrapper, + file_format_custom_key_separator_wrapper, +) + + +class CustomKeySeparatorWrappersTest(TestCase): + def test_file_format_custom_key_separator_wrapper_skips_non_json_classes(self): + custom_key_separator = ":" + for file_format_cls in [ + PoFormat, + XliffFormat, + TranslationFormat, + CSVFormat, + ]: + new_file_format_cls = file_format_custom_key_separator_wrapper( + file_format_cls, custom_key_separator + ) + self.assertEqual(file_format_cls, new_file_format_cls) + + def test_file_format_custom_key_separator_wrapper_applies_to_json_classes(self): + custom_key_separator = ":" + for file_format_cls in [ + JSONFormat, + I18NextFormat, + GoI18JSONFormat, + ]: + new_file_format_cls = file_format_custom_key_separator_wrapper( + file_format_cls, custom_key_separator + ) + self.assertNotEqual(file_format_cls, new_file_format_cls) + self.assertEqual( + file_format_cls.get_class().UnitClass.IdClass.KEY_SEPARATOR, + DEFAULT_KEY_SEPARATOR, + ) + self.assertEqual( + file_format_cls.unit_class.KEY_SEPARATOR, DEFAULT_KEY_SEPARATOR + ) + self.assertEqual( + new_file_format_cls.get_class().UnitClass.IdClass.KEY_SEPARATOR, + custom_key_separator, + ) + self.assertEqual( + new_file_format_cls.unit_class.KEY_SEPARATOR, custom_key_separator + ) + + def test_exporter_custom_key_separator_wrapper_skips_non_json_classes(self): + custom_key_separator = ":" + for exporter_cls in [BaseExporter, PoExporter, XliffExporter, CSVExporter]: + new_exporter_cls = exporter_custom_key_separator_wrapper( + exporter_cls, custom_key_separator + ) + self.assertEqual(exporter_cls, new_exporter_cls) + + def test_exporter_custom_key_separator_wrapper_applies_to_json_classes(self): + custom_key_separator = ":" + for exporter_cls in [ + JSONExporter, + I18NextV4Exporter, + ]: + new_exporter_cls = exporter_custom_key_separator_wrapper( + exporter_cls, custom_key_separator + ) + self.assertNotEqual(exporter_cls, new_exporter_cls) + self.assertEqual( + exporter_cls.storage_class.UnitClass.IdClass.KEY_SEPARATOR, + DEFAULT_KEY_SEPARATOR, + ) + self.assertEqual( + new_exporter_cls.storage_class.UnitClass.IdClass.KEY_SEPARATOR, + custom_key_separator, + ) diff --git a/weblate/trans/views/files.py b/weblate/trans/views/files.py index 561270d8fe82..ecfbbabf3995 100644 --- a/weblate/trans/views/files.py +++ b/weblate/trans/views/files.py @@ -20,6 +20,7 @@ Project, Translation, ) +from weblate.trans.wrappers import exporter_custom_key_separator_wrapper from weblate.utils import messages from weblate.utils.data import data_dir from weblate.utils.errors import report_error @@ -53,7 +54,10 @@ def download_multi(request, translations, commit_objs, fmt=None, name="translati raise Http404(f"Conversion to {fmt} is not supported") from exc for translation in translations: - exporter = exporter_cls(translation=translation) + translation_exporter_cls = exporter_custom_key_separator_wrapper( + exporter_cls, translation.component.key_separator + ) + exporter = translation_exporter_cls(translation=translation) filename = exporter.get_filename() if not exporter_cls.supports(translation): extra[f"{filename}.skipped"] = ( diff --git a/weblate/trans/wrappers.py b/weblate/trans/wrappers.py new file mode 100644 index 000000000000..229e8d931869 --- /dev/null +++ b/weblate/trans/wrappers.py @@ -0,0 +1,77 @@ +# Copyright © Michal Karol +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from translate.storage.base import DictStore + +from weblate.formats.base import TranslationFormat +from weblate.formats.exporters import BaseExporter, JSONExporter +from weblate.formats.ttkit import JSONFormat +from weblate.trans.defines import DEFAULT_KEY_SEPARATOR + + +def store_class_custom_key_separator_wrapper(store_cls: DictStore, key_separator: str): + """Create class deriving from store class with overridden key separator.""" + + class CustomKeySeparatorUnitIdClass(store_cls.UnitClass.IdClass): + """IdClass with overridden key separator.""" + + KEY_SEPARATOR = key_separator + + class CustomKeySeparatorStoreUnitClass(store_cls.UnitClass): + """UnitClass with overridden IdClass.""" + + IdClass = CustomKeySeparatorUnitIdClass + + class CustomKeySeparatorStoreClass(store_cls): + """StoreClass with overridden UnitClass.""" + + UnitClass = CustomKeySeparatorStoreUnitClass + + return CustomKeySeparatorStoreClass + + +def file_format_custom_key_separator_wrapper( + file_format_cls: type[TranslationFormat], key_separator: str +): + """Create class deriving from file format class with overridden key separator if file format class derives from JSONFormat.""" + if ( + not issubclass(file_format_cls, JSONFormat) + or key_separator == DEFAULT_KEY_SEPARATOR + ): + return file_format_cls + + class CustomKeySeparatorUnitClass(file_format_cls.unit_class): + """File format unit class with overridden custom key separator.""" + + KEY_SEPARATOR = key_separator + + class CustomKeySeparatorFileFormat(file_format_cls): + """File format class with overridden loader and unit class to a classes with overridden custom key separator.""" + + loader = store_class_custom_key_separator_wrapper( + file_format_cls.get_class(), key_separator + ) + unit_class = CustomKeySeparatorUnitClass + + return CustomKeySeparatorFileFormat + + +def exporter_custom_key_separator_wrapper( + exporter_cls: type[BaseExporter], key_separator: str +): + """Create class deriving from exporter class with overridden key separator if exporter class derives from JSONExporter.""" + if ( + not issubclass(exporter_cls, JSONExporter) + or key_separator == DEFAULT_KEY_SEPARATOR + ): + return exporter_cls + + class CustomKeyExporter(exporter_cls): + """Exporter class with overridden storage_class to a class with overridden custom key separator.""" + + storage_class = store_class_custom_key_separator_wrapper( + exporter_cls.storage_class, key_separator + ) + + return CustomKeyExporter diff --git a/weblate/utils/views.py b/weblate/utils/views.py index bae72a305e35..bc135131871f 100644 --- a/weblate/utils/views.py +++ b/weblate/utils/views.py @@ -32,6 +32,7 @@ from weblate.formats.models import EXPORTERS, FILE_FORMATS from weblate.lang.models import Language from weblate.trans.models import Category, Component, Project, Translation, Unit +from weblate.trans.wrappers import exporter_custom_key_separator_wrapper from weblate.utils import messages from weblate.utils.errors import report_error from weblate.utils.stats import BaseStats, CategoryLanguage, ProjectLanguage @@ -500,6 +501,10 @@ def download_translation_file( raise Http404(f"Conversion to {fmt} is not supported") from exc if not exporter_cls.supports(translation): raise Http404("File format is not compatible with this translation") + + exporter_cls = exporter_custom_key_separator_wrapper( + exporter_cls, translation.component.key_separator + ) exporter = exporter_cls(translation=translation) units = translation.unit_set.prefetch_full().order_by("position") if query_string: