Coverage for app/venv/lib/python3.14/site-packages/weblate/formats/convert.py: 36%
361 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# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5"""Translate Toolkit converter based file format wrappers."""
7from __future__ import annotations
9import codecs
10import os
11import shutil
12from collections import defaultdict
13from io import BytesIO
14from typing import TYPE_CHECKING, Any, BinaryIO, ClassVar, NoReturn
15from zipfile import ZipFile
17from django.utils.functional import cached_property
18from django.utils.translation import gettext_lazy
19from translate.convert.po2html import po2html
20from translate.convert.po2idml import translate_idml, write_idml
21from translate.convert.po2rc import rerc
22from translate.convert.po2txt import po2txt
23from translate.convert.rc2po import rc2po
24from translate.convert.xliff2odf import translate_odf, write_odf
25from translate.storage.html import htmlfile
26from translate.storage.idml import INLINE_ELEMENTS, NO_TRANSLATE_ELEMENTS, open_idml
27from translate.storage.odf_io import open_odf
28from translate.storage.odf_shared import inline_elements, no_translate_content_elements
29from translate.storage.pypo import pofile
30from translate.storage.rc import rcfile
31from translate.storage.txt import TxtFile
32from translate.storage.xliff import xlifffile
33from translate.storage.xml_extract.extract import (
34 IdMaker,
35 ParseState,
36 build_idml_store,
37 build_store,
38 make_postore_adder,
39)
41from weblate.formats.base import (
42 TranslationFormat,
43)
44from weblate.formats.helpers import NamedBytesIO
45from weblate.formats.ttkit import PoUnit, XliffUnit
46from weblate.trans.util import get_string
47from weblate.utils.errors import report_error
48from weblate.utils.state import STATE_APPROVED
50if TYPE_CHECKING: 50 ↛ 51line 50 didn't jump to line 51 because the condition on line 50 was never true
51 from collections.abc import Callable
53 from translate.storage.base import TranslationStore
54 from translate.storage.base import TranslationUnit as TranslateToolkitUnit
56 from weblate.formats.base import (
57 TranslationUnit,
58 )
59 from weblate.trans.models import Unit
62class ConvertPoUnit(PoUnit):
63 id_hash_with_source: bool = True
65 def is_translated(self):
66 """Check whether unit is translated."""
67 if self.parent.is_template:
68 return self.has_translation()
69 return self.unit is not None and self.has_translation()
71 def is_fuzzy(self, fallback=False):
72 """Check whether unit needs editing."""
73 return fallback
75 def is_approved(self, fallback=False):
76 """Check whether unit is approved."""
77 return fallback
79 @cached_property
80 def source(self):
81 """Return source string from a Translate Toolkit unit."""
82 if self.template:
83 return get_string(self.template.source)
84 return get_string(self.unit.source)
87class ConvertXliffUnit(XliffUnit):
88 def is_fuzzy(self, fallback=False):
89 """Check whether unit needs editing."""
90 return fallback
92 def is_approved(self, fallback=False):
93 """Check whether unit is approved."""
94 return fallback
96 def is_translated(self):
97 """Check whether unit is translated."""
98 if self.parent.is_template:
99 return self.has_translation()
100 return self.unit is not None
102 @cached_property
103 def flags(self):
104 flags = super().flags
105 flags.remove("xml-text")
106 return flags
109class ConvertFormat(TranslationFormat):
110 """
111 Base class for convert based formats.
113 This always uses intermediate representation.
114 """
116 monolingual = True
117 can_add_unit = False
118 can_delete_unit = False
119 can_edit_base: bool = False
120 unit_class: type[TranslationUnit] = ConvertPoUnit
121 autoaddon: ClassVar[dict[str, dict[str, Any]]] = {
122 "weblate.flags.same_edit": {},
123 "weblate.cleanup.generic": {},
124 }
125 create_style = "copy"
126 units: list[TranslateToolkitUnit]
127 store: TranslationStore
129 def save_content(self, handle) -> None:
130 """Store content to file."""
131 raise NotImplementedError
133 def save(self) -> None:
134 """Save underlying store to disk."""
135 self.save_atomic(self.storefile, self.save_content)
137 def convertfile(self, storefile, template_store) -> NoReturn:
138 raise NotImplementedError
140 @staticmethod
141 def needs_target_sync(template_store) -> bool: # noqa: ARG004
142 return False
144 def load(
145 self,
146 storefile: str | BinaryIO,
147 template_store: TranslationFormat | None,
148 ) -> TranslationStore:
149 # Did we get file or filename?
150 if not hasattr(storefile, "read"):
151 with open(storefile, "rb") as handle:
152 store = self.convertfile(handle, template_store)
153 else:
154 store = self.convertfile(storefile, template_store)
155 # Adjust store to have translations
156 if self.needs_target_sync(template_store):
157 for unit in store.units:
158 if unit.isheader():
159 continue
160 # HTML does this properly on loading, others need it
161 unit.target = unit.source
162 unit.rich_target = unit.rich_source
163 return store
165 @classmethod
166 def create_new_file(
167 cls,
168 filename: str,
169 language: str, # noqa: ARG003
170 base: str,
171 callback: Callable | None = None, # noqa: ARG003
172 file_format_params: dict[str, Any] | None = None, # noqa: ARG003
173 ) -> None:
174 """Handle creation of new translation file."""
175 if not base:
176 msg = "Not supported"
177 raise ValueError(msg)
178 # Copy file
179 shutil.copy(base, filename)
181 @classmethod
182 def is_valid_base_for_new(
183 cls,
184 base: str,
185 monolingual: bool, # noqa: ARG003
186 errors: list[Exception] | None = None,
187 fast: bool = False,
188 file_format_params: dict[str, Any] | None = None, # noqa: ARG003
189 ) -> bool:
190 """Check whether base is valid."""
191 if not base:
192 return False
193 try:
194 if not fast:
195 cls(base, None)
196 except Exception as exception:
197 if errors is not None:
198 errors.append(exception)
199 report_error("File-parsing error")
200 return False
201 return True
203 def add_unit(self, unit: TranslationUnit) -> None:
204 self.store.addunit(unit.unit)
206 @classmethod
207 def get_class(cls) -> None:
208 return None
210 def create_unit(
211 self,
212 key: str,
213 source: str | list[str],
214 target: str | list[str] | None = None,
215 ) -> NoReturn:
216 msg = "Not supported"
217 raise ValueError(msg)
219 def cleanup_unused(self) -> list[str]:
220 """
221 Bring target in sync with the source.
223 This is done automatically on save as it reshapes translations
224 based on the template.
225 """
226 self.save()
227 return []
229 def convert_to_po(self, parser, template_store, use_location: bool = True):
230 store = pofile()
231 # Prepare index of existing translations
232 unitindex: dict[str, list[Unit]] = defaultdict(list)
233 for existing_unit in self.existing_units:
234 for source in existing_unit.get_source_plurals():
235 unitindex[source].append(existing_unit)
237 # Convert store
238 if template_store:
239 parser.makeindex()
240 for unit in template_store.content_units:
241 thepo = store.addsourceunit(unit.source)
242 locations = unit.unit.getlocations()
243 thepo.addlocations(locations)
244 thepo.addnote(unit.unit.getnotes(), "developer")
245 if self.is_template:
246 thepo.target = unit.source
247 elif use_location and not unitindex:
248 # Try to import initial translation from the file
249 for location in locations:
250 try:
251 translation = parser.locationindex[location]
252 thepo.target = translation.source
253 break
254 except KeyError:
255 continue
256 else:
257 for htmlunit in parser.units:
258 # Source file
259 thepo = store.addsourceunit(htmlunit.source)
260 thepo.target = htmlunit.source
261 thepo.addlocations(htmlunit.getlocations())
262 thepo.addnote(htmlunit.getnotes(), "developer")
264 # Handle duplicate strings (use context to differentiate them)
265 store.removeduplicates("msgctxt")
267 # Merge existing translations
268 if unitindex and not self.is_template:
269 for unit in store.units:
270 possible_translations = unitindex[unit.source]
271 # Single match
272 if len(possible_translations) == 1:
273 unit.target = possible_translations[0].target
274 continue
275 # None match
276 if not possible_translations:
277 continue
278 # Multiple matches
279 for translation in possible_translations:
280 if translation.context == unit.getcontext():
281 unit.target = translation.target
282 break
284 return store
287class HTMLFormat(ConvertFormat):
288 # Translators: File format name
289 name = gettext_lazy("HTML file")
290 autoload = ("*.htm", "*.html")
291 format_id = "html"
292 check_flags = ("safe-html", "strict-same")
294 def convertfile(self, storefile, template_store):
295 # Fake input file with a blank filename
296 htmlparser = htmlfile(inputfile=NamedBytesIO("", storefile.read()))
297 return self.convert_to_po(htmlparser, template_store)
299 def save_content(self, handle) -> None:
300 """Store content to file."""
301 converter = po2html()
302 templatename = self.template_store.storefile
303 if hasattr(templatename, "name"):
304 templatename = templatename.name
305 with open(templatename, "rb") as templatefile:
306 outputstring = converter.mergestore(
307 self.store, templatefile, includefuzzy=True
308 )
309 handle.write(outputstring.encode("utf-8"))
311 @staticmethod
312 def mimetype() -> str:
313 """Return most common mime type for format."""
314 return "text/html"
316 @staticmethod
317 def extension() -> str:
318 """Return most common file extension for format."""
319 return "html"
322class MarkdownFormat(ConvertFormat):
323 # Translators: File format name
324 name = gettext_lazy("Markdown file")
325 autoload = ("*.md", "*.markdown")
326 format_id = "markdown"
327 check_flags = ("safe-html", "strict-same", "md-text")
329 def convertfile(self, storefile, template_store):
330 # Lazy import as mistletoe is expensive
331 from translate.storage.markdown import MarkdownFile
333 # Fake input file with a blank filename
334 mdparser = MarkdownFile(inputfile=NamedBytesIO("", storefile.read()))
335 return self.convert_to_po(mdparser, template_store, use_location=False)
337 def save_content(self, handle) -> None:
338 """Store content to file."""
339 # Lazy import as mistletoe is expensive
340 from translate.convert.po2md import MarkdownTranslator
342 converter = MarkdownTranslator(
343 inputstore=self.store, includefuzzy=True, outputthreshold=None, maxlength=80
344 )
345 templatename = self.template_store.storefile
346 if hasattr(templatename, "name"):
347 templatename = templatename.name
348 with open(templatename, "rb") as templatefile:
349 converter.translate(templatefile, handle)
351 @staticmethod
352 def mimetype() -> str:
353 """Return most common mime type for format."""
354 return "text/markdown"
356 @staticmethod
357 def extension() -> str:
358 """Return most common file extension for format."""
359 return "md"
362class OpenDocumentFormat(ConvertFormat):
363 # Translators: File format name
364 name = gettext_lazy("OpenDocument file")
365 autoload = (
366 "*.sxw",
367 "*.odt",
368 "*.ods",
369 "*.odp",
370 "*.odg",
371 "*.odc",
372 "*.odf",
373 "*.odi",
374 "*.odm",
375 "*.ott",
376 "*.ots",
377 "*.otp",
378 "*.otg",
379 "*.otc",
380 "*.otf",
381 "*.oti",
382 "*.oth",
383 )
384 format_id = "odf"
385 check_flags = ("strict-same",)
386 unit_class = ConvertXliffUnit
388 @staticmethod
389 def convertfile(storefile, template_store): # noqa: ARG004
390 store = xlifffile()
391 store.setfilename(store.getfilenode("NoName"), "odf")
392 contents = open_odf(storefile)
393 for data in contents.values():
394 parse_state = ParseState(no_translate_content_elements, inline_elements)
395 build_store(BytesIO(data), store, parse_state)
396 return store
398 def save_content(self, handle) -> None:
399 """Store content to file."""
400 templatename = self.template_store.storefile
401 if hasattr(templatename, "name"):
402 templatename = templatename.name
403 # This is workaround for weird fuzzy handling in translate-toolkit
404 for unit in self.all_units:
405 if any(state == "translated" for state in unit.get_xliff_states()):
406 unit.set_state(STATE_APPROVED)
408 with open(templatename, "rb") as templatefile:
409 dom_trees = translate_odf(templatefile, self.store)
410 write_odf(templatefile, handle, dom_trees)
412 @staticmethod
413 def mimetype() -> str:
414 """Return most common mime type for format."""
415 return "application/vnd.oasis.opendocument.text"
417 @staticmethod
418 def extension() -> str:
419 """Return most common file extension for format."""
420 return "odt"
422 @staticmethod
423 def needs_target_sync(template_store) -> bool: # noqa: ARG004
424 return True
427class IDMLFormat(ConvertFormat):
428 # Translators: File format name
429 name = gettext_lazy("IDML file")
430 autoload = ("*.idml", "*.idms")
431 format_id = "idml"
432 check_flags = ("strict-same",)
434 @staticmethod
435 def convertfile(storefile, template_store): # noqa: ARG004
436 store = pofile()
438 contents = open_idml(storefile)
440 # Create it here to avoid having repeated ids.
441 id_maker = IdMaker()
443 for filename, translatable_file in contents.items():
444 parse_state = ParseState(NO_TRANSLATE_ELEMENTS, INLINE_ELEMENTS)
445 po_store_adder = make_postore_adder(store, id_maker, filename)
446 build_idml_store(
447 BytesIO(translatable_file),
448 store,
449 parse_state,
450 store_adder=po_store_adder,
451 )
453 return store
455 def save_content(self, handle) -> None:
456 """Store content to file."""
457 templatename = self.template_store.storefile
458 if hasattr(templatename, "name"):
459 templatename = templatename.name
460 with ZipFile(templatename, "r") as template_zip:
461 translatable_files = [
462 filename
463 for filename in template_zip.namelist()
464 if filename.startswith("Stories/")
465 ]
467 dom_trees = translate_idml(templatename, self.store, translatable_files)
469 write_idml(template_zip, handle, dom_trees)
471 @staticmethod
472 def mimetype() -> str:
473 """Return most common mime type for format."""
474 return "application/octet-stream"
476 @staticmethod
477 def extension() -> str:
478 """Return most common file extension for format."""
479 return "idml"
481 @staticmethod
482 def needs_target_sync(template_store) -> bool: # noqa: ARG004
483 return True
486class WindowsRCFormat(ConvertFormat):
487 # Translators: File format name
488 name = gettext_lazy("RC file")
489 format_id = "rc"
490 autoload = ("*.rc",)
491 language_format = "bcp"
493 @staticmethod
494 def needs_target_sync(template_store):
495 return template_store is None
497 @staticmethod
498 def mimetype() -> str:
499 """Return most common media type for format."""
500 return "text/plain"
502 @staticmethod
503 def extension() -> str:
504 """Return most common file extension for format."""
505 return "rc"
507 def convertfile(self, storefile, template_store):
508 input_store = rcfile()
509 input_store.parse(storefile.read())
510 converter = rc2po()
511 if template_store:
512 store = converter.merge_store(template_store.store.rcfile, input_store)
513 else:
514 store = converter.convert_store(input_store)
515 store.rcfile = input_store
516 return store
518 def save_content(self, handle) -> None:
519 """Store content to file."""
520 # Fallback language
521 lang = "LANG_ENGLISH"
522 sublang = "SUBLANG_DEFAULT"
524 # Keep existing language tags
525 storage = self.store.rcfile
526 if storage.lang:
527 lang = storage.lang
528 if storage.sublang:
529 sublang = storage.sublang
531 templatename = self.template_store.storefile
532 if hasattr(templatename, "name"):
533 templatename = templatename.name
534 encoding = "utf-8"
535 with open(templatename, "rb") as templatefile:
536 bom = templatefile.read(2)
537 if bom == codecs.BOM_UTF16_LE or b"\000" in bom:
538 encoding = "utf-16-le"
539 templatefile.seek(0)
540 converter = rerc(
541 templatefile,
542 lang=lang,
543 sublang=sublang,
544 charset=encoding,
545 )
546 outputrclines = converter.convertstore(self.store, includefuzzy=True)
547 try:
548 handle.write(outputrclines.encode(encoding))
549 except UnicodeEncodeError:
550 handle.write(codecs.BOM_UTF16_LE)
551 handle.write(outputrclines.encode("utf-16-le"))
554class PlainTextFormat(ConvertFormat):
555 # Translators: File format name
556 name = gettext_lazy("Plain text file")
557 format_id = "txt"
558 autoload = ("*.txt",)
559 flavour = "plain"
561 @staticmethod
562 def mimetype() -> str:
563 """Return most common media type for format."""
564 return "text/plain"
566 @staticmethod
567 def extension() -> str:
568 """Return most common file extension for format."""
569 return "txt"
571 def convertfile(self, storefile, template_store):
572 input_store = TxtFile(encoding="utf-8", flavour=self.flavour)
573 input_store.parse(storefile.readlines())
574 input_store.filename = os.path.basename(storefile.name)
575 return self.convert_to_po(input_store, template_store)
577 def save_content(self, handle) -> None:
578 """Store content to file."""
579 templatename = self.template_store.storefile
580 if hasattr(templatename, "name"):
581 templatename = templatename.name
582 with open(templatename, "rb") as templatefile:
583 converter = po2txt(
584 input_file=self.store,
585 output_file=None,
586 template_file=templatefile,
587 )
588 outputstring = converter.merge_stores()
589 handle.write(outputstring.encode("utf-8"))
592class DokuWikiFormat(PlainTextFormat):
593 # Translators: File format name
594 name = gettext_lazy("DokuWiki text file")
595 format_id = "dokuwiki"
596 autoload = ("*.dw",)
597 flavour = "dokuwiki"
600class MediaWikiFormat(PlainTextFormat):
601 # Translators: File format name
602 name = gettext_lazy("MediaWiki text file")
603 format_id = "mediawiki"
604 autoload = ("*.mw",)
605 flavour = "mediawiki"