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

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 

5 

6"""Translate Toolkit based file-format wrappers.""" 

7 

8from __future__ import annotations 

9 

10import csv 

11import importlib 

12import inspect 

13import os 

14import re 

15import subprocess 

16from io import StringIO 

17from typing import TYPE_CHECKING, Any, BinaryIO, ClassVar 

18 

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 

39 

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) 

66 

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 

69 

70 from translate.storage.base import TranslationUnit as TranslateToolkitUnit 

71 

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"} 

76 

77 

78class TTKitUnit(TranslationUnit): 

79 template: TranslateToolkitUnit | None 

80 unit: TranslateToolkitUnit 

81 mainunit: TranslateToolkitUnit 

82 parent: TTKitFormat 

83 

84 @cached_property 

85 def locations(self): 

86 """Return a comma-separated list of locations.""" 

87 return ", ".join(self.mainunit.getlocations()) 

88 

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) 

95 

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) 

104 

105 @cached_property 

106 def context(self): 

107 """ 

108 Return context of message. 

109 

110 In some cases we have to use ID here to make all the back-ends consistent. 

111 """ 

112 return self.mainunit.getcontext() 

113 

114 @cached_property 

115 def notes(self): 

116 """Return notes or notes from units.""" 

117 comment = "" 

118 

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() 

121 

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}" 

127 

128 return comment 

129 

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() 

135 

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 

145 

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 ) 

153 

154 def is_readonly(self) -> bool: 

155 return not self.mainunit.istranslatable() 

156 

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 

163 

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) 

171 

172 @cached_property 

173 def flags(self): 

174 """ 

175 Return flags from unit. 

176 

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 

185 

186 def clone_template(self) -> None: 

187 super().clone_template() 

188 

189 # do not copy notes from the template (#11133) 

190 self.unit.removenotes() 

191 

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) 

197 

198 

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) 

208 

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) 

215 

216 @cached_property 

217 def context(self): 

218 """ 

219 Return context of message. 

220 

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 

227 

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() 

237 

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 

244 

245 

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 

259 

260 @staticmethod 

261 def serialize(store): 

262 """Serialize given Translate Toolkit store.""" 

263 return bytes(store) 

264 

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) 

274 

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 

282 

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) 

288 

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 

292 

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) 

304 

305 # Get the class 

306 return getattr(module, class_name) 

307 

308 def get_format_class_kwargs(self) -> dict[str, Any]: 

309 return {} 

310 

311 def get_unit_class_kwargs(self) -> dict[str, Any]: 

312 return {} 

313 

314 def get_store_instance(self, **kwargs): 

315 kwargs.update(self.get_format_class_kwargs()) 

316 store = self.get_class()(**kwargs) 

317 

318 # Apply possible fixups 

319 self.fixup(store) 

320 

321 return store 

322 

323 def parse_store(self, storefile, **kwargs): 

324 """Parse the store.""" 

325 store = self.get_store_instance(**kwargs) 

326 

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() 

333 

334 # Parse the content 

335 store.parse(content) 

336 

337 return store 

338 

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) 

346 

347 def save_content(self, handle) -> None: 

348 """Store content to file.""" 

349 self.store.serialize(handle) 

350 

351 def save(self) -> None: 

352 """Save underlying store to disk.""" 

353 self.save_atomic(self.storefile, self.save_content) 

354 

355 @classmethod 

356 def mimetype(cls): 

357 """Return most common media type for format.""" 

358 return cls.get_class().Mimetypes[0] 

359 

360 @classmethod 

361 def extension(cls): 

362 """Return most common file extension for format.""" 

363 return cls.get_class().Extensions[0] 

364 

365 def is_valid(self) -> bool: 

366 """ 

367 Check whether store seems to be valid. 

368 

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 

375 

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 

391 

392 def create_unit_key( 

393 self, key: str, source: str | list[str] | multistring 

394 ) -> str | multistring: 

395 return key 

396 

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 

414 

415 # Process target 

416 if isinstance(target, list): 

417 target = target[0] if len(target) == 1 else multistring(target) 

418 

419 # Build the unit 

420 unit = self.construct_unit(context) 

421 

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) 

436 

437 if self.use_settarget and self.source_language: 

438 unit.setsource(source, self.source_language) 

439 else: 

440 unit.source = source 

441 

442 if self.use_settarget and self.language_code: 

443 unit.settarget(target, self.language_code) 

444 else: 

445 unit.target = target 

446 

447 return unit 

448 

449 def untranslate_store(self, language) -> None: 

450 """Remove translations from Translate Toolkit store.""" 

451 self.store.settargetlanguage(self.get_language_code(language.code)) 

452 

453 for unit in self.content_units: 

454 if unit.is_translated(): 

455 unit.untranslate(language) 

456 

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 

463 

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) 

487 

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) 

511 

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 ) 

520 

521 def delete_unit(self, ttkit_unit) -> str | None: 

522 self.store.removeunit(ttkit_unit) 

523 return None 

524 

525 

526class PropertiesUnit(KeyValueUnit): 

527 """Wrapper for properties-based units.""" 

528 

529 @cached_property 

530 def locations(self) -> str: 

531 """Return a comma-separated list of locations.""" 

532 return "" 

533 

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)) 

541 

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) 

548 

549 

550class PoUnit(TTKitUnit): 

551 """Wrapper for gettext PO unit.""" 

552 

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 = [] 

560 

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 

572 

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) 

579 

580 @cached_property 

581 def locations(self): 

582 """ 

583 Return comma separated list of locations. 

584 

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()) 

590 

591 

592class PoMonoUnit(PoUnit): 

593 @cached_property 

594 def context(self): 

595 """ 

596 Return context of message. 

597 

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 

608 

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) 

622 

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 = ['""'] 

628 

629 super().set_target(target) 

630 

631 

632class XliffUnit(TTKitUnit): 

633 """ 

634 Wrapper unit for XLIFF. 

635 

636 XLIFF is special in Translate Toolkit — it uses locations for what 

637 is context in other formats. 

638 """ 

639 

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 

645 

646 def get_xliff_units(self): 

647 # Iterate over poxliff sub-units, or main unit 

648 return getattr(self.unit, "units", [self.unit]) 

649 

650 def get_xliff_nodes(self): 

651 return (self.get_unit_node(unit) for unit in self.get_xliff_units()) 

652 

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 

662 

663 @cached_property 

664 def context(self): 

665 """ 

666 Return context of message. 

667 

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, "///") 

675 

676 def is_translated(self): 

677 """ 

678 Check whether unit is translated. 

679 

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() 

684 

685 def is_fuzzy(self, fallback: bool = False) -> bool: 

686 """ 

687 Check whether unit needs edit. 

688 

689 The isfuzzy on XLIFF is really messing up the "approved" flag with "fuzzy" 

690 flag, leading to various problems. 

691 

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 ) 

697 

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" 

713 

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) 

718 

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 

726 

727 def has_content(self): 

728 """ 

729 Check whether unit has content. 

730 

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 ) 

739 

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) 

746 

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 ) 

755 

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 

768 

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) 

778 

779 @cached_property 

780 def target(self): 

781 """Return target string from a Translate Toolkit unit.""" 

782 if self.unit is None: 

783 return "" 

784 

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 "" 

790 

791 return get_string(self.unit.target) 

792 

793 

794class RichXliffUnit(XliffUnit): 

795 """Wrapper unit for XLIFF with XML elements.""" 

796 

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) 

806 

807 @cached_property 

808 def target(self): 

809 """Return target string from a Translate Toolkit unit.""" 

810 if self.unit is None: 

811 return "" 

812 

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 "" 

818 

819 return rich_to_xliff_string(self.unit.rich_target) 

820 

821 @cached_property 

822 def flags(self): 

823 flags = super().flags 

824 flags.merge("xml-text") 

825 return flags 

826 

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 

850 

851 

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 

858 

859 @cached_property 

860 def source(self): 

861 return get_string(self.mainunit.target) 

862 

863 def has_content(self): 

864 """ 

865 Check whether unit has content. 

866 

867 The attribute-less units will have context None. 

868 """ 

869 if self.context is None: 

870 return False 

871 return super().has_content() 

872 

873 

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() 

880 

881 

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 

891 

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 

901 

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 

911 

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() 

918 

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) 

927 

928 

929class MonolingualSimpleUnit(MonolingualIDUnit): 

930 @cached_property 

931 def locations(self) -> str: 

932 return "" 

933 

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) 

939 

940 def has_content(self) -> bool: 

941 return not self.mainunit.isheader() 

942 

943 def is_readonly(self) -> bool: 

944 return False 

945 

946 

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 

954 

955 

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 

963 

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 

973 

974 

975class CSVUnit(MonolingualSimpleUnit): 

976 @staticmethod 

977 def unescape_csv(string): 

978 r""" 

979 Remove Excel-specific escaping from CSV. 

980 

981 See weblate.formats.exporters.CSVExporter.string_filter 

982 

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) 

993 

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() 

1002 

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()) 

1010 

1011 @cached_property 

1012 def locations(self): 

1013 return self.mainunit.location 

1014 

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) 

1022 

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) 

1036 

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 

1046 

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() 

1052 

1053 

1054class RESXUnit(TTKitUnit): 

1055 @cached_property 

1056 def locations(self) -> str: 

1057 return "" 

1058 

1059 @cached_property 

1060 def context(self): 

1061 if self.template is not None: 

1062 return self.template.getid() 

1063 return self.unit.getid() 

1064 

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) 

1070 

1071 

1072class PHPUnit(KeyValueUnit): 

1073 @cached_property 

1074 def locations(self) -> str: 

1075 return "" 

1076 

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()) 

1082 

1083 @cached_property 

1084 def target(self): 

1085 if self.unit is None: 

1086 return "" 

1087 return get_string(self.unit.source) 

1088 

1089 

1090class INIUnit(TTKitUnit): 

1091 @cached_property 

1092 def locations(self) -> str: 

1093 return "" 

1094 

1095 @cached_property 

1096 def context(self): 

1097 if self.template is not None: 

1098 return self.template.location 

1099 return self.unit.location 

1100 

1101 def has_content(self) -> bool: 

1102 return True 

1103 

1104 def is_readonly(self) -> bool: 

1105 return False 

1106 

1107 

1108class AndroidUnit(MonolingualIDUnit): 

1109 """Wrapper unit for Android Resource.""" 

1110 

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) 

1116 

1117 

1118class BasePoFormat(TTKitFormat): 

1119 loader = pofile 

1120 plural_preference = None 

1121 supports_plural: bool = True 

1122 store: pofile 

1123 

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 {} 

1129 

1130 try: 

1131 number, formula = Plural.parse_plural_forms(header["Plural-Forms"]) 

1132 except (ValueError, KeyError): 

1133 return super().get_plural(language, store) 

1134 

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 

1139 

1140 # Create new one 

1141 return Plural.objects.create( 

1142 language=language, 

1143 source=Plural.SOURCE_GETTEXT, 

1144 number=number, 

1145 formula=formula, 

1146 ) 

1147 

1148 def untranslate_store(self, language) -> None: 

1149 """Remove translations from Translate Toolkit store.""" 

1150 super().untranslate_store(language) 

1151 plural = language.plural 

1152 

1153 self.store.updateheader( 

1154 add=True, 

1155 last_translator="Automatically generated", 

1156 plural_forms=plural.plural_form, 

1157 language_team="none", 

1158 ) 

1159 

1160 def update_header(self, **kwargs) -> None: 

1161 """Update store header if available.""" 

1162 kwargs["x_generator"] = f"Weblate {weblate.utils.version.VERSION}" 

1163 

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" 

1172 

1173 self.store.updateheader(add=True, **kwargs) 

1174 

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) 

1182 

1183 

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 

1191 

1192 @classmethod 

1193 def get_new_file_content(cls) -> bytes: 

1194 """Empty PO file content.""" 

1195 return b"" 

1196 

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. 

1203 

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) 

1217 

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)) 

1253 

1254 

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 

1271 

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 

1278 

1279 

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 ) 

1293 

1294 

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""" 

1312 

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 

1321 

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 

1332 

1333 

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 

1340 

1341 

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 

1349 

1350 

1351class PropertiesBaseFormat(TTKitFormat): 

1352 unit_class = PropertiesUnit 

1353 

1354 def is_valid(self): 

1355 result = super().is_valid() 

1356 if not result: 

1357 return False 

1358 

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 

1363 

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" 

1369 

1370 def construct_unit(self, source: str): 

1371 return self.store.UnitClass(source, personality=self.store.personality.name) 

1372 

1373 

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" 

1382 

1383 

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" 

1390 

1391 

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",) 

1400 

1401 

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" 

1411 

1412 

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" 

1424 

1425 

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",) 

1434 

1435 

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 

1445 

1446 

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" 

1452 

1453 

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 

1462 

1463 @staticmethod 

1464 def mimetype() -> str: 

1465 """Return most common media type for format.""" 

1466 return "text/x-php" 

1467 

1468 @staticmethod 

1469 def extension() -> str: 

1470 """Return most common file extension for format.""" 

1471 return "php" 

1472 

1473 

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 

1480 

1481 

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 

1494 

1495 

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 

1517 

1518 

1519class MOKOFormat(AndroidFormat): 

1520 # Translators: File format name 

1521 name = gettext_lazy("Mobile Kotlin Resource") 

1522 format_id = "moko-resource" 

1523 loader = ("aresource", "MOKOResourceFile") 

1524 

1525 

1526class CMPFormat(AndroidFormat): 

1527 # Translators: File format name 

1528 name = gettext_lazy("Compose Multiplatform Resource") 

1529 format_id = "cmp-resource" 

1530 loader = ("aresource", "CMPResourceFile") 

1531 

1532 

1533class DictStoreFormat(TTKitFormat): 

1534 @classmethod 

1535 def validate_context(cls, context: str) -> None: 

1536 id_class = cls.get_class().UnitClass.IdClass 

1537 

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 

1544 

1545 

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 

1555 

1556 @staticmethod 

1557 def mimetype() -> str: 

1558 """Return most common media type for format.""" 

1559 return "application/json" 

1560 

1561 @staticmethod 

1562 def extension() -> str: 

1563 """Return most common file extension for format.""" 

1564 return "json" 

1565 

1566 

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, ...] = () 

1573 

1574 

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 

1584 

1585 

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 

1595 

1596 

1597class I18NextV4Format(I18NextFormat): 

1598 # Translators: File format name 

1599 name = gettext_lazy("i18next JSON file v4") 

1600 format_id = "i18nextv4" 

1601 loader = ("jsonl10n", "I18NextV4File") 

1602 

1603 

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 

1612 

1613 

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 

1621 

1622 

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",) 

1631 

1632 

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 

1641 

1642 

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",) 

1650 

1651 

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" 

1660 

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 

1686 

1687 @staticmethod 

1688 def mimetype() -> str: 

1689 """Return most common media type for format.""" 

1690 return "text/csv" 

1691 

1692 @staticmethod 

1693 def extension() -> str: 

1694 """Return most common file extension for format.""" 

1695 return "csv" 

1696 

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 

1709 

1710 def parse_store(self, storefile, *, dialect: str | None = None): 

1711 """Parse the store.""" 

1712 content, filename = self.get_content_and_filename(storefile) 

1713 

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 

1724 

1725 fileobj = StringIO( 

1726 store.detect_encoding(content, default_encodings=["utf-8", "utf-16"])[0] 

1727 ) 

1728 

1729 # Try reading header 

1730 reader = csv.reader(fileobj, store.dialect) 

1731 header = next(reader) 

1732 fileobj.close() 

1733 

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 

1738 

1739 return self.parse_simple_csv(content, filename, header=header) 

1740 

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 

1753 

1754 

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" 

1761 

1762 

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" 

1769 

1770 @staticmethod 

1771 def extension() -> str: 

1772 """Return most common file extension for format.""" 

1773 return "csv" 

1774 

1775 def parse_store(self, storefile): 

1776 """Parse the store.""" 

1777 content, filename = self.get_content_and_filename(storefile) 

1778 

1779 return self.parse_simple_csv(content, filename) 

1780 

1781 

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, ...] = () 

1788 

1789 

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, ...] = () 

1796 

1797 

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" 

1806 

1807 @staticmethod 

1808 def mimetype() -> str: 

1809 """Return most common media type for format.""" 

1810 return "text/yaml" 

1811 

1812 @staticmethod 

1813 def extension() -> str: 

1814 """Return most common file extension for format.""" 

1815 return "yml" 

1816 

1817 

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 

1825 

1826 

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 

1836 

1837 @staticmethod 

1838 def mimetype() -> str: 

1839 """Return most common media type for format.""" 

1840 return "application/xml-dtd" 

1841 

1842 @staticmethod 

1843 def extension() -> str: 

1844 """Return most common file extension for format.""" 

1845 return "dtd" 

1846 

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()) 

1851 

1852 

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 

1859 

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) 

1866 

1867 def is_translated(self): 

1868 """Check whether unit is translated.""" 

1869 return self.has_translation() 

1870 

1871 

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": {}} 

1881 

1882 @staticmethod 

1883 def mimetype() -> str: 

1884 """Return most common media type for format.""" 

1885 return "text/plain" 

1886 

1887 

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",) 

1894 

1895 

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",) 

1902 

1903 

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",) 

1910 

1911 

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>' 

1920 

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 } 

1927 

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 } 

1933 

1934 

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>""" 

1947 

1948 

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" 

1957 

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" 

1963 

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" 

1969 

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 

1981 

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 

1991 

1992 

1993class InnoSetupINIFormat(INIFormat): 

1994 # Translators: File format name 

1995 name = gettext_lazy("Inno Setup INI file") 

1996 format_id = "islu" 

1997 loader = ("ini", "inifile") 

1998 

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" 

2004 

2005 def get_format_class_kwargs(self): 

2006 return {"dialect": "inno"} 

2007 

2008 

2009class XWikiUnit(PropertiesUnit): 

2010 """ 

2011 Dedicated unit for XWiki. 

2012 

2013 Inspired from PropertiesUnit, allow to override the methods to use the right 

2014 XWikiDialect methods for decoding properties. 

2015 """ 

2016 

2017 @cached_property 

2018 def source(self): 

2019 # Need to decode property encoded string 

2020 return get_string(quote.xwiki_properties_decode(super().source)) 

2021 

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) 

2034 

2035 

2036class XWikiPropertiesFormat(PropertiesBaseFormat): 

2037 """ 

2038 Represents an XWiki Java Properties translation file. 

2039 

2040 This format specification is detailed in 

2041 https://dev.xwiki.org/xwiki/bin/view/Community/XWiki%20Translations%20Formats/#HXWikiJavaProperties 

2042 """ 

2043 

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 

2055 

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 

2061 

2062 # We empty the store units since we want to control what we'll serialize 

2063 self.store.units = [] 

2064 

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 

2075 

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) 

2089 

2090 self.store.serialize(handle) 

2091 

2092 

2093class XWikiPagePropertiesFormat(XWikiPropertiesFormat): 

2094 """ 

2095 Represents an XWiki Page Properties translation file. 

2096 

2097 This format specification is detailed in 

2098 https://dev.xwiki.org/xwiki/bin/view/Community/XWiki%20Translations%20Formats/#HXWikiPageProperties 

2099 """ 

2100 

2101 name = "XWiki Page Properties" 

2102 format_id = "xwiki-page-properties" 

2103 loader = ("properties", "XWikiPageProperties") 

2104 force_encoding = "utf-8" 

2105 

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) 

2110 

2111 

2112class XWikiFullPageFormat(XWikiPagePropertiesFormat): 

2113 """ 

2114 Represents an XWiki Full Page translation file. 

2115 

2116 This format specification is detailed in 

2117 https://dev.xwiki.org/xwiki/bin/view/Community/XWiki%20Translations%20Formats/#HXWikiFullContentTranslation 

2118 """ 

2119 

2120 name = "XWiki Full Page" 

2121 format_id = "xwiki-fullpage" 

2122 loader = ("properties", "XWikiFullPage") 

2123 

2124 

2125class TBXUnit(TTKitUnit): 

2126 unit: tbxunit 

2127 

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 ) 

2133 

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) 

2142 

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 

2147 

2148 return "\n".join(notes) 

2149 

2150 @cached_property 

2151 def context(self): 

2152 return self.unit.xmlelement.get("id") or "" 

2153 

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) 

2158 

2159 @cached_property 

2160 def explanation(self) -> str: 

2161 return self.unit.getnotes("translator") 

2162 

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) 

2167 

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) 

2179 

2180 return "\n".join(notes) 

2181 

2182 @cached_property 

2183 def flags(self): 

2184 flags = super().flags 

2185 

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 

2197 

2198 return flags 

2199 

2200 

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 

2228 

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() 

2250 

2251 

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 

2260 

2261 

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 

2272 

2273 @classmethod 

2274 def mimetype(cls) -> str: 

2275 return "text/x-catkeys" 

2276 

2277 @classmethod 

2278 def extension(cls) -> str: 

2279 return "catkeys" 

2280 

2281 

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 

2297 

2298 @staticmethod 

2299 def mimetype() -> str: 

2300 """Return most common media type for format.""" 

2301 return "application/xml" 

2302 

2303 @staticmethod 

2304 def extension() -> str: 

2305 """Return most common file extension for format.""" 

2306 return "stringsdict" 

2307 

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 

2314 

2315 from weblate.lang.models import Plural 

2316 

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] 

2324 

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) 

2330 

2331 

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 

2346 

2347 @cached_property 

2348 def flags(self): 

2349 flags = super().flags 

2350 flags.set_value("fluent-type", self.mainunit.fluent_type) 

2351 return flags 

2352 

2353 

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 ) 

2374 

2375 @staticmethod 

2376 def mimetype() -> str: 

2377 """Return most common media type for format.""" 

2378 return "text/x-fluent" 

2379 

2380 @staticmethod 

2381 def extension() -> str: 

2382 """Return most common file extension for format.""" 

2383 return "ftl" 

2384 

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