Coverage for app/venv/lib/python3.14/site-packages/weblate/formats/ttkit.py: 54%
1382 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# weblate/formats/ttkit.py Copyright © Michal Čihař <michal@weblate.org>
2# Copyright © WofWca <wofwca@protonmail.com>
3#
4# SPDX-License-Identifier: GPL-3.0-or-later
6"""Translate Toolkit based file-format wrappers."""
8from __future__ import annotations
10import csv
11import importlib
12import inspect
13import os
14import re
15import subprocess
16from io import StringIO
17from typing import TYPE_CHECKING, Any, BinaryIO, ClassVar
19from django.core.exceptions import ValidationError
20from django.utils.functional import cached_property
21from django.utils.translation import gettext, gettext_lazy
22from lxml import etree
23from lxml.etree import XMLSyntaxError
24from pyparsing import ParseException
25from translate.misc import quote
26from translate.misc.multistring import multistring
27from translate.misc.xml_helpers import setXMLspace
28from translate.storage.base import TranslationStore
29from translate.storage.catkeys import CatkeysFile
30from translate.storage.csvl10n import csvunit
31from translate.storage.jsonl10n import BaseJsonUnit, JsonFile
32from translate.storage.lisa import LISAfile
33from translate.storage.poxliff import PoXliffFile
34from translate.storage.pypo import pofile, pounit
35from translate.storage.resx import RESXFile
36from translate.storage.tbx import tbxfile, tbxunit
37from translate.storage.ts2 import tsfile, tsunit
38from translate.storage.xliff import ID_SEPARATOR, xlifffile, xliffunit
40import weblate.utils.version
41from weblate.checks.flags import Flags
42from weblate.formats.base import (
43 BilingualUpdateMixin,
44 MissingTemplateError,
45 TranslationFormat,
46 TranslationUnit,
47 UpdateError,
48)
49from weblate.lang.data import FORMULA_WITH_ZERO, ZERO_PLURAL_TYPES
50from weblate.lang.models import Plural
51from weblate.trans.util import (
52 get_clean_env,
53 get_string,
54 rich_to_xliff_string,
55 xliff_string_to_rich,
56)
57from weblate.utils.errors import report_error
58from weblate.utils.files import cleanup_error_message
59from weblate.utils.state import (
60 STATE_APPROVED,
61 STATE_EMPTY,
62 STATE_FUZZY,
63 STATE_READONLY,
64 STATE_TRANSLATED,
65)
67if TYPE_CHECKING: 67 ↛ 68line 67 didn't jump to line 68 because the condition on line 67 was never true
68 from collections.abc import Callable
70 from translate.storage.base import TranslationUnit as TranslateToolkitUnit
72LOCATIONS_RE = re.compile(r"^([+-]|.*, [+-]|.*:[+-])")
73PO_DOCSTRING_LOCATION = re.compile(r":docstring of [a-zA-Z0-9._]+:[0-9]+")
74SUPPORTS_FUZZY = (pounit, tsunit, csvunit)
75XLIFF_FUZZY_STATES = {"new", "needs-translation", "needs-adaptation", "needs-l10n"}
78class TTKitUnit(TranslationUnit):
79 template: TranslateToolkitUnit | None
80 unit: TranslateToolkitUnit
81 mainunit: TranslateToolkitUnit
82 parent: TTKitFormat
84 @cached_property
85 def locations(self):
86 """Return a comma-separated list of locations."""
87 return ", ".join(self.mainunit.getlocations())
89 @cached_property
90 def source(self):
91 """Return source string from a Translate Toolkit unit."""
92 if self.template is not None: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true
93 return get_string(self.template.target)
94 return get_string(self.unit.source)
96 @cached_property
97 def target(self):
98 """Return target string from a Translate Toolkit unit."""
99 if self.unit is None: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true
100 if self.parent.is_template and self.template is not None:
101 return get_string(self.template.target)
102 return ""
103 return get_string(self.unit.target)
105 @cached_property
106 def context(self):
107 """
108 Return context of message.
110 In some cases we have to use ID here to make all the back-ends consistent.
111 """
112 return self.mainunit.getcontext()
114 @cached_property
115 def notes(self):
116 """Return notes or notes from units."""
117 comment = ""
119 if self.unit is not None: 119 ↛ 122line 119 didn't jump to line 122 because the condition on line 119 was always true
120 comment = self.unit.getnotes()
122 if self.template is not None: 122 ↛ 124line 122 didn't jump to line 124 because the condition on line 122 was never true
123 # Avoid duplication in case template has same notes
124 template_comment = self.template.getnotes()
125 if template_comment != comment:
126 comment = f"{template_comment}\n{comment}"
128 return comment
130 def is_translated(self):
131 """Check whether unit is translated."""
132 if self.unit is None: 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true
133 return False
134 return self.unit.istranslated()
136 def is_fuzzy(self, fallback=False):
137 """Check whether unit needs editing."""
138 if self.unit is None: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 return fallback
140 # Most of the formats do not support this, but they
141 # happily return False
142 if isinstance(self.unit, SUPPORTS_FUZZY): 142 ↛ 144line 142 didn't jump to line 144 because the condition on line 142 was always true
143 return self.has_translation() and self.unit.isfuzzy()
144 return fallback
146 def has_content(self) -> bool:
147 """Check whether unit has content."""
148 return (
149 not self.mainunit.isheader()
150 and not self.mainunit.isblank()
151 and not self.mainunit.isobsolete()
152 )
154 def is_readonly(self) -> bool:
155 return not self.mainunit.istranslatable()
157 def set_target(self, target: str | list[str]) -> None:
158 """Set translation unit target."""
159 self._invalidate_target()
160 if isinstance(target, list): 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true
161 target = multistring(target)
162 self.unit.target = target
164 def set_state(self, state) -> None:
165 """Set fuzzy /approved flag on translated unit."""
166 if "flags" in self.__dict__: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true
167 del self.__dict__["flags"]
168 self.unit.markfuzzy(state == STATE_FUZZY)
169 if hasattr(self.unit, "markapproved"): 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true
170 self.unit.markapproved(state == STATE_APPROVED)
172 @cached_property
173 def flags(self):
174 """
175 Return flags from unit.
177 We currently extract maxwidth attribute.
178 """
179 flags = super().flags
180 if hasattr(self.unit, "xmlelement"): 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true
181 flags.merge(self.unit.xmlelement)
182 if self.template is not None and hasattr(self.template, "xmlelement"): 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true
183 flags.merge(self.template.xmlelement)
184 return flags
186 def clone_template(self) -> None:
187 super().clone_template()
189 # do not copy notes from the template (#11133)
190 self.unit.removenotes()
192 def untranslate(self, language) -> None:
193 target: str | list[str]
194 target = [""] * language.plural.number if self.mainunit.hasplural() else ""
195 self.set_target(target)
196 self.set_state(STATE_EMPTY)
199class KeyValueUnit(TTKitUnit):
200 @cached_property
201 def source(self):
202 """Return source string from a Translate Toolkit unit."""
203 if self.template is not None:
204 return get_string(self.template.source)
205 if self.unit is None:
206 raise MissingTemplateError
207 return get_string(self.unit.name)
209 @cached_property
210 def target(self):
211 """Return target string from a Translate Toolkit unit."""
212 if self.unit is None:
213 return ""
214 return get_string(self.unit.source)
216 @cached_property
217 def context(self):
218 """
219 Return context of message.
221 In some cases we have to use ID here to make all the back-ends consistent.
222 """
223 context = super().context
224 if not context:
225 return self.mainunit.getid()
226 return context
228 def is_translated(self):
229 """Check whether unit is translated."""
230 if self.unit is None:
231 return False
232 # The hasattr check here is needed for merged storages
233 # where template is different kind than translations
234 if hasattr(self.unit, "value"):
235 return not self.unit.isfuzzy() and self.unit.value
236 return self.unit.istranslated()
238 def set_target(self, target: str | list[str]) -> None:
239 """Set translation unit target."""
240 super().set_target(target)
241 # Propagate to value so that searializing of empty values works correctly
242 if not target:
243 self.unit.value = self.unit.target
246class TTKitFormat(TranslationFormat):
247 unit_class = TTKitUnit
248 loader = ("", "")
249 set_context_bilingual = True
250 # Use settarget/setsource to set language as well
251 use_settarget = False
252 force_encoding: str | None = None
253 plural_preference: tuple[int, ...] | None = (
254 Plural.SOURCE_CLDR,
255 Plural.SOURCE_DEFAULT,
256 )
257 units: list[TranslateToolkitUnit]
258 store: TranslationStore
260 @staticmethod
261 def serialize(store):
262 """Serialize given Translate Toolkit store."""
263 return bytes(store)
265 def fixup(self, store) -> None:
266 """Perform optional fixups on store."""
267 if self.force_encoding is not None:
268 store.encoding = self.force_encoding
269 # This gets already native language code, so no conversion is needed
270 if self.language_code is not None:
271 store.settargetlanguage(self.language_code)
272 if self.source_language is not None:
273 store.setsourcelanguage(self.source_language)
275 def load(
276 self,
277 storefile: str | BinaryIO,
278 template_store: TranslationFormat | None,
279 ) -> TranslationStore:
280 """Load file using defined loader."""
281 from weblate.trans.file_format_params import get_params_for_file_format
283 if isinstance(storefile, TranslationStore): 283 ↛ 285line 283 didn't jump to line 285 because the condition on line 283 was never true
284 # Used by XLSX writer
285 store = storefile
286 else:
287 store = self.parse_store(storefile)
289 for format_param_class in get_params_for_file_format(self.format_id):
290 format_param_class().setup_store(store, **self.file_format_params)
291 return store
293 @classmethod
294 def get_class(cls) -> TranslationStore:
295 """Return class for handling this module."""
296 # Direct class
297 if inspect.isclass(cls.loader):
298 return cls.loader
299 # Tuple style loader, import from translate toolkit
300 module_name, class_name = cls.loader
301 if "." not in module_name: 301 ↛ 303line 301 didn't jump to line 303 because the condition on line 301 was always true
302 module_name = f"translate.storage.{module_name}"
303 module = importlib.import_module(module_name)
305 # Get the class
306 return getattr(module, class_name)
308 def get_format_class_kwargs(self) -> dict[str, Any]:
309 return {}
311 def get_unit_class_kwargs(self) -> dict[str, Any]:
312 return {}
314 def get_store_instance(self, **kwargs):
315 kwargs.update(self.get_format_class_kwargs())
316 store = self.get_class()(**kwargs)
318 # Apply possible fixups
319 self.fixup(store)
321 return store
323 def parse_store(self, storefile, **kwargs):
324 """Parse the store."""
325 store = self.get_store_instance(**kwargs)
327 # Read the content
328 if isinstance(storefile, str): 328 ↛ 332line 328 didn't jump to line 332 because the condition on line 328 was always true
329 with open(storefile, "rb") as handle:
330 content = handle.read()
331 else:
332 content = storefile.read()
334 # Parse the content
335 store.parse(content)
337 return store
339 def add_unit(self, unit: TranslationUnit) -> None:
340 """Add new unit to underlying store."""
341 if isinstance(self.store, LISAfile):
342 # LISA based stores need to know this
343 self.store.addunit(unit.unit, new=True)
344 else:
345 self.store.addunit(unit.unit)
347 def save_content(self, handle) -> None:
348 """Store content to file."""
349 self.store.serialize(handle)
351 def save(self) -> None:
352 """Save underlying store to disk."""
353 self.save_atomic(self.storefile, self.save_content)
355 @classmethod
356 def mimetype(cls):
357 """Return most common media type for format."""
358 return cls.get_class().Mimetypes[0]
360 @classmethod
361 def extension(cls):
362 """Return most common file extension for format."""
363 return cls.get_class().Extensions[0]
365 def is_valid(self) -> bool:
366 """
367 Check whether store seems to be valid.
369 In some cases Translate Toolkit happily "parses" the file, even though it really
370 did not do so (e.g. gettext parser on a random textfile).
371 """
372 if not super().is_valid(): 372 ↛ 373line 372 didn't jump to line 373 because the condition on line 372 was never true
373 return False
374 return self.store is not None
376 def construct_unit(self, source: str):
377 if self.use_settarget and self.source_language:
378 # Setting source on LISAunit will make it use default language
379 unit = self.store.UnitClass(None, **self.get_unit_class_kwargs())
380 unit.setsource(source, self.source_language)
381 elif hasattr(self.store, "wrapper"):
382 # gettext PO
383 unit = self.store.UnitClass(
384 source, wrapper=self.store.wrapper, **self.get_unit_class_kwargs()
385 )
386 else:
387 unit = self.store.UnitClass(source, **self.get_unit_class_kwargs())
388 # Needed by some formats (Android) to set target
389 unit._store = self.store # noqa: SLF001
390 return unit
392 def create_unit_key(
393 self, key: str, source: str | list[str] | multistring
394 ) -> str | multistring:
395 return key
397 def create_unit(
398 self,
399 key: str,
400 source: str | list[str],
401 target: str | list[str] | None = None,
402 ):
403 # Make sure target is a string
404 if target is None:
405 target = ""
406 # Process source
407 if isinstance(source, list):
408 context = source[0]
409 # Single string passed plain or multistring
410 source = context if len(source) == 1 else multistring(source)
411 else:
412 # This is string
413 context = source
415 # Process target
416 if isinstance(target, list):
417 target = target[0] if len(target) == 1 else multistring(target)
419 # Build the unit
420 unit = self.construct_unit(context)
422 # Monolingual translation
423 if self.is_template or self.template_store:
424 unit.setid(key)
425 if isinstance(unit, csvunit):
426 unit.setcontext(key)
427 target = source
428 source = self.create_unit_key(key, source)
429 # Bilingual translation
430 elif isinstance(unit, tbxunit | xliffunit) and key:
431 unit.setid(key)
432 elif self.set_context_bilingual and key:
433 unit.setcontext(key)
434 elif isinstance(unit, BaseJsonUnit):
435 unit.setid(context)
437 if self.use_settarget and self.source_language:
438 unit.setsource(source, self.source_language)
439 else:
440 unit.source = source
442 if self.use_settarget and self.language_code:
443 unit.settarget(target, self.language_code)
444 else:
445 unit.target = target
447 return unit
449 def untranslate_store(self, language) -> None:
450 """Remove translations from Translate Toolkit store."""
451 self.store.settargetlanguage(self.get_language_code(language.code))
453 for unit in self.content_units:
454 if unit.is_translated():
455 unit.untranslate(language)
457 @classmethod
458 def get_new_file_content(cls):
459 result = cls.new_translation
460 if isinstance(result, str): 460 ↛ 462line 460 didn't jump to line 462 because the condition on line 460 was always true
461 result = result.encode()
462 return result
464 @classmethod
465 def create_new_file(
466 cls,
467 filename: str,
468 language: str,
469 base: str,
470 callback: Callable | None = None,
471 file_format_params: dict[str, Any] | None = None,
472 ) -> None:
473 """Handle creation of new translation file."""
474 if base: 474 ↛ 476line 474 didn't jump to line 476 because the condition on line 474 was never true
475 # Parse file
476 store = cls(base, file_format_params=file_format_params)
477 if callback:
478 callback(store)
479 store.untranslate_store(language)
480 store.store.savefile(filename)
481 elif cls.new_translation is not None: 481 ↛ 485line 481 didn't jump to line 485 because the condition on line 481 was always true
482 with open(filename, "wb") as output:
483 output.write(cls.get_new_file_content())
484 else:
485 msg = "Not supported"
486 raise ValueError(msg)
488 @classmethod
489 def is_valid_base_for_new(
490 cls,
491 base: str,
492 monolingual: bool,
493 errors: list[Exception] | None = None,
494 fast: bool = False,
495 file_format_params: dict[str, Any] | None = None,
496 ) -> bool:
497 """Check whether base is valid."""
498 if not base:
499 if cls.create_empty_bilingual:
500 return True
501 return monolingual and cls.new_translation is not None
502 try:
503 if not fast: 503 ↛ 510line 503 didn't jump to line 510 because the condition on line 503 was always true
504 cls(base, file_format_params=file_format_params)
505 except Exception as exception:
506 if errors is not None:
507 errors.append(exception)
508 report_error("File-parsing error")
509 return False
510 return os.path.exists(base)
512 @property
513 def all_store_units(self):
514 """Wrapper for all store unit filtering out obsolete."""
515 return (
516 unit
517 for unit in self.store.units
518 if not unit.isobsolete() and not unit.isheader()
519 )
521 def delete_unit(self, ttkit_unit) -> str | None:
522 self.store.removeunit(ttkit_unit)
523 return None
526class PropertiesUnit(KeyValueUnit):
527 """Wrapper for properties-based units."""
529 @cached_property
530 def locations(self) -> str:
531 """Return a comma-separated list of locations."""
532 return ""
534 @cached_property
535 def source(self):
536 """Return source string from a Translate Toolkit unit."""
537 if self.template is not None:
538 return get_string(self.template.source)
539 # Need to decode property encoded string
540 return get_string(quote.propertiesdecode(self.unit.name))
542 @cached_property
543 def target(self):
544 """Return target string from a Translate Toolkit unit."""
545 if self.unit is None:
546 return ""
547 return get_string(self.unit.target or self.unit.source)
550class PoUnit(TTKitUnit):
551 """Wrapper for gettext PO unit."""
553 def set_state(self, state) -> None:
554 """Set fuzzy /approved flag on translated unit."""
555 super().set_state(state)
556 if state != STATE_FUZZY: 556 ↛ exitline 556 didn't return from function 'set_state' because the condition on line 556 was always true
557 self.unit.prev_msgid = []
558 self.unit.prev_msgid_plural = []
559 self.unit.prev_msgctxt = []
561 @cached_property
562 def flags(self):
563 """Return flags or typecomments from units."""
564 flags = super().flags
565 try:
566 flags.merge(Flags(*self.mainunit.typecomments))
567 except ParseException as error:
568 msg = f"Could not parse flags: {self.mainunit.typecomments!r}: {error}"
569 raise ValueError(msg) from error
570 flags.remove({"fuzzy"})
571 return flags
573 @cached_property
574 def previous_source(self):
575 """Return previous message source if there was any."""
576 if not self.is_fuzzy(): 576 ↛ 578line 576 didn't jump to line 578 because the condition on line 576 was always true
577 return ""
578 return get_string(self.unit.prev_source)
580 @cached_property
581 def locations(self):
582 """
583 Return comma separated list of locations.
585 Here we clean up Sphinx-generated "docstring of ..." part.
586 """
587 locations = " ".join(self.mainunit.getlocations())
588 locations = PO_DOCSTRING_LOCATION.sub("", locations)
589 return ", ".join(locations.split())
592class PoMonoUnit(PoUnit):
593 @cached_property
594 def context(self):
595 """
596 Return context of message.
598 In some cases we have to use ID here to make all the backends consistent.
599 """
600 # Monolingual PO files
601 if self.template is not None:
602 context = self.template.getcontext()
603 source = self.template.source
604 if source and context:
605 return f"{context}.{source}"
606 return source or context
607 return super().context
609 @cached_property
610 def notes(self):
611 result = []
612 notes = super().notes
613 if notes:
614 result.append(notes)
615 # Use unit context as note only in case source is present, otherwise
616 # it is used as a context (see above)
617 if self.template is not None and self.template.source:
618 context = self.template.getcontext()
619 if context:
620 result.append(context)
621 return "\n".join(result)
623 def set_target(self, target: str | list[str]) -> None:
624 """Set translation unit target."""
625 # Add blank msgid_plural to store plural
626 if isinstance(target, list | multistring) and not self.unit.hasplural():
627 self.unit.msgid_plural = ['""']
629 super().set_target(target)
632class XliffUnit(TTKitUnit):
633 """
634 Wrapper unit for XLIFF.
636 XLIFF is special in Translate Toolkit — it uses locations for what
637 is context in other formats.
638 """
640 @staticmethod
641 def get_unit_node(unit, element: str = "target"):
642 if unit is not None:
643 return unit.xmlelement.find(unit.namespaced(element))
644 return None
646 def get_xliff_units(self):
647 # Iterate over poxliff sub-units, or main unit
648 return getattr(self.unit, "units", [self.unit])
650 def get_xliff_nodes(self):
651 return (self.get_unit_node(unit) for unit in self.get_xliff_units())
653 def get_xliff_states(self) -> set[str]:
654 result = set()
655 for node in self.get_xliff_nodes():
656 if node is None:
657 continue
658 state = node.get("state", None)
659 if state is not None:
660 result.add(state)
661 return result
663 @cached_property
664 def context(self):
665 """
666 Return context of message.
668 Use resname if available as it usually is more interesting for the translator
669 than ID.
670 """
671 resname = self.mainunit.xmlelement.get("resname")
672 if resname:
673 return resname
674 return self.mainunit.getid().replace(ID_SEPARATOR, "///")
676 def is_translated(self):
677 """
678 Check whether unit is translated.
680 We replace Translate Toolkit logic here as the isfuzzy is pretty much wrong
681 there, see is_fuzzy docs.
682 """
683 return self.has_translation()
685 def is_fuzzy(self, fallback: bool = False) -> bool:
686 """
687 Check whether unit needs edit.
689 The isfuzzy on XLIFF is really messing up the "approved" flag with "fuzzy"
690 flag, leading to various problems.
692 That's why we handle it on our own.
693 """
694 return self.has_translation() and bool(
695 XLIFF_FUZZY_STATES.intersection(self.get_xliff_states())
696 )
698 def set_state(self, state) -> None:
699 """Set fuzzy /approved flag on translated unit."""
700 self.unit.markapproved(state == STATE_APPROVED)
701 target_state = None
702 if state == STATE_FUZZY:
703 # Always set state for fuzzy
704 target_state = "needs-translation"
705 elif state == STATE_TRANSLATED:
706 # Always set state for translated
707 target_state = "translated"
708 elif state == STATE_APPROVED:
709 target_state = "final"
710 elif self.get_xliff_states():
711 # Only update state if it exists
712 target_state = "new"
714 if target_state:
715 for xliff_node in self.get_xliff_nodes():
716 if xliff_node is not None:
717 xliff_node.set("state", target_state)
719 def is_approved(self, fallback=False):
720 """Check whether unit is approved."""
721 if self.unit is None:
722 return fallback
723 if hasattr(self.unit, "isapproved"):
724 return self.unit.isapproved()
725 return fallback
727 def has_content(self):
728 """
729 Check whether unit has content.
731 For some reason, blank string does not mean non-translatable unit in XLIFF, so
732 lets skip those as well.
733 """
734 return (
735 not self.mainunit.isheader()
736 and bool(self.source)
737 and not self.mainunit.isobsolete()
738 )
740 def untranslate(self, language) -> None:
741 super().untranslate(language)
742 # Delete empty <target/> tag
743 for xmlnode in self.get_xliff_nodes():
744 if xmlnode is not None:
745 xmlnode.getparent().remove(xmlnode)
747 def set_target(self, target: str | list[str]) -> None:
748 """Set translation unit target."""
749 if self.get_unit_node(self.unit, "source") is None:
750 # Make sure source element is present, otherwise it breaks
751 # translate-toolkit expectations.
752 self.unit.set_source_dom(
753 self.unit.createlanguageNode(self.parent.source_language, "", "source")
754 )
756 self._invalidate_target()
757 if isinstance(target, list):
758 target = multistring(target)
759 if self.template is not None:
760 if self.parent.is_template:
761 # Use source for monolingual files if editing template
762 self.unit.source = target
763 elif self.unit.source:
764 # Update source to match current source
765 self.unit.source = self.template.source
766 # Always set target, even in monolingual template
767 self.unit.target = target
769 @cached_property
770 def source(self):
771 """Return source string from a Translate Toolkit unit."""
772 if self.template is not None:
773 # Use target if set, otherwise fall back to source
774 if self.template.target:
775 return get_string(self.template.target)
776 return get_string(self.template.source)
777 return get_string(self.unit.source)
779 @cached_property
780 def target(self):
781 """Return target string from a Translate Toolkit unit."""
782 if self.unit is None:
783 return ""
785 # Use source for monolingual base if target is not set
786 if self.unit.target is None:
787 if self.parent.is_template or isinstance(self.unit, csvunit):
788 return get_string(self.unit.source)
789 return ""
791 return get_string(self.unit.target)
794class RichXliffUnit(XliffUnit):
795 """Wrapper unit for XLIFF with XML elements."""
797 @cached_property
798 def source(self):
799 """Return source string from a Translate Toolkit unit."""
800 if self.template is not None:
801 # Use target if set, otherwise fall back to source
802 if self.template.target:
803 return rich_to_xliff_string(self.template.rich_target)
804 return rich_to_xliff_string(self.template.rich_source)
805 return rich_to_xliff_string(self.unit.rich_source)
807 @cached_property
808 def target(self):
809 """Return target string from a Translate Toolkit unit."""
810 if self.unit is None:
811 return ""
813 # Use source for monolingual base if target is not set
814 if self.unit.target is None:
815 if self.parent.is_template:
816 return rich_to_xliff_string(self.unit.rich_source)
817 return ""
819 return rich_to_xliff_string(self.unit.rich_target)
821 @cached_property
822 def flags(self):
823 flags = super().flags
824 flags.merge("xml-text")
825 return flags
827 def set_target(self, target: str | list[str]) -> None:
828 """Set translation unit target."""
829 self._invalidate_target()
830 # Delete the empty target element
831 if not target:
832 for xmlnode in self.get_xliff_nodes():
833 if xmlnode is not None:
834 xmlnode.getparent().remove(xmlnode)
835 return
836 try:
837 converted = xliff_string_to_rich(target)
838 except (XMLSyntaxError, TypeError, KeyError):
839 # KeyError happens on missing attribute
840 converted = [target]
841 if self.template is not None:
842 if self.parent.is_template:
843 # Use source for monolingual files if editing template
844 self.unit.rich_source = converted
845 elif self.unit.source:
846 # Update source to match current source
847 self.unit.rich_source = self.template.rich_source
848 # Always set target, even in monolingual template
849 self.unit.rich_target = converted
852class FlatXMLUnit(TTKitUnit):
853 @cached_property
854 def context(self):
855 if self.template is not None:
856 return self.template.source
857 return self.mainunit.source
859 @cached_property
860 def source(self):
861 return get_string(self.mainunit.target)
863 def has_content(self):
864 """
865 Check whether unit has content.
867 The attribute-less units will have context None.
868 """
869 if self.context is None:
870 return False
871 return super().has_content()
874class MonolingualIDUnit(TTKitUnit):
875 @cached_property
876 def context(self):
877 if self.template is not None:
878 return self.template.getid()
879 return self.mainunit.getcontext()
882class TSUnit(MonolingualIDUnit):
883 @cached_property
884 def source(self):
885 if self.template is None and self.mainunit.hasplural():
886 # Need to apply special magic for plurals here
887 # as there is no singular/plural in the source string
888 source = self.unit.source
889 return get_string([source.replace("(s)", ""), source.replace("(s)", "s")])
890 return super().source
892 @cached_property
893 def locations(self):
894 """Return a comma-separated list of locations."""
895 result = super().locations
896 # Do not try to handle relative locations in Qt TS, see
897 # https://doc.qt.io/qt-6/linguist-ts-file-format.html
898 if LOCATIONS_RE.match(result):
899 return ""
900 return result
902 @cached_property
903 def target(self):
904 """Return target string from a Translate Toolkit unit."""
905 if self.unit is None:
906 return ""
907 if not self.unit.isreview() and not self.unit.istranslated():
908 # For Qt ts, empty translated string means source should be used
909 return self.source
910 return super().target
912 def is_translated(self):
913 """Check whether unit is translated."""
914 if self.unit is None:
915 return False
916 # For Qt ts, empty translated string means source should be used
917 return not self.unit.isreview() or self.unit.istranslated()
919 def set_state(self, state) -> None:
920 """Set fuzzy /approved flag on translated unit."""
921 super().set_state(state)
922 if state == STATE_EMPTY:
923 # We need to mark all units as fuzzy to get
924 # type="unfinished" on empty strings, which are otherwise
925 # treated as translated same as source
926 self.unit.markfuzzy(True)
929class MonolingualSimpleUnit(MonolingualIDUnit):
930 @cached_property
931 def locations(self) -> str:
932 return ""
934 @cached_property
935 def source(self):
936 if self.template is None:
937 return self.mainunit.getid().lstrip(".")
938 return get_string(self.template.target)
940 def has_content(self) -> bool:
941 return not self.mainunit.isheader()
943 def is_readonly(self) -> bool:
944 return False
947class JSONUnit(MonolingualSimpleUnit):
948 @cached_property
949 def context(self):
950 context = super().context
951 if context.startswith("."):
952 return context[1:]
953 return context
956class PlaceholdersJSONUnit(JSONUnit):
957 @cached_property
958 def flags(self):
959 flags = super().flags
960 placeholders = self.mainunit.placeholders
961 if not placeholders:
962 return flags
964 if isinstance(placeholders, list):
965 # golang placeholders
966 placeholder_ids = [f"{{{p['id']}}}" for p in placeholders]
967 else:
968 # WebExtension placeholders
969 placeholder_ids = [f"${key.upper()}$" for key in placeholders]
970 flags.merge("case-insensitive")
971 flags.merge(f"placeholders:{Flags.format_flag(placeholder_ids)}")
972 return flags
975class CSVUnit(MonolingualSimpleUnit):
976 @staticmethod
977 def unescape_csv(string):
978 r"""
979 Remove Excel-specific escaping from CSV.
981 See weblate.formats.exporters.CSVExporter.string_filter
983 Note: | is replaced by \ in the condition as it is escaped
984 """
985 if ( 985 ↛ 991line 985 didn't jump to line 991 because the condition on line 985 was never true
986 len(string) > 2
987 and string[0] == "'"
988 and string[-1] == "'"
989 and string[1] in {"=", "+", "-", "@", "\\", "%"}
990 ):
991 return get_string(string[1:-1].replace("\\|", "|"))
992 return get_string(string)
994 @cached_property
995 def context(self):
996 def get_context(unit):
997 if unit.id:
998 return unit.id
999 if unit.context:
1000 return unit.context
1001 return unit.getid()
1003 # Needed to avoid Translate Toolkit construct ID
1004 # as context\04source
1005 if self.template is not None: 1005 ↛ 1006line 1005 didn't jump to line 1006 because the condition on line 1005 was never true
1006 return get_context(self.template)
1007 if self.parent.is_template: 1007 ↛ 1008line 1007 didn't jump to line 1008 because the condition on line 1007 was never true
1008 return get_context(self.unit)
1009 return self.unescape_csv(self.mainunit.getcontext())
1011 @cached_property
1012 def locations(self):
1013 return self.mainunit.location
1015 @cached_property
1016 def source(self):
1017 # Needed to avoid Translate Toolkit construct ID
1018 # as context\04source
1019 if self.template is None: 1019 ↛ 1021line 1019 didn't jump to line 1021 because the condition on line 1019 was always true
1020 return self.unescape_csv(get_string(self.mainunit.source))
1021 return self.unescape_csv(super().source)
1023 @cached_property
1024 def target(self):
1025 if ( 1025 ↛ 1032line 1025 didn't jump to line 1032 because the condition on line 1025 was never true
1026 not self.parent.is_template
1027 and self.template is not None
1028 and self.unit is not None
1029 and "target" not in self.parent.store.fieldnames
1030 ):
1031 # Use source if target is not stored
1032 target = get_string(self.unit.source)
1033 else:
1034 target = super().target
1035 return self.unescape_csv(target)
1037 def set_target(self, target: str | list[str]) -> None:
1038 super().set_target(target)
1039 if ( 1039 ↛ 1045line 1039 didn't jump to line 1045 because the condition on line 1039 was never true
1040 self.template is not None
1041 and not self.parent.is_template
1042 and "target" not in self.parent.store.fieldnames
1043 ):
1044 # Update source for bilingual as CSV fields can contain just source
1045 self.unit.source = self.unit.target
1047 def is_fuzzy(self, fallback=False):
1048 # Report fuzzy state only if present in the fields
1049 if "fuzzy" not in self.parent.store.fieldnames: 1049 ↛ 1051line 1049 didn't jump to line 1051 because the condition on line 1049 was always true
1050 return fallback
1051 return super().is_fuzzy()
1054class RESXUnit(TTKitUnit):
1055 @cached_property
1056 def locations(self) -> str:
1057 return ""
1059 @cached_property
1060 def context(self):
1061 if self.template is not None:
1062 return self.template.getid()
1063 return self.unit.getid()
1065 @cached_property
1066 def source(self):
1067 if self.template is None:
1068 return self.mainunit.getid()
1069 return get_string(self.template.target)
1072class PHPUnit(KeyValueUnit):
1073 @cached_property
1074 def locations(self) -> str:
1075 return ""
1077 @cached_property
1078 def source(self):
1079 if self.template is not None:
1080 return get_string(self.template.source)
1081 return get_string(self.unit.getid())
1083 @cached_property
1084 def target(self):
1085 if self.unit is None:
1086 return ""
1087 return get_string(self.unit.source)
1090class INIUnit(TTKitUnit):
1091 @cached_property
1092 def locations(self) -> str:
1093 return ""
1095 @cached_property
1096 def context(self):
1097 if self.template is not None:
1098 return self.template.location
1099 return self.unit.location
1101 def has_content(self) -> bool:
1102 return True
1104 def is_readonly(self) -> bool:
1105 return False
1108class AndroidUnit(MonolingualIDUnit):
1109 """Wrapper unit for Android Resource."""
1111 def set_state(self, state) -> None:
1112 """Tag unit as translatable/readonly aside from fuzzy and approved flags."""
1113 super().set_state(state)
1114 if state == STATE_READONLY:
1115 self.unit.marktranslatable(False)
1118class BasePoFormat(TTKitFormat):
1119 loader = pofile
1120 plural_preference = None
1121 supports_plural: bool = True
1122 store: pofile
1124 @classmethod
1125 def get_plural(cls, language, store=None):
1126 """Return matching plural object."""
1127 # Fallback will trigger KeyError later
1128 header = store.store.parseheader() if store else {}
1130 try:
1131 number, formula = Plural.parse_plural_forms(header["Plural-Forms"])
1132 except (ValueError, KeyError):
1133 return super().get_plural(language, store)
1135 # Find matching one
1136 for plural in language.plural_set.iterator(): 1136 ↛ 1141line 1136 didn't jump to line 1141 because the loop on line 1136 didn't complete
1137 if plural.same_plural(number, formula): 1137 ↛ 1136line 1137 didn't jump to line 1136 because the condition on line 1137 was always true
1138 return plural
1140 # Create new one
1141 return Plural.objects.create(
1142 language=language,
1143 source=Plural.SOURCE_GETTEXT,
1144 number=number,
1145 formula=formula,
1146 )
1148 def untranslate_store(self, language) -> None:
1149 """Remove translations from Translate Toolkit store."""
1150 super().untranslate_store(language)
1151 plural = language.plural
1153 self.store.updateheader(
1154 add=True,
1155 last_translator="Automatically generated",
1156 plural_forms=plural.plural_form,
1157 language_team="none",
1158 )
1160 def update_header(self, **kwargs) -> None:
1161 """Update store header if available."""
1162 kwargs["x_generator"] = f"Weblate {weblate.utils.version.VERSION}"
1164 # Adjust Content-Type header if needed
1165 header = self.store.parseheader()
1166 if ( 1166 ↛ 1171line 1166 didn't jump to line 1171 because the condition on line 1166 was never true
1167 "Content-Type" not in header
1168 or "charset=CHARSET" in header["Content-Type"]
1169 or "charset=ASCII" in header["Content-Type"]
1170 ):
1171 kwargs["Content_Type"] = "text/plain; charset=UTF-8"
1173 self.store.updateheader(add=True, **kwargs)
1175 def add_unit(self, unit: TranslationUnit) -> None:
1176 self.store.require_index()
1177 # Check if there is matching obsolete unit
1178 old_unit = self.store.id_index.get(unit.unit.getid())
1179 if old_unit and old_unit.isobsolete():
1180 self.store.removeunit(old_unit)
1181 super().add_unit(unit)
1184class PoFormat(BasePoFormat, BilingualUpdateMixin):
1185 # Translators: File format name
1186 name = gettext_lazy("gettext PO file")
1187 format_id = "po"
1188 monolingual = False
1189 autoload: tuple[str, ...] = ("*.po", "*.pot")
1190 unit_class = PoUnit
1192 @classmethod
1193 def get_new_file_content(cls) -> bytes:
1194 """Empty PO file content."""
1195 return b""
1197 @classmethod
1198 def do_bilingual_update(
1199 cls, in_file: str, out_file: str, template: str, **kwargs
1200 ) -> None:
1201 """
1202 Perform update of a bilingual file.
1204 Wrapper around msgmerge.
1205 """
1206 cmd = [
1207 "msgmerge",
1208 *kwargs.pop("args", ["--previous"]),
1209 "--output-file",
1210 out_file,
1211 in_file,
1212 template,
1213 ]
1214 if kwargs:
1215 msg = f"Unsupported arguments: {kwargs!r}"
1216 raise ValueError(msg)
1218 try:
1219 result = subprocess.run(
1220 cmd,
1221 env=get_clean_env(),
1222 cwd=os.path.dirname(out_file),
1223 capture_output=True,
1224 check=True,
1225 text=True,
1226 )
1227 except FileNotFoundError as error:
1228 report_error("Failed msgmerge")
1229 msg = "msgmerge not found, please install gettext"
1230 raise UpdateError(msg, error) from error
1231 except OSError as error:
1232 report_error("Failed msgmerge")
1233 raise UpdateError(" ".join(cmd), error) from error
1234 except subprocess.CalledProcessError as error:
1235 error_output = error.output + error.stderr
1236 report_error("Failed msgmerge")
1237 raise UpdateError(
1238 " ".join(cmd), cleanup_error_message(error_output)
1239 ) from error
1240 else:
1241 # The warnings can cause corruption (for example in case
1242 # PO file header is missing ASCII encoding is assumed)
1243 errors = []
1244 for line in result.stderr.splitlines():
1245 if (
1246 "warning: internationalized messages should not contain the" in line
1247 or ". done." in line
1248 ):
1249 continue
1250 errors.append(line)
1251 if errors:
1252 raise UpdateError(" ".join(cmd), "\n".join(errors))
1255class PoMonoFormat(BasePoFormat):
1256 # Translators: File format name
1257 name = gettext_lazy("gettext PO file (monolingual)")
1258 format_id = "po-mono"
1259 monolingual = True
1260 autoload: tuple[str, ...] = ()
1261 new_translation = (
1262 'msgid ""\n'
1263 'msgstr "X-Generator: Weblate\\n'
1264 "MIME-Version: 1.0\\n"
1265 "Content-Type: text/plain; charset=UTF-8\\n"
1266 'Content-Transfer-Encoding: 8bit"'
1267 )
1268 unit_class = PoMonoUnit
1269 bilingual_class = PoFormat
1270 set_context_bilingual = False
1272 def create_unit_key(
1273 self, key: str, source: str | list[str] | multistring
1274 ) -> str | multistring:
1275 if isinstance(source, list | multistring):
1276 return multistring([key, f"{key}_plural"])
1277 return key
1280class TSFormat(TTKitFormat):
1281 # Translators: File format name
1282 name = gettext_lazy("Qt Linguist translation file")
1283 format_id = "ts"
1284 loader = tsfile
1285 autoload: tuple[str, ...] = ("*.ts",)
1286 unit_class = TSUnit
1287 set_context_bilingual = False
1288 supports_plural: bool = True
1289 plural_preference = (
1290 Plural.SOURCE_QT,
1291 Plural.SOURCE_DEFAULT,
1292 )
1295class XliffFormat(TTKitFormat):
1296 # Translators: File format name
1297 name = gettext_lazy("XLIFF 1.2 translation file")
1298 format_id = "plainxliff"
1299 loader = xlifffile
1300 autoload: tuple[str, ...] = ("*.xlf", "*.xliff")
1301 unit_class = XliffUnit
1302 language_format = "bcp"
1303 use_settarget = True
1304 new_translation = """<?xml version="1.0" encoding="UTF-8"?>
1305<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
1306 <file original="Weblate" source-language="en" datatype="plaintext">
1307 <body>
1308 </body>
1309 </file>
1310</xliff>
1311"""
1313 def construct_unit(self, source: str):
1314 unit = super().construct_unit(source)
1315 # Make sure new unit is using same namespace as the original
1316 # file (xliff 1.1/1.2)
1317 unit.namespace = self.store.namespace
1318 unit.xmlelement = etree.Element(unit.namespaced(unit.rootNode))
1319 setXMLspace(unit.xmlelement, "preserve")
1320 return unit
1322 def create_unit(
1323 self,
1324 key: str,
1325 source: str | list[str],
1326 target: str | list[str] | None = None,
1327 ):
1328 unit = super().create_unit(key, source, target)
1329 unit.marktranslated()
1330 unit.markapproved(False)
1331 return unit
1334class RichXliffFormat(XliffFormat):
1335 # Translators: File format name
1336 name = gettext_lazy("XLIFF 1.2 with placeables support")
1337 format_id = "xliff"
1338 autoload: tuple[str, ...] = ("*.sdlxliff", "*.mxliff")
1339 unit_class = RichXliffUnit
1342class PoXliffFormat(XliffFormat):
1343 # Translators: File format name
1344 name = gettext_lazy("XLIFF 1.2 with gettext extensions")
1345 format_id = "poxliff"
1346 autoload: tuple[str, ...] = ("*.poxliff",)
1347 loader = PoXliffFile
1348 supports_plural: bool = True
1351class PropertiesBaseFormat(TTKitFormat):
1352 unit_class = PropertiesUnit
1354 def is_valid(self):
1355 result = super().is_valid()
1356 if not result:
1357 return False
1359 # Accept empty file, but reject file without a delimiter.
1360 # Translate Toolkit happily parses anything into a property
1361 # even if there is no delimiter used in the line.
1362 return not self.store.units or self.store.units[0].delimiter
1364 @staticmethod
1365 def mimetype() -> str:
1366 """Return most common media type for format."""
1367 # Properties files do not expose mimetype
1368 return "text/plain"
1370 def construct_unit(self, source: str):
1371 return self.store.UnitClass(source, personality=self.store.personality.name)
1374class StringsFormat(PropertiesBaseFormat):
1375 # Translators: File format name
1376 name = gettext_lazy("iOS strings (UTF-16)")
1377 format_id = "strings"
1378 loader = ("properties", "stringsfile")
1379 new_translation: str | bytes | None = "\n".encode("utf-16")
1380 autoload: tuple[str, ...] = ("*.strings",)
1381 language_format = "bcp"
1384class StringsUtf8Format(StringsFormat):
1385 # Translators: File format name
1386 name = gettext_lazy("iOS strings (UTF-8)")
1387 format_id = "strings-utf8"
1388 loader = ("properties", "stringsutf8file")
1389 new_translation = "\n"
1392class PropertiesUtf8Format(PropertiesBaseFormat):
1393 # Translators: File format name
1394 name = gettext_lazy("Java Properties (UTF-8)")
1395 format_id = "properties-utf8"
1396 loader = ("properties", "javautf8file")
1397 new_translation = "\n"
1398 language_format = "linux"
1399 check_flags = ("auto-java-messageformat",)
1402class PropertiesUtf16Format(PropertiesBaseFormat):
1403 # Translators: File format name
1404 name = gettext_lazy("Java Properties (UTF-16)")
1405 format_id = "properties-utf16"
1406 loader = ("properties", "javafile")
1407 language_format = "linux"
1408 new_translation = "\n"
1409 # Translate Toolkit autodetection might fail in some cases.
1410 force_encoding = "utf-16"
1413class PropertiesFormat(PropertiesBaseFormat):
1414 # Translators: File format name
1415 name = gettext_lazy("Java Properties (ISO 8859-1)")
1416 format_id = "properties"
1417 loader = ("properties", "javafile")
1418 language_format = "linux"
1419 new_translation = "\n"
1420 autoload: tuple[str, ...] = ("*.properties",)
1421 # Java properties need to be ISO 8859-1, but Translate Toolkit converts
1422 # them to UTF-8.
1423 force_encoding = "iso-8859-1"
1426class JoomlaFormat(PropertiesBaseFormat):
1427 # Translators: File format name
1428 name = gettext_lazy("Joomla language file")
1429 format_id = "joomla"
1430 loader = ("properties", "joomlafile")
1431 monolingual = True
1432 new_translation = "\n"
1433 autoload: tuple[str, ...] = ("*.ini",)
1436class GWTFormat(StringsFormat):
1437 # Translators: File format name
1438 name = gettext_lazy("GWT properties (UTF-8)")
1439 format_id = "gwt"
1440 loader = ("properties", "gwtfile")
1441 new_translation = "\n"
1442 check_flags = ("auto-java-messageformat",)
1443 language_format = "linux"
1444 supports_plural: bool = True
1447class GWTISOFormat(GWTFormat):
1448 # Translators: File format name
1449 name = gettext_lazy("GWT properties (ISO-8859-1)")
1450 format_id = "gwt-iso"
1451 force_encoding = "iso-8859-1"
1454class PhpFormat(TTKitFormat):
1455 # Translators: File format name
1456 name = gettext_lazy("PHP strings")
1457 format_id = "php"
1458 loader = ("php", "phpfile")
1459 new_translation = "<?php\n"
1460 autoload: tuple[str, ...] = ("*.php",)
1461 unit_class = PHPUnit
1463 @staticmethod
1464 def mimetype() -> str:
1465 """Return most common media type for format."""
1466 return "text/x-php"
1468 @staticmethod
1469 def extension() -> str:
1470 """Return most common file extension for format."""
1471 return "php"
1474class LaravelPhpFormat(PhpFormat):
1475 # Translators: File format name
1476 name = gettext_lazy("Laravel PHP strings")
1477 format_id = "laravel"
1478 loader = ("php", "LaravelPHPFile")
1479 supports_plural: bool = True
1482class RESXFormat(TTKitFormat):
1483 # Translators: File format name
1484 name = gettext_lazy(".NET resource file")
1485 format_id = "resx"
1486 loader = RESXFile
1487 monolingual = True
1488 unit_class = RESXUnit
1489 new_translation = RESXFile.XMLskeleton
1490 autoload: tuple[str, ...] = ("*.resx",)
1491 language_format = "bcp"
1492 supports_plural: bool = True
1493 store: RESXFile
1496class AndroidFormat(TTKitFormat):
1497 # Translators: File format name
1498 name = gettext_lazy("Android String Resource")
1499 format_id = "aresource"
1500 loader = ("aresource", "AndroidResourceFile")
1501 monolingual = True
1502 unit_class = AndroidUnit
1503 new_translation = (
1504 '<?xml version="1.0" encoding="utf-8"?>\n<resources>\n</resources>'
1505 )
1506 autoload: tuple[str, ...] = ("strings*.xml", "values*.xml")
1507 language_format = "android"
1508 check_flags = ("java-printf-format",)
1509 autoaddon: ClassVar[dict[str, dict[str, Any]]] = {"weblate.cleanup.blank": {}}
1510 plural_preference = (
1511 Plural.SOURCE_ANDROID,
1512 Plural.SOURCE_CLDR,
1513 Plural.SOURCE_DEFAULT,
1514 )
1515 strict_format_plurals: bool = True
1516 supports_plural: bool = True
1519class MOKOFormat(AndroidFormat):
1520 # Translators: File format name
1521 name = gettext_lazy("Mobile Kotlin Resource")
1522 format_id = "moko-resource"
1523 loader = ("aresource", "MOKOResourceFile")
1526class CMPFormat(AndroidFormat):
1527 # Translators: File format name
1528 name = gettext_lazy("Compose Multiplatform Resource")
1529 format_id = "cmp-resource"
1530 loader = ("aresource", "CMPResourceFile")
1533class DictStoreFormat(TTKitFormat):
1534 @classmethod
1535 def validate_context(cls, context: str) -> None:
1536 id_class = cls.get_class().UnitClass.IdClass
1538 try:
1539 id_class.from_string(context)
1540 except Exception as error:
1541 raise ValidationError(
1542 gettext("Could not parse the key: %s") % error
1543 ) from error
1546class JSONFormat(DictStoreFormat):
1547 # Translators: File format name
1548 name = gettext_lazy("JSON file")
1549 format_id = "json"
1550 loader = JsonFile
1551 unit_class = JSONUnit
1552 autoload: tuple[str, ...] = ("*.json",)
1553 new_translation = "{}\n"
1554 set_context_bilingual = False
1556 @staticmethod
1557 def mimetype() -> str:
1558 """Return most common media type for format."""
1559 return "application/json"
1561 @staticmethod
1562 def extension() -> str:
1563 """Return most common file extension for format."""
1564 return "json"
1567class JSONNestedFormat(JSONFormat):
1568 # Translators: File format name
1569 name = gettext_lazy("JSON nested structure file")
1570 format_id = "json-nested"
1571 loader = ("jsonl10n", "JsonNestedFile")
1572 autoload: tuple[str, ...] = ()
1575class WebExtensionJSONFormat(JSONFormat):
1576 # Translators: File format name
1577 name = gettext_lazy("WebExtension JSON file")
1578 format_id = "webextension"
1579 loader = ("jsonl10n", "WebExtensionJsonFile")
1580 monolingual = True
1581 autoload: tuple[str, ...] = ("messages*.json",)
1582 unit_class = PlaceholdersJSONUnit
1583 supports_plural: bool = True
1586class I18NextFormat(JSONFormat):
1587 # Translators: File format name
1588 name = gettext_lazy("i18next JSON file v3")
1589 format_id = "i18next"
1590 loader = ("jsonl10n", "I18NextFile")
1591 autoload: tuple[str, ...] = ()
1592 check_flags = ("i18next-interpolation",)
1593 language_format: str = "bcp"
1594 supports_plural: bool = True
1597class I18NextV4Format(I18NextFormat):
1598 # Translators: File format name
1599 name = gettext_lazy("i18next JSON file v4")
1600 format_id = "i18nextv4"
1601 loader = ("jsonl10n", "I18NextV4File")
1604class GoI18JSONFormat(JSONFormat):
1605 # Translators: File format name
1606 name = gettext_lazy("go-i18n v1 JSON file")
1607 format_id = "go-i18n-json"
1608 loader = ("jsonl10n", "GoI18NJsonFile")
1609 autoload: tuple[str, ...] = ()
1610 new_translation = "[]\n"
1611 supports_plural: bool = True
1614class GoI18V2JSONFormat(JSONFormat):
1615 # Translators: File format name
1616 name = gettext_lazy("go-i18n v2 JSON file")
1617 format_id = "go-i18n-json-v2"
1618 loader = ("jsonl10n", "GoI18NV2JsonFile")
1619 autoload: tuple[str, ...] = ()
1620 supports_plural: bool = True
1623class ARBFormat(JSONFormat):
1624 # Translators: File format name
1625 name = gettext_lazy("ARB file")
1626 format_id = "arb"
1627 loader = ("jsonl10n", "ARBJsonFile")
1628 autoload: tuple[str, ...] = ("*.arb",)
1629 unit_class = PlaceholdersJSONUnit
1630 check_flags = ("icu-message-format",)
1633class GoTextFormat(JSONFormat):
1634 # Translators: File format name
1635 name = gettext_lazy("gotext JSON file")
1636 format_id = "gotext"
1637 loader = ("jsonl10n", "GoTextJsonFile")
1638 autoload: tuple[str, ...] = ()
1639 unit_class = PlaceholdersJSONUnit
1640 supports_plural: bool = True
1643class FormatJSFormat(JSONFormat):
1644 # Translators: File format name
1645 name = gettext_lazy("Format.JS JSON file")
1646 format_id = "formatjs"
1647 loader = ("jsonl10n", "FormatJSJsonFile")
1648 autoload: tuple[str, ...] = ()
1649 check_flags = ("icu-message-format",)
1652class CSVFormat(TTKitFormat):
1653 # Translators: File format name
1654 name = gettext_lazy("CSV file")
1655 format_id = "csv"
1656 loader = ("csvl10n", "csvfile")
1657 unit_class = CSVUnit
1658 autoload: tuple[str, ...] = ("*.csv",)
1659 force_encoding = "auto"
1661 def __init__(
1662 self,
1663 storefile,
1664 template_store=None,
1665 language_code: str | None = None,
1666 source_language: str | None = None,
1667 is_template: bool = False,
1668 existing_units: list[Any] | None = None,
1669 file_format_params: dict[str, Any] | None = None,
1670 ) -> None:
1671 super().__init__(
1672 storefile,
1673 template_store=template_store,
1674 language_code=language_code,
1675 source_language=source_language,
1676 is_template=is_template,
1677 existing_units=existing_units,
1678 file_format_params=file_format_params,
1679 )
1680 # Remove template if the file contains source, this is needed
1681 # for import, but probably usable elsewhere as well
1682 if "source" in self.store.fieldnames and not isinstance( 1682 ↛ exitline 1682 didn't return from function '__init__' because the condition on line 1682 was always true
1683 template_store, CSVFormat
1684 ):
1685 self.template_store = None
1687 @staticmethod
1688 def mimetype() -> str:
1689 """Return most common media type for format."""
1690 return "text/csv"
1692 @staticmethod
1693 def extension() -> str:
1694 """Return most common file extension for format."""
1695 return "csv"
1697 @staticmethod
1698 def get_content_and_filename(storefile):
1699 # Did we get file or filename or file object?
1700 if hasattr(storefile, "read"): 1700 ↛ 1701line 1700 didn't jump to line 1701 because the condition on line 1700 was never true
1701 filename = getattr(storefile, "name", getattr(storefile, "filename", None))
1702 content = storefile.read()
1703 storefile.close()
1704 else:
1705 filename = storefile
1706 with open(filename, "rb") as handle:
1707 content = handle.read()
1708 return content, filename
1710 def parse_store(self, storefile, *, dialect: str | None = None):
1711 """Parse the store."""
1712 content, filename = self.get_content_and_filename(storefile)
1714 # Parse file
1715 store = self.get_store_instance()
1716 store.parse(
1717 content,
1718 sample_length=40000 if dialect is None else None,
1719 dialect=dialect,
1720 )
1721 # Did detection of headers work?
1722 if store.fieldnames != ["location", "source", "target"]:
1723 return store
1725 fileobj = StringIO(
1726 store.detect_encoding(content, default_encodings=["utf-8", "utf-16"])[0]
1727 )
1729 # Try reading header
1730 reader = csv.reader(fileobj, store.dialect)
1731 header = next(reader)
1732 fileobj.close()
1734 # Check if the file is not two column only, in that case translate-toolkit detection
1735 # wrongly assumes three column files
1736 if len(header) != 2:
1737 return store
1739 return self.parse_simple_csv(content, filename, header=header)
1741 def parse_simple_csv(self, content, filename, header: list[str] | None = None):
1742 fieldnames = ["source", "target"]
1743 if header and all( 1743 ↛ 1746line 1743 didn't jump to line 1746 because the condition on line 1743 was never true
1744 field in {"source", "target", "context", "id"} for field in header
1745 ):
1746 fieldnames = header
1747 elif self.is_template or self.template_store: 1747 ↛ 1748line 1747 didn't jump to line 1748 because the condition on line 1747 was never true
1748 fieldnames = ["context", "target"]
1749 result = self.get_store_instance(fieldnames=fieldnames)
1750 result.parse(content, sample_length=None)
1751 result.filename = filename
1752 return result
1755class CSVUtf8Format(CSVFormat):
1756 # Translators: File format name
1757 name = gettext_lazy("CSV file (UTF-8)")
1758 format_id = "csv-utf-8"
1759 autoload: tuple[str, ...] = ()
1760 force_encoding = "utf-8"
1763class CSVSimpleFormat(CSVFormat):
1764 # Translators: File format name
1765 name = gettext_lazy("Simple CSV file")
1766 format_id = "csv-simple"
1767 autoload: tuple[str, ...] = ("*.txt",)
1768 force_encoding = "auto"
1770 @staticmethod
1771 def extension() -> str:
1772 """Return most common file extension for format."""
1773 return "csv"
1775 def parse_store(self, storefile):
1776 """Parse the store."""
1777 content, filename = self.get_content_and_filename(storefile)
1779 return self.parse_simple_csv(content, filename)
1782class CSVSimpleFormatISO(CSVSimpleFormat):
1783 # Translators: File format name
1784 name = gettext_lazy("Simple CSV file (ISO-8859-1)")
1785 format_id = "csv-simple-iso"
1786 force_encoding = "iso-8859-1"
1787 autoload: tuple[str, ...] = ()
1790class CSVUtf8SimpleFormat(CSVSimpleFormat):
1791 # Translators: File format name
1792 name = gettext_lazy("Simple CSV file (UTF-8)")
1793 format_id = "csv-simple-utf-8"
1794 force_encoding = "utf-8"
1795 autoload: tuple[str, ...] = ()
1798class YAMLFormat(DictStoreFormat):
1799 # Translators: File format name
1800 name = gettext_lazy("YAML file")
1801 format_id = "yaml"
1802 loader = ("yaml", "YAMLFile")
1803 unit_class = MonolingualSimpleUnit
1804 autoload: tuple[str, ...] = ("*.pyml",)
1805 new_translation = "{}\n"
1807 @staticmethod
1808 def mimetype() -> str:
1809 """Return most common media type for format."""
1810 return "text/yaml"
1812 @staticmethod
1813 def extension() -> str:
1814 """Return most common file extension for format."""
1815 return "yml"
1818class RubyYAMLFormat(YAMLFormat):
1819 # Translators: File format name
1820 name = gettext_lazy("Ruby YAML file")
1821 format_id = "ruby-yaml"
1822 loader = ("yaml", "RubyYAMLFile")
1823 autoload: tuple[str, ...] = ("*.ryml", "*.yml", "*.yaml")
1824 supports_plural: bool = True
1827class DTDFormat(TTKitFormat):
1828 # Translators: File format name
1829 name = gettext_lazy("DTD file")
1830 format_id = "dtd"
1831 loader = ("dtd", "dtdfile")
1832 autoload: tuple[str, ...] = ("*.dtd",)
1833 unit_class = MonolingualSimpleUnit
1834 new_translation = "\n"
1835 can_add_unit: bool = False
1837 @staticmethod
1838 def mimetype() -> str:
1839 """Return most common media type for format."""
1840 return "application/xml-dtd"
1842 @staticmethod
1843 def extension() -> str:
1844 """Return most common file extension for format."""
1845 return "dtd"
1847 @property
1848 def all_store_units(self):
1849 """Wrapper for all store unit filtering out null."""
1850 return (unit for unit in self.store.units if not unit.isblank())
1853class SubtitleUnit(MonolingualIDUnit):
1854 @cached_property
1855 def source(self):
1856 if self.template is not None:
1857 return self.template.source
1858 return self.unit.source
1860 @cached_property
1861 def target(self):
1862 """Return target string from a Translate Toolkit unit."""
1863 if self.unit is None:
1864 return ""
1865 return get_string(self.unit.source)
1867 def is_translated(self):
1868 """Check whether unit is translated."""
1869 return self.has_translation()
1872class SubRipFormat(TTKitFormat):
1873 # Translators: File format name
1874 name = gettext_lazy("SubRip subtitle file")
1875 format_id = "srt"
1876 loader = ("subtitles", "SubRipFile")
1877 unit_class = SubtitleUnit
1878 autoload: tuple[str, ...] = ("*.srt",)
1879 monolingual = True
1880 autoaddon: ClassVar[dict[str, dict[str, Any]]] = {"weblate.flags.same_edit": {}}
1882 @staticmethod
1883 def mimetype() -> str:
1884 """Return most common media type for format."""
1885 return "text/plain"
1888class MicroDVDFormat(SubRipFormat):
1889 # Translators: File format name
1890 name = gettext_lazy("MicroDVD subtitle file")
1891 format_id = "sub"
1892 loader = ("subtitles", "MicroDVDFile")
1893 autoload: tuple[str, ...] = ("*.sub",)
1896class AdvSubStationAlphaFormat(SubRipFormat):
1897 # Translators: File format name
1898 name = gettext_lazy("Advanced SubStation Alpha subtitle file")
1899 format_id = "ass"
1900 loader = ("subtitles", "AdvSubStationAlphaFile")
1901 autoload: tuple[str, ...] = ("*.ass",)
1904class SubStationAlphaFormat(SubRipFormat):
1905 # Translators: File format name
1906 name = gettext_lazy("SubStation Alpha subtitle file")
1907 format_id = "ssa"
1908 loader = ("subtitles", "SubStationAlphaFile")
1909 autoload: tuple[str, ...] = ("*.ssa",)
1912class FlatXMLFormat(TTKitFormat):
1913 # Translators: File format name
1914 name = gettext_lazy("Flat XML file")
1915 format_id = "flatxml"
1916 loader = ("flatxml", "FlatXMLFile")
1917 monolingual = True
1918 unit_class = FlatXMLUnit
1919 new_translation = '<?xml version="1.0" encoding="utf-8"?>\n<root></root>'
1921 def get_format_class_kwargs(self):
1922 return {
1923 "root_name": self.file_format_params.get("flatxml_root_name", None),
1924 "value_name": self.file_format_params.get("flatxml_value_name", None),
1925 "key_name": self.file_format_params.get("flatxml_key_name", None),
1926 }
1928 def get_unit_class_kwargs(self):
1929 return {
1930 "element_name": self.file_format_params.get("flatxml_value_name", None),
1931 "attribute_name": self.file_format_params.get("flatxml_key_name", None),
1932 }
1935class ResourceDictionaryFormat(FlatXMLFormat):
1936 # Translators: File format name
1937 name = gettext_lazy("ResourceDictionary file")
1938 format_id = "resourcedictionary"
1939 loader = ("resourcedictionary", "ResourceDictionaryFile")
1940 check_flags = ("c-sharp-format",)
1941 language_format = "bcp_legacy"
1942 new_translation = """<ResourceDictionary
1943 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
1944 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
1945 xmlns:system="clr-namespace:System;assembly=mscorlib">
1946</ResourceDictionary>"""
1949class INIFormat(TTKitFormat):
1950 # Translators: File format name
1951 name = gettext_lazy("INI file")
1952 format_id = "ini"
1953 loader = ("ini", "inifile")
1954 monolingual = True
1955 unit_class = INIUnit
1956 new_translation = "\n"
1958 @staticmethod
1959 def mimetype() -> str:
1960 """Return most common media type for format."""
1961 # INI files do not expose mimetype
1962 return "text/plain"
1964 @classmethod
1965 def extension(cls) -> str:
1966 """Return most common file extension for format."""
1967 # INI files do not expose extension
1968 return "ini"
1970 def load(
1971 self,
1972 storefile: str | BinaryIO,
1973 template_store: TranslationFormat | None,
1974 ) -> TranslationStore:
1975 store = super().load(storefile, template_store)
1976 # Adjust store to have translations
1977 for unit in store.units:
1978 unit.target = unit.source
1979 unit.rich_target = unit.rich_source
1980 return store
1982 def create_unit(
1983 self,
1984 key: str,
1985 source: str | list[str],
1986 target: str | list[str] | None = None,
1987 ):
1988 unit = super().create_unit(key, source, target)
1989 unit.location = key
1990 return unit
1993class InnoSetupINIFormat(INIFormat):
1994 # Translators: File format name
1995 name = gettext_lazy("Inno Setup INI file")
1996 format_id = "islu"
1997 loader = ("ini", "inifile")
1999 @classmethod
2000 def extension(cls) -> str:
2001 """Return most common file extension for format."""
2002 # INI files do not expose extension
2003 return "islu"
2005 def get_format_class_kwargs(self):
2006 return {"dialect": "inno"}
2009class XWikiUnit(PropertiesUnit):
2010 """
2011 Dedicated unit for XWiki.
2013 Inspired from PropertiesUnit, allow to override the methods to use the right
2014 XWikiDialect methods for decoding properties.
2015 """
2017 @cached_property
2018 def source(self):
2019 # Need to decode property encoded string
2020 return get_string(quote.xwiki_properties_decode(super().source))
2022 @cached_property
2023 def target(self):
2024 """Return target string from a Translate Toolkit unit."""
2025 if self.unit is None:
2026 return ""
2027 # Need to decode property encoded string
2028 # This is basically stolen from
2029 # translate.storage.properties.propunit.gettarget
2030 # which for some reason does not return translation
2031 value = quote.xwiki_properties_decode(self.unit.value)
2032 value = re.sub(r"\\ ", " ", value)
2033 return get_string(value)
2036class XWikiPropertiesFormat(PropertiesBaseFormat):
2037 """
2038 Represents an XWiki Java Properties translation file.
2040 This format specification is detailed in
2041 https://dev.xwiki.org/xwiki/bin/view/Community/XWiki%20Translations%20Formats/#HXWikiJavaProperties
2042 """
2044 unit_class = XWikiUnit
2045 name = "XWiki Java Properties"
2046 format_id = "xwiki-java-properties"
2047 loader = ("properties", "xwikifile")
2048 language_format = "linux"
2049 autoload: tuple[str, ...] = ("*.properties",)
2050 new_translation = "\n"
2051 can_add_unit: bool = False
2052 can_delete_unit: bool = False
2053 set_context_bilingual: bool = True
2054 supports_plural: bool = True
2056 # Ensure that untranslated units are saved too as missing properties and
2057 # comments are preserved as in the original source file.
2058 def save_content(self, handle) -> None:
2059 current_units = self.all_units
2060 store_units = self.store.units
2062 # We empty the store units since we want to control what we'll serialize
2063 self.store.units = []
2065 for unit in current_units:
2066 # If the translation unit is missing and the current unit is not
2067 # only about comment.
2068 if unit.unit is None and unit.has_content():
2069 # We first check if the unit has not been translated as part of a
2070 # new language: in that case the unit is not linked yet.
2071 found_store_unit = None
2072 for store_unit in store_units:
2073 if unit.context == store_unit.name:
2074 found_store_unit = store_unit
2076 # If we found a unit for same context not linked, we just link it.
2077 if found_store_unit is not None:
2078 unit.unit = found_store_unit
2079 # else it's a missing unit: we need to mark it as missing.
2080 else:
2081 missingunit = self.unit_class(self, unit.mainunit, unit.template)
2082 unit.unit = missingunit.unit
2083 unit.unit.missing = True
2084 # if the unit was only a comment, we take back the original source file unit
2085 # to avoid any change.
2086 elif not unit.has_content():
2087 unit.unit = unit.mainunit
2088 self.add_unit(unit)
2090 self.store.serialize(handle)
2093class XWikiPagePropertiesFormat(XWikiPropertiesFormat):
2094 """
2095 Represents an XWiki Page Properties translation file.
2097 This format specification is detailed in
2098 https://dev.xwiki.org/xwiki/bin/view/Community/XWiki%20Translations%20Formats/#HXWikiPageProperties
2099 """
2101 name = "XWiki Page Properties"
2102 format_id = "xwiki-page-properties"
2103 loader = ("properties", "XWikiPageProperties")
2104 force_encoding = "utf-8"
2106 def save_content(self, handle) -> None:
2107 if self.store.root is None:
2108 self.store.root = self.template_store.store.root
2109 super().save_content(handle)
2112class XWikiFullPageFormat(XWikiPagePropertiesFormat):
2113 """
2114 Represents an XWiki Full Page translation file.
2116 This format specification is detailed in
2117 https://dev.xwiki.org/xwiki/bin/view/Community/XWiki%20Translations%20Formats/#HXWikiFullContentTranslation
2118 """
2120 name = "XWiki Full Page"
2121 format_id = "xwiki-fullpage"
2122 loader = ("properties", "XWikiFullPage")
2125class TBXUnit(TTKitUnit):
2126 unit: tbxunit
2128 def _is_usage_node(self, node: etree.Element) -> bool:
2129 return (
2130 self.unit.namespaced("descrip") == node.tag
2131 and node.get("type") == "Usage note"
2132 )
2134 @cached_property
2135 def notes(self):
2136 """Return notes or notes from units."""
2137 notes = []
2138 for origin in ["pos", "developer"]:
2139 note = self.unit.getnotes(origin)
2140 if note:
2141 notes.append(note)
2143 for node in self.unit._getnotenodes(origin="definition"): # noqa: SLF001
2144 if self._is_usage_node(node):
2145 notes.append(self.unit._getnodetext(node)) # noqa: SLF001
2146 break
2148 return "\n".join(notes)
2150 @cached_property
2151 def context(self):
2152 return self.unit.xmlelement.get("id") or ""
2154 def set_explanation(self, explanation: str) -> None:
2155 if explanation or self.explanation:
2156 self.unit.addnote(explanation, origin="translator", position="replace")
2157 self.__dict__.pop("explanation", None)
2159 @cached_property
2160 def explanation(self) -> str:
2161 return self.unit.getnotes("translator")
2163 def set_source_explanation(self, explanation: str) -> None:
2164 if explanation or self.source_explanation:
2165 self.unit.addnote(explanation, origin="definition", position="replace")
2166 self.__dict__.pop("source_explanation", None)
2168 @cached_property
2169 def source_explanation(self) -> str:
2170 seen_notes = set()
2171 notes = []
2172 for node in self.unit._getnotenodes(origin="definition"): # noqa: SLF001
2173 if self._is_usage_node(node) or self.unit._is_translation_needed_node(node): # noqa: SLF001
2174 continue
2175 note = self.unit._getnodetext(node) # noqa: SLF001
2176 if note not in seen_notes:
2177 notes.append(note)
2178 seen_notes.add(note)
2180 return "\n".join(notes)
2182 @cached_property
2183 def flags(self):
2184 flags = super().flags
2186 for node in self.unit._getnotenodes(origin="pos"): # noqa: SLF001
2187 # each tig in the two langsets in the termEntry can have the
2188 # <termNote type="administrativeStatus">, consider forbidden
2189 # if either of the two is forbidden/obsolete
2190 if self.unit._is_administrative_status_term_node(node): # noqa: SLF001
2191 if self.unit._getnodetext(node).strip().lower() in { # noqa: SLF001
2192 "forbidden",
2193 "obsolete",
2194 }:
2195 flags.merge("forbidden")
2196 break
2198 return flags
2201class TBXFormat(TTKitFormat):
2202 # Translators: File format name
2203 name = gettext_lazy("TermBase eXchange file")
2204 format_id = "tbx"
2205 loader = tbxfile
2206 autoload: tuple[str, ...] = ("*.tbx",)
2207 new_translation = """<?xml version="1.0"?>
2208<!DOCTYPE martif PUBLIC "ISO 12200:1999A//DTD MARTIF core (DXFcdV04)//EN" "TBXcdv04.dtd">
2209<martif type="TBX">
2210 <martifHeader>
2211 <fileDesc>
2212 <sourceDesc>
2213 <p>Weblate Glossary</p>
2214 </sourceDesc>
2215 </fileDesc>
2216 </martifHeader>
2217 <text>
2218 <body>
2219 </body>
2220 </text>
2221</martif>"""
2222 unit_class = TBXUnit
2223 create_empty_bilingual: bool = True
2224 use_settarget = True
2225 monolingual = False
2226 supports_explanation: bool = True
2227 store: tbxfile
2229 def __init__(
2230 self,
2231 storefile,
2232 template_store=None,
2233 language_code: str | None = None,
2234 source_language: str | None = None,
2235 is_template: bool = False,
2236 existing_units: list[Any] | None = None,
2237 file_format_params: dict[str, Any] | None = None,
2238 ) -> None:
2239 super().__init__(
2240 storefile,
2241 template_store=template_store,
2242 language_code=language_code,
2243 is_template=is_template,
2244 source_language=source_language,
2245 existing_units=existing_units,
2246 file_format_params=file_format_params,
2247 )
2248 # Add language header if not present
2249 self.store.addheader()
2252class PropertiesMi18nFormat(PropertiesUtf8Format):
2253 # Translators: File format name
2254 name = gettext_lazy("@draggable/i18n lang file")
2255 format_id = "mi18n-lang"
2256 new_translation = "\n"
2257 language_format = "bcp_legacy"
2258 check_flags = ("es-format",)
2259 monolingual = True
2262class CatkeysFormat(TTKitFormat):
2263 # Translators: File format name
2264 name = gettext_lazy("Haiku catkeys")
2265 format_id = "catkeys"
2266 loader = CatkeysFile
2267 autoload: tuple[str, ...] = ("*.catkeys",)
2268 unit_class = TTKitUnit
2269 monolingual = False
2270 supports_explanation = True
2271 store: CatkeysFile
2273 @classmethod
2274 def mimetype(cls) -> str:
2275 return "text/x-catkeys"
2277 @classmethod
2278 def extension(cls) -> str:
2279 return "catkeys"
2282class StringsdictFormat(DictStoreFormat):
2283 # Translators: File format name
2284 name = gettext_lazy("Stringsdict file")
2285 format_id = "stringsdict"
2286 loader = ("stringsdict", "StringsDictFile")
2287 unit_class = MonolingualSimpleUnit
2288 autoload: tuple[str, ...] = ("*.stringsdict",)
2289 new_translation = """<?xml version="1.0" encoding="UTF-8"?>
2290<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2291<plist version="1.0">
2292 <dict>
2293 </dict>
2294</plist>
2295"""
2296 supports_plural: bool = True
2298 @staticmethod
2299 def mimetype() -> str:
2300 """Return most common media type for format."""
2301 return "application/xml"
2303 @staticmethod
2304 def extension() -> str:
2305 """Return most common file extension for format."""
2306 return "stringsdict"
2308 @classmethod
2309 def get_plural(cls, language, store=None):
2310 """Return matching plural object."""
2311 plural = super().get_plural(language, store)
2312 if plural.type in ZERO_PLURAL_TYPES:
2313 return plural
2315 from weblate.lang.models import Plural
2317 return language.plural_set.get_or_create(
2318 source=Plural.SOURCE_CLDR_ZERO,
2319 defaults={
2320 "formula": FORMULA_WITH_ZERO[plural.formula],
2321 "number": plural.number + 1,
2322 },
2323 )[0]
2325 def fixup(self, store) -> None:
2326 if self.language_code:
2327 store.settargetlanguage(self.language_code)
2328 elif self.source_language:
2329 store.settargetlanguage(self.source_language)
2332class FluentUnit(MonolingualSimpleUnit):
2333 def set_target(self, target: str | list[str]) -> None:
2334 old_target = self.unit.target
2335 old_source = self.unit.source
2336 super().set_target(target)
2337 self.unit.source = target
2338 try:
2339 # This triggers serialization discovering any syntax issues
2340 self.unit.to_entry()
2341 except Exception:
2342 # Restore previous content
2343 self.unit.target = old_target
2344 self.unit.source = old_source
2345 raise
2347 @cached_property
2348 def flags(self):
2349 flags = super().flags
2350 flags.set_value("fluent-type", self.mainunit.fluent_type)
2351 return flags
2354class FluentFormat(TTKitFormat):
2355 # Translators: File format name
2356 name = gettext_lazy("Fluent file")
2357 format_id = "fluent"
2358 loader = ("fluent", "FluentFile")
2359 unit_class = FluentUnit
2360 autoload: tuple[str, ...] = ("*.ftl",)
2361 new_translation = ""
2362 language_format: str = "bcp"
2363 check_flags = (
2364 "fluent-source-syntax",
2365 "fluent-target-syntax",
2366 "fluent-parts",
2367 "fluent-references",
2368 "fluent-source-inner-html",
2369 "fluent-target-inner-html",
2370 # Ignore xml check since we have inner-html checks.
2371 "ignore-xml-tags",
2372 "ignore-xml-invalid",
2373 )
2375 @staticmethod
2376 def mimetype() -> str:
2377 """Return most common media type for format."""
2378 return "text/x-fluent"
2380 @staticmethod
2381 def extension() -> str:
2382 """Return most common file extension for format."""
2383 return "ftl"
2385 def create_unit(
2386 self,
2387 key: str,
2388 source: str | list[str],
2389 target: str | list[str] | None = None,
2390 ):
2391 unit = super().create_unit(key, source, target)
2392 unit.source = unit.target
2393 return unit