Coverage for adhoc-cicd-odoo-odoo / odoo / tools / translate.py: 46%

1085 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-09 18:22 +0000

1# Part of Odoo. See LICENSE file for full copyright and licensing details. 

2 

3# When using quotation marks in translation strings, please use curly quotes (“”) 

4# instead of straight quotes (""). On Linux, the keyboard shortcuts are: 

5# AltGr + V for the opening curly quotes “ 

6# AltGr + B for the closing curly quotes ” 

7 

8from __future__ import annotations 

9 

10import codecs 

11import fnmatch 

12import functools 

13import inspect 

14import io 

15import json 

16import locale 

17import logging 

18import os 

19import polib 

20import re 

21import tarfile 

22import typing 

23from collections import defaultdict, namedtuple 

24from collections.abc import Iterable, Iterator 

25from contextlib import suppress 

26from datetime import datetime 

27from os.path import join 

28from pathlib import Path 

29from tokenize import generate_tokens, STRING, NEWLINE, INDENT, DEDENT 

30 

31from babel.messages import extract 

32from lxml import etree, html 

33from markupsafe import escape, Markup 

34from psycopg2.extras import Json 

35 

36import odoo 

37from odoo.exceptions import UserError 

38from .config import config 

39from .i18n import format_list 

40from .misc import file_open, file_path, get_iso_codes, split_every, OrderedSet, ReadonlyDict, SKIPPED_ELEMENT_TYPES 

41 

42if typing.TYPE_CHECKING: 

43 from odoo.api import Environment 

44 

45__all__ = [ 

46 "_", 

47 "LazyTranslate", 

48 "html_translate", 

49 "xml_translate", 

50] 

51 

52_logger = logging.getLogger(__name__) 

53 

54PYTHON_TRANSLATION_COMMENT = 'odoo-python' 

55 

56# translation used for javascript code in web client 

57JAVASCRIPT_TRANSLATION_COMMENT = 'odoo-javascript' 

58 

59SKIPPED_ELEMENTS = ('script', 'style', 'title') 

60 

61# these direct uses of CSV are ok. 

62import csv # pylint: disable=deprecated-module 

63 

64# which elements are translated inline 

65TRANSLATED_ELEMENTS = { 

66 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'del', 'dfn', 'em', 

67 'font', 'i', 'ins', 'kbd', 'keygen', 'mark', 'math', 'meter', 'output', 

68 'progress', 'q', 'ruby', 's', 'samp', 'small', 'span', 'strong', 'sub', 

69 'sup', 'time', 'u', 'var', 'wbr', 'text', 'select', 'option', 

70} 

71 

72# Attributes from QWeb views that must be translated. 

73# ⚠ Note that it implicitly includes their t-attf-* equivalent. 

74TRANSLATED_ATTRS = { 

75 'string', 'add-label', 'help', 'sum', 'avg', 'confirm', 'placeholder', 'alt', 'title', 'aria-label', 

76 'aria-keyshortcuts', 'aria-placeholder', 'aria-roledescription', 'aria-valuetext', 

77 'value_label', 'data-tooltip', 'label', 'confirm-label', 'confirm-title', 'cancel-label', 

78} 

79 

80TRANSLATED_ATTRS.update({f't-attf-{attr}' for attr in TRANSLATED_ATTRS}) 

81 

82# {column value of "ir_model_fields"."translate": orm field.translate} 

83FIELD_TRANSLATE = { 

84 None: False, 

85 'standard': True, 

86} 

87 

88 

89def is_translatable_attrib(key, node): 

90 if not key: 90 ↛ 91line 90 didn't jump to line 91 because the condition on line 90 was never true

91 return False 

92 if 't-call' not in node.attrib and key in TRANSLATED_ATTRS: 

93 return True 

94 return key.endswith('.translate') 

95 

96def is_translatable_attrib_value(node): 

97 # check if the value attribute of a node must be translated 

98 classes = node.attrib.get('class', '').split(' ') 

99 return ( 

100 (node.tag == 'input' and node.attrib.get('type', 'text') == 'text') 

101 and 'datetimepicker-input' not in classes 

102 or (node.tag == 'input' and node.attrib.get('type') == 'hidden') 

103 and 'o_translatable_input_hidden' in classes 

104 ) 

105 

106def is_translatable_attrib_text(node): 

107 return node.tag == 'field' and node.attrib.get('widget', '') == 'url' 

108 

109 

110# This should match the list provided to OWL (see translatableAttributes). 

111OWL_TRANSLATED_ATTRS = { 

112 "alt", 

113 "aria-label", 

114 "aria-placeholder", 

115 "aria-roledescription", 

116 "aria-valuetext", 

117 "data-tooltip", 

118 "label", 

119 "placeholder", 

120 "title", 

121} 

122 

123avoid_pattern = re.compile(r"\s*<!DOCTYPE", re.IGNORECASE | re.MULTILINE | re.UNICODE) 

124space_pattern = re.compile(r"[\s\uFEFF]*") # web_editor uses \uFEFF as ZWNBSP 

125 

126# regexpr for string formatting and extract ( ruby-style )|( jinja-style ) used in `_compile_format` 

127FORMAT_REGEX = re.compile(r'(?:#\{(.+?)\})|(?:\{\{(.+?)\}\})') 

128 

129def translate_format_string_expression(term, callback): 

130 expressions = {} 

131 def add(exp_py): 

132 index = len(expressions) 

133 expressions[str(index)] = exp_py 

134 return '{{%s}}' % index 

135 term_without_py = FORMAT_REGEX.sub(lambda g: add(g.group(0)), term) 

136 translated_value = callback(term_without_py) 

137 if translated_value: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 return FORMAT_REGEX.sub(lambda g: expressions.get(g.group(0)[2:-2], 'None'), translated_value) 

139 

140def translate_xml_node(node, callback, parse, serialize): 

141 """ Return the translation of the given XML/HTML node. 

142 

143 :param node: 

144 :param callback: callback(text) returns translated text or None 

145 :param parse: parse(text) returns a node (text is unicode) 

146 :param serialize: serialize(node) returns unicode text 

147 """ 

148 

149 def nonspace(text): 

150 """ Return whether ``text`` is a string with non-space characters. """ 

151 return bool(text) and not space_pattern.fullmatch(text) 

152 

153 def is_force_inline(node): 

154 """ Return whether ``node`` is marked as it should be translated as 

155 one term. 

156 """ 

157 return "o_translate_inline" in node.attrib.get("class", "").split() 

158 

159 def translatable(node, force_inline=False): 

160 """ Return whether the given node can be translated as a whole. """ 

161 # Some specific nodes (e.g., text highlights) have an auto-updated DOM 

162 # structure that makes them impossible to translate. 

163 # The introduction of a translation `<span>` in the middle of their 

164 # hierarchy breaks their functionalities. We need to force them to be 

165 # translated as a whole using the `o_translate_inline` class. 

166 force_inline = force_inline or is_force_inline(node) 

167 return ( 

168 (force_inline or node.tag in TRANSLATED_ELEMENTS) 

169 # Nodes with directives are not translatable. Directives usually 

170 # start with `t-`, but this prefix is optional for `groups` (see 

171 # `_compile_directive_groups` which reads `t-groups` and `groups`) 

172 and not any(key.startswith("t-") or key == 'groups' or key.endswith(".translate") for key in node.attrib) 

173 and all(translatable(child, force_inline) for child in node) 

174 ) 

175 

176 def hastext(node, pos=0, force_inline=False): 

177 """ Return whether the given node contains some text to translate at the 

178 given child node position. The text may be before the child node, 

179 inside it, or after it. 

180 """ 

181 force_inline = force_inline or is_force_inline(node) 

182 return ( 

183 # there is some text before node[pos] 

184 nonspace(node[pos-1].tail if pos else node.text) 

185 or ( 

186 pos < len(node) 

187 and translatable(node[pos], force_inline) 

188 and ( 

189 any( # attribute to translate 

190 val and ( 

191 is_translatable_attrib(key, node) or 

192 (key == 'value' and is_translatable_attrib_value(node[pos])) or 

193 (key == 'text' and is_translatable_attrib_text(node[pos])) 

194 ) 

195 for key, val in node[pos].attrib.items() 

196 ) 

197 # node[pos] contains some text to translate 

198 or hastext(node[pos], 0, force_inline) 

199 # node[pos] has no text, but there is some text after it 

200 or hastext(node, pos + 1, force_inline) 

201 ) 

202 ) 

203 ) 

204 

205 def process(node): 

206 """ Translate the given node. """ 

207 if ( 

208 isinstance(node, SKIPPED_ELEMENT_TYPES) 

209 or node.tag in SKIPPED_ELEMENTS 

210 or node.get('t-translation', "").strip() == "off" 

211 or node.tag == 'attribute' and node.get('name') not in ('value', 'text') and not is_translatable_attrib(node.get('name'), node) 

212 or node.getparent() is None and avoid_pattern.match(node.text or "") 

213 ): 

214 return 

215 

216 pos = 0 

217 while True: 

218 # check for some text to translate at the given position 

219 if hastext(node, pos): 

220 # move all translatable children nodes from the given position 

221 # into a <div> element 

222 div = etree.Element('div') 

223 div.text = (node[pos-1].tail if pos else node.text) or '' 

224 while pos < len(node) and translatable(node[pos], is_force_inline(node)): 

225 div.append(node[pos]) 

226 

227 # translate the content of the <div> element as a whole 

228 content = serialize(div)[5:-6] 

229 original = content.strip() 

230 translated = callback(original) 

231 if translated: 

232 result = content.replace(original, translated) 

233 # <div/> is used to auto fix crapy result 

234 result_elem = parse_html(f"<div>{result}</div>") 

235 # change the tag to <span/> which is one of TRANSLATED_ELEMENTS 

236 # so that 'result_elem' can be checked by translatable and hastext 

237 result_elem.tag = 'span' 

238 if translatable(result_elem) and hastext(result_elem): 

239 div = result_elem 

240 if pos: 

241 node[pos-1].tail = div.text 

242 else: 

243 node.text = div.text 

244 

245 # move the content of the <div> element back inside node 

246 while len(div) > 0: 

247 node.insert(pos, div[0]) 

248 pos += 1 

249 

250 if pos >= len(node): 

251 break 

252 

253 # node[pos] is not translatable as a whole, process it recursively 

254 process(node[pos]) 

255 pos += 1 

256 

257 # translate the attributes of the node 

258 for key, val in node.attrib.items(): 

259 if nonspace(val): 

260 if ( 

261 is_translatable_attrib(key, node) or 

262 (key == 'value' and is_translatable_attrib_value(node)) or 

263 (key == 'text' and is_translatable_attrib_text(node)) 

264 ): 

265 if key.startswith('t-'): 

266 value = translate_format_string_expression(val.strip(), callback) 

267 else: 

268 value = callback(val.strip()) 

269 node.set(key, value or val) 

270 

271 process(node) 

272 

273 return node 

274 

275 

276def parse_xml(text): 

277 return etree.fromstring(text) 

278 

279def serialize_xml(node): 

280 return etree.tostring(node, method='xml', encoding='unicode') 

281 

282 

283MODIFIER_ATTRS = {"invisible", "readonly", "required", "column_invisible", "attrs"} 

284def xml_term_adapter(term_en): 

285 """ 

286 Returns an `adapter(term)` function that will ensure the modifiers are copied 

287 from the base `term_en` to the translated `term` when the XML structure of 

288 both terms match. `term_en` and any input `term` to the adapter must be valid 

289 XML terms. Using the adapter only makes sense if `term_en` contains some tags 

290 from TRANSLATED_ELEMENTS. 

291 """ 

292 orig_node = parse_xml(f"<div>{term_en}</div>") 

293 

294 def same_struct_iter(left, right): 

295 if left.tag != right.tag or len(left) != len(right): 

296 raise ValueError("Non matching struct") 

297 yield left, right 

298 left_iter = left.iterchildren() 

299 right_iter = right.iterchildren() 

300 for lc, rc in zip(left_iter, right_iter): 

301 yield from same_struct_iter(lc, rc) 

302 

303 def adapter(term): 

304 new_node = parse_xml(f"<div>{term}</div>") 

305 try: 

306 for orig_n, new_n in same_struct_iter(orig_node, new_node): 

307 removed_attrs = [k for k in new_n.attrib if k in MODIFIER_ATTRS and k not in orig_n.attrib] 

308 for k in removed_attrs: 

309 del new_n.attrib[k] 

310 keep_attrs = {k: v for k, v in orig_n.attrib.items()} 

311 new_n.attrib.update(keep_attrs) 

312 except ValueError: # non-matching structure 

313 return None 

314 

315 # remove tags <div> and </div> from result 

316 return serialize_xml(new_node)[5:-6] 

317 

318 return adapter 

319 

320 

321_HTML_PARSER = etree.HTMLParser(encoding='utf8') 

322 

323def parse_html(text): 

324 try: 

325 parse = html.fragment_fromstring(text, parser=_HTML_PARSER) 

326 except (etree.ParserError, TypeError) as e: 

327 raise UserError(_("Error while parsing view:\n\n%s") % e) from e 

328 return parse 

329 

330def serialize_html(node): 

331 return etree.tostring(node, method='html', encoding='unicode') 

332 

333 

334def xml_translate(callback, value): 

335 """ Translate an XML value (string), using `callback` for translating text 

336 appearing in `value`. 

337 """ 

338 if not value: 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true

339 return value 

340 

341 try: 

342 root = parse_xml(value) 

343 result = translate_xml_node(root, callback, parse_xml, serialize_xml) 

344 return serialize_xml(result) 

345 except etree.ParseError: 

346 # fallback for translated terms: use an HTML parser and wrap the term 

347 root = parse_html(u"<div>%s</div>" % value) 

348 result = translate_xml_node(root, callback, parse_xml, serialize_xml) 

349 # remove tags <div> and </div> from result 

350 return serialize_xml(result)[5:-6] 

351 

352def xml_term_converter(value): 

353 """ Convert the HTML fragment ``value`` to XML if necessary 

354 """ 

355 # wrap value inside a div and parse it as HTML 

356 div = f"<div>{value}</div>" 

357 root = etree.fromstring(div, etree.HTMLParser()) 

358 # root is html > body > div 

359 # serialize div as XML and discard surrounding tags 

360 return etree.tostring(root[0][0], encoding='unicode')[5:-6] 

361 

362def html_translate(callback, value): 

363 """ Translate an HTML value (string), using `callback` for translating text 

364 appearing in `value`. 

365 """ 

366 if not value: 366 ↛ 367line 366 didn't jump to line 367 because the condition on line 366 was never true

367 return value 

368 

369 try: 

370 # value may be some HTML fragment, wrap it into a div 

371 root = parse_html("<div>%s</div>" % value) 

372 result = translate_xml_node(root, callback, parse_html, serialize_html) 

373 # remove tags <div> and </div> from result 

374 value = serialize_html(result)[5:-6].replace('\xa0', '&nbsp;') 

375 except ValueError: 

376 _logger.exception("Cannot translate malformed HTML, using source value instead") 

377 

378 return value 

379 

380def html_term_converter(value): 

381 """ Convert the HTML fragment ``value`` to XML if necessary 

382 """ 

383 # wrap value inside a div and parse it as HTML 

384 div = f"<div>{value}</div>" 

385 root = etree.fromstring(div, etree.HTMLParser()) 

386 # root is html > body > div 

387 # serialize div as HTML and discard surrounding tags 

388 return etree.tostring(root[0][0], encoding='unicode', method='html')[5:-6] 

389 

390 

391def get_text_content(term): 

392 """ Return the textual content of the given term. """ 

393 content = html.fromstring(term).text_content() 

394 return " ".join(content.split()) 

395 

396def is_text(term): 

397 """ Return whether the term has only text. """ 

398 return len(html.fromstring(f"<_>{term}</_>")) == 0 

399 

400xml_translate.get_text_content = get_text_content 

401html_translate.get_text_content = get_text_content 

402 

403xml_translate.term_converter = xml_term_converter 

404html_translate.term_converter = html_term_converter 

405 

406xml_translate.is_text = is_text 

407html_translate.is_text = is_text 

408 

409xml_translate.term_adapter = xml_term_adapter 

410 

411FIELD_TRANSLATE['html_translate'] = html_translate 

412FIELD_TRANSLATE['xml_translate'] = xml_translate 

413 

414 

415def get_translation(module: str, lang: str, source: str, args: tuple | dict) -> str: 

416 """Translate and format using a module, language, source text and args.""" 

417 # get the translation by using the language 

418 assert lang, "missing language for translation" 

419 if lang == 'en_US': 419 ↛ 422line 419 didn't jump to line 422 because the condition on line 419 was always true

420 translation = source 

421 else: 

422 assert module, "missing module name for translation" 

423 translation = code_translations.get_python_translations(module, lang).get(source, source) 

424 # skip formatting if we have no args 

425 if not args: 

426 return translation 

427 # we need to check the args for markup values and for lazy translations 

428 args_is_dict = isinstance(args, dict) 

429 if any(isinstance(a, Markup) for a in (args.values() if args_is_dict else args)): 

430 translation = escape(translation) 

431 if any(isinstance(a, LazyGettext) for a in (args.values() if args_is_dict else args)): 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true

432 if args_is_dict: 

433 args = {k: v._translate(lang) if isinstance(v, LazyGettext) else v for k, v in args.items()} 

434 else: 

435 args = tuple(v._translate(lang) if isinstance(v, LazyGettext) else v for v in args) 

436 if any(isinstance(a, Iterable) and not isinstance(a, (str, bytes)) for a in (args.values() if args_is_dict else args)): 436 ↛ 438line 436 didn't jump to line 438 because the condition on line 436 was never true

437 # automatically format list-like arguments in a localized way 

438 def process_translation_arg(v): 

439 return format_list(env=None, lst=v, lang_code=lang) if isinstance(v, Iterable) and not isinstance(v, (str, bytes)) else v 

440 if args_is_dict: 

441 args = {k: process_translation_arg(v) for k, v in args.items()} 

442 else: 

443 args = tuple(process_translation_arg(v) for v in args) 

444 # format 

445 try: 

446 return translation % args 

447 except (TypeError, ValueError, KeyError): 

448 bad = translation 

449 # fallback: apply to source before logging exception (in case source fails) 

450 translation = source % args 

451 _logger.exception('Bad translation %r for string %r', bad, source) 

452 return translation 

453 

454 

455def get_translated_module(arg: str | int | typing.Any) -> str: # frame not represented as hint 

456 """Get the addons name. 

457 

458 :param arg: can be any of the following: 

459 str ("name_of_module") returns itself; 

460 str (__name__) use to resolve module name; 

461 int is number of frames to go back to the caller; 

462 frame of the caller function 

463 """ 

464 if isinstance(arg, str): 464 ↛ 474line 464 didn't jump to line 474 because the condition on line 464 was always true

465 if arg.startswith('odoo.addons.'): 

466 # get the name of the module 

467 return arg.split('.')[2] 

468 if '.' in arg or not arg: 468 ↛ 470line 468 didn't jump to line 470 because the condition on line 468 was never true

469 # module name is not in odoo.addons. 

470 return 'base' 

471 else: 

472 return arg 

473 else: 

474 if isinstance(arg, int): 

475 frame = inspect.currentframe() 

476 while arg > 0: 

477 arg -= 1 

478 frame = frame.f_back 

479 else: 

480 frame = arg 

481 if not frame: 

482 return 'base' 

483 if (module_name := frame.f_globals.get("__name__")) and module_name.startswith('odoo.addons.'): 

484 # just a quick lookup because `get_resource_from_path is slow compared to this` 

485 return module_name.split('.')[2] 

486 path = inspect.getfile(frame) 

487 from odoo.modules import get_resource_from_path # noqa: PLC0415 

488 path_info = get_resource_from_path(path) 

489 return path_info[0] if path_info else 'base' 

490 

491 

492def _get_cr(frame): 

493 # try, in order: cr, cursor, self.env.cr, self.cr, 

494 # request.env.cr 

495 if 'cr' in frame.f_locals: 495 ↛ 496line 495 didn't jump to line 496 because the condition on line 495 was never true

496 return frame.f_locals['cr'] 

497 if 'cursor' in frame.f_locals: 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true

498 return frame.f_locals['cursor'] 

499 if (local_self := frame.f_locals.get('self')) is not None: 499 ↛ 504line 499 didn't jump to line 504 because the condition on line 499 was always true

500 if (local_env := getattr(local_self, 'env', None)) is not None: 500 ↛ 502line 500 didn't jump to line 502 because the condition on line 500 was always true

501 return local_env.cr 

502 if (cr := getattr(local_self, 'cr', None)) is not None: 

503 return cr 

504 if (req := odoo.http.request) and (env := req.env): 

505 return env.cr 

506 return None 

507 

508 

509def _get_uid(frame) -> int | None: 

510 # try, in order: uid, user, self.env.uid 

511 if 'uid' in frame.f_locals: 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true

512 return frame.f_locals['uid'] 

513 if 'user' in frame.f_locals: 513 ↛ 514line 513 didn't jump to line 514 because the condition on line 513 was never true

514 return int(frame.f_locals['user']) # user may be a record 

515 if (local_self := frame.f_locals.get('self')) is not None: 515 ↛ 518line 515 didn't jump to line 518 because the condition on line 515 was always true

516 if hasattr(local_self, 'env') and (uid := local_self.env.uid): 516 ↛ 518line 516 didn't jump to line 518 because the condition on line 516 was always true

517 return uid 

518 return None 

519 

520 

521def _get_lang(frame, default_lang='') -> str: 

522 # get from: context.get('lang'), kwargs['context'].get('lang'), 

523 if local_context := frame.f_locals.get('context'): 523 ↛ 524line 523 didn't jump to line 524 because the condition on line 523 was never true

524 if lang := local_context.get('lang'): 

525 return lang 

526 if (local_kwargs := frame.f_locals.get('kwargs')) and (local_context := local_kwargs.get('context')): 526 ↛ 527line 526 didn't jump to line 527 because the condition on line 526 was never true

527 if lang := local_context.get('lang'): 

528 return lang 

529 # get from self.env 

530 log_level = logging.WARNING 

531 local_self = frame.f_locals.get('self') 

532 local_env = local_self is not None and getattr(local_self, 'env', None) 

533 if local_env: 533 ↛ 539line 533 didn't jump to line 539 because the condition on line 533 was always true

534 if lang := local_env.lang: 

535 return lang 

536 # we found the env, in case we fail, just log in debug 

537 log_level = logging.DEBUG 

538 # get from request? 

539 if (req := odoo.http.request) and (env := req.env) and (lang := env.lang): 539 ↛ 540line 539 didn't jump to line 540 because the condition on line 539 was never true

540 return lang 

541 # Last resort: attempt to guess the language of the user 

542 # Pitfall: some operations are performed in sudo mode, and we 

543 # don't know the original uid, so the language may 

544 # be wrong when the admin language differs. 

545 cr = _get_cr(frame) 

546 uid = _get_uid(frame) 

547 if cr and uid: 547 ↛ 553line 547 didn't jump to line 553 because the condition on line 547 was always true

548 from odoo import api # noqa: PLC0415 

549 env = api.Environment(cr, uid, {}) 

550 if lang := env['res.users'].context_get().get('lang'): 550 ↛ 553line 550 didn't jump to line 553 because the condition on line 550 was always true

551 return lang 

552 # fallback 

553 if default_lang: 

554 _logger.debug('no translation language detected, fallback to %s', default_lang) 

555 return default_lang 

556 # give up 

557 _logger.log(log_level, 'no translation language detected, skipping translation %s', frame, stack_info=True) 

558 return '' 

559 

560 

561def _get_translation_source(stack_level: int, module: str = '', lang: str = '', default_lang: str = '') -> tuple[str, str]: 

562 if not (module and lang): 

563 frame = inspect.currentframe() 

564 for _index in range(stack_level + 1): 

565 frame = frame.f_back 

566 lang = lang or _get_lang(frame, default_lang) 

567 if lang and lang != 'en_US': 567 ↛ 568line 567 didn't jump to line 568 because the condition on line 567 was never true

568 return get_translated_module(module or frame), lang 

569 else: 

570 # we don't care about the module for 'en_US' 

571 return module or 'base', 'en_US' 

572 

573 

574def get_text_alias(source: str, /, *args, **kwargs): 

575 assert not (args and kwargs) 

576 assert isinstance(source, str) 

577 module, lang = _get_translation_source(1) 

578 return get_translation(module, lang, source, args or kwargs) 

579 

580 

581@functools.total_ordering 

582class LazyGettext: 

583 """ Lazy code translated term. 

584 

585 Similar to get_text_alias but the translation lookup will be done only at 

586 __str__ execution. 

587 This eases the search for terms to translate as lazy evaluated strings 

588 are declared early. 

589 

590 A code using translated global variables such as: 

591 

592 ``` 

593 _lt = LazyTranslate(__name__) 

594 LABEL = _lt("User") 

595 

596 def _compute_label(self): 

597 env = self.with_env(lang=self.partner_id.lang).env 

598 self.user_label = env._(LABEL) 

599 ``` 

600 

601 works as expected (unlike the classic get_text_alias implementation). 

602 """ 

603 

604 __slots__ = ('_args', '_default_lang', '_module', '_source') 

605 

606 def __init__(self, source, /, *args, _module='', _default_lang='', **kwargs): 

607 assert not (args and kwargs) 

608 assert isinstance(source, str) 

609 self._source = source 

610 self._args = args or kwargs 

611 self._module = get_translated_module(_module or 2) 

612 self._default_lang = _default_lang 

613 

614 def _translate(self, lang: str = '') -> str: 

615 module, lang = _get_translation_source(2, self._module, lang, default_lang=self._default_lang) 

616 return get_translation(module, lang, self._source, self._args) 

617 

618 def __repr__(self): 

619 """ Show for the debugger""" 

620 args = {'_module': self._module, '_default_lang': self._default_lang, '_args': self._args} 

621 return f"_lt({self._source!r}, **{args!r})" 

622 

623 def __str__(self): 

624 """ Translate.""" 

625 return self._translate() 

626 

627 def __eq__(self, other): 

628 """ Prevent using equal operators 

629 

630 Prevent direct comparisons with ``self``. 

631 One should compare the translation of ``self._source`` as ``str(self) == X``. 

632 """ 

633 raise NotImplementedError() 

634 

635 def __hash__(self): 

636 raise NotImplementedError() 

637 

638 def __lt__(self, other): 

639 raise NotImplementedError() 

640 

641 def __add__(self, other): 

642 if isinstance(other, str): 

643 return self._translate() + other 

644 elif isinstance(other, LazyGettext): 

645 return self._translate() + other._translate() 

646 return NotImplemented 

647 

648 def __radd__(self, other): 

649 if isinstance(other, str): 

650 return other + self._translate() 

651 return NotImplemented 

652 

653 

654class LazyTranslate: 

655 """ Lazy translation template. 

656 

657 Usage: 

658 ``` 

659 _lt = LazyTranslate(__name__) 

660 MYSTR = _lt('Translate X') 

661 ``` 

662 

663 You may specify a `default_lang` to fallback to a given language on error 

664 """ 

665 module: str 

666 default_lang: str 

667 

668 def __init__(self, module: str, *, default_lang: str = '') -> None: 

669 self.module = module = get_translated_module(module or 2) 

670 # set the default lang to en_US for lazy translations in the base module 

671 self.default_lang = default_lang or ('en_US' if module == 'base' else '') 

672 

673 def __call__(self, source: str, *args, **kwargs) -> LazyGettext: 

674 return LazyGettext(source, *args, **kwargs, _module=self.module, _default_lang=self.default_lang) 

675 

676 

677_ = get_text_alias 

678_lt = LazyGettext 

679 

680 

681def quote(s): 

682 """Returns quoted PO term string, with special PO characters escaped""" 

683 assert r"\n" not in s, "Translation terms may not include escaped newlines ('\\n'), please use only literal newlines! (in '%s')" % s 

684 return '"%s"' % s.replace('\\','\\\\') \ 

685 .replace('"','\\"') \ 

686 .replace('\n', '\\n"\n"') 

687 

688re_escaped_char = re.compile(r"(\\.)") 

689re_escaped_replacements = {'n': '\n', 't': '\t',} 

690 

691def _sub_replacement(match_obj): 

692 return re_escaped_replacements.get(match_obj.group(1)[1], match_obj.group(1)[1]) 

693 

694def unquote(str): 

695 """Returns unquoted PO term string, with special PO characters unescaped""" 

696 return re_escaped_char.sub(_sub_replacement, str[1:-1]) 

697 

698 

699def parse_xmlid(xmlid: str, default_module: str) -> tuple[str, str]: 

700 split_id = xmlid.split('.', maxsplit=1) 

701 if len(split_id) == 1: 

702 return default_module, split_id[0] 

703 return split_id[0], split_id[1] 

704 

705 

706def translation_file_reader(source, fileformat='po', module=None): 

707 """ Iterate over translation file to return Odoo translation entries """ 

708 if fileformat == 'csv': 

709 if module is not None: 709 ↛ 712line 709 didn't jump to line 712 because the condition on line 709 was always true

710 # if `module` is provided, we are reading a data file located in that module 

711 return CSVDataFileReader(source, module) 

712 return CSVFileReader(source) 

713 if fileformat == 'po': 

714 return PoFileReader(source) 

715 if fileformat == 'xml': 715 ↛ 718line 715 didn't jump to line 718 because the condition on line 715 was always true

716 assert module 

717 return XMLDataFileReader(source, module) 

718 _logger.info('Bad file format: %s', fileformat) 

719 raise Exception(_('Bad file format: %s', fileformat)) 

720 

721class CSVFileReader: 

722 def __init__(self, source): 

723 _reader = codecs.getreader('utf-8') 

724 self.source = csv.DictReader(_reader(source), quotechar='"', delimiter=',') 

725 self.prev_code_src = "" 

726 

727 def __iter__(self): 

728 for entry in self.source: 

729 

730 # determine <module>.<imd_name> from res_id 

731 if entry["res_id"] and entry["res_id"].isnumeric(): 

732 # res_id is an id or line number 

733 entry["res_id"] = int(entry["res_id"]) 

734 elif not entry.get("imd_name"): 

735 # res_id is an external id and must follow <module>.<name> 

736 entry["module"], entry["imd_name"] = entry["res_id"].split(".") 

737 entry["res_id"] = None 

738 if entry["type"] == "model" or entry["type"] == "model_terms": 

739 entry["imd_model"] = entry["name"].partition(',')[0] 

740 

741 if entry["type"] == "code": 

742 if entry["src"] == self.prev_code_src: 

743 # skip entry due to unicity constrain on code translations 

744 continue 

745 self.prev_code_src = entry["src"] 

746 

747 yield entry 

748 

749 

750class CSVDataFileReader: 

751 def __init__(self, source, module: str): 

752 """Read the translations in CSV data file. 

753 

754 :param source: the input stream 

755 :param module: the CSV file is considered as a data file possibly 

756 containing terms translated with the `@` syntax 

757 """ 

758 _reader = codecs.getreader('utf-8') 

759 self.module = module 

760 self.model = os.path.splitext((os.path.basename(source.name)))[0].split('-')[0] 

761 self.source = csv.DictReader(_reader(source), quotechar='"', delimiter=',') 

762 self.prev_code_src = "" 

763 

764 def __iter__(self): 

765 translated_fnames = sorted( 

766 [fname.split('@', maxsplit=1) for fname in self.source.fieldnames or [] if '@' in fname], 

767 key=lambda x: x[1], # Put fallback languages first 

768 ) 

769 for entry in self.source: 

770 for fname, lang in translated_fnames: 

771 module, imd_name = parse_xmlid(entry['id'], self.module) 

772 yield { 

773 'type': 'model', 

774 'imd_model': self.model, 

775 'imd_name': imd_name, 

776 'lang': lang, 

777 'value': entry[f"{fname}@{lang}"], 

778 'src': entry[fname], 

779 'module': module, 

780 'name': f"{self.model},{fname}", 

781 } 

782 

783 

784class XMLDataFileReader: 

785 def __init__(self, source, module: str): 

786 try: 

787 tree = etree.parse(source) 

788 except etree.LxmlSyntaxError: 

789 _logger.warning("Error parsing XML file %s", source) 

790 tree = etree.fromstring('<data/>') 

791 self.source = tree 

792 self.module = module 

793 

794 def __iter__(self): 

795 for record in self.source.xpath("//field[contains(@name, '@')]/.."): 

796 vals = {field.attrib['name']: field.text for field in record.xpath("field")} 

797 translated_fnames = sorted( 

798 [fname.split('@', maxsplit=1) for fname in vals if '@' in fname], 

799 key=lambda x: x[1], # Put fallback languages first 

800 ) 

801 for fname, lang in translated_fnames: 

802 module, imd_name = parse_xmlid(record.attrib['id'], self.module) 

803 yield { 

804 'type': 'model', 

805 'imd_model': record.attrib['model'], 

806 'imd_name': imd_name, 

807 'lang': lang, 

808 'value': vals[f"{fname}@{lang}"], 

809 'src': vals[fname], 

810 'module': module, 

811 'name': f"{record.attrib['model']},{fname}", 

812 } 

813 

814 

815class PoFileReader: 

816 """ Iterate over po file to return Odoo translation entries """ 

817 def __init__(self, source): 

818 

819 def get_pot_path(source_name): 

820 # when fileobj is a TemporaryFile, its name is an inter in P3, a string in P2 

821 if isinstance(source_name, str) and source_name.endswith('.po'): 821 ↛ 830line 821 didn't jump to line 830 because the condition on line 821 was always true

822 # Normally the path looks like /path/to/xxx/i18n/lang.po 

823 # and we try to find the corresponding 

824 # /path/to/xxx/i18n/xxx.pot file. 

825 # (Sometimes we have 'i18n_extra' instead of just 'i18n') 

826 path = Path(source_name) 

827 filename = path.parent.parent.name + '.pot' 

828 pot_path = path.with_name(filename) 

829 return pot_path.exists() and str(pot_path) or False 

830 return False 

831 

832 # polib accepts a path or the file content as a string, not a fileobj 

833 if isinstance(source, str): 833 ↛ 834line 833 didn't jump to line 834 because the condition on line 833 was never true

834 self.pofile = polib.pofile(source) 

835 pot_path = get_pot_path(source) 

836 else: 

837 # either a BufferedIOBase or result from NamedTemporaryFile 

838 self.pofile = polib.pofile(source.read().decode()) 

839 pot_path = get_pot_path(source.name) 

840 

841 if pot_path: 

842 # Make a reader for the POT file 

843 # (Because the POT comments are correct on GitHub but the 

844 # PO comments tends to be outdated. See LP bug 933496.) 

845 self.pofile.merge(polib.pofile(pot_path)) 

846 

847 def __iter__(self): 

848 for entry in self.pofile: 

849 if entry.obsolete: 

850 continue 

851 

852 # in case of moduleS keep only the first 

853 match = re.match(r"(module[s]?): (\w+)", entry.comment) 

854 _, module = match.groups() 

855 comments = "\n".join([c for c in entry.comment.split('\n') if not c.startswith('module:')]) 

856 source = entry.msgid 

857 translation = entry.msgstr 

858 found_code_occurrence = False 

859 for occurrence, line_number in entry.occurrences: 

860 match = re.match(r'(model|model_terms):([\w.]+),([\w]+):(\w+)\.([^ ]+)', occurrence) 

861 if match: 

862 type, model_name, field_name, module, xmlid = match.groups() 

863 yield { 

864 'type': type, 

865 'imd_model': model_name, 

866 'name': model_name+','+field_name, 

867 'imd_name': xmlid, 

868 'res_id': None, 

869 'src': source, 

870 'value': translation, 

871 'comments': comments, 

872 'module': module, 

873 } 

874 continue 

875 

876 match = re.match(r'(code):([\w/.]+)', occurrence) 

877 if match: 877 ↛ 894line 877 didn't jump to line 894 because the condition on line 877 was always true

878 type, name = match.groups() 

879 if found_code_occurrence: 

880 # unicity constrain on code translation 

881 continue 

882 found_code_occurrence = True 

883 yield { 

884 'type': type, 

885 'name': name, 

886 'src': source, 

887 'value': translation, 

888 'comments': comments, 

889 'res_id': int(line_number), 

890 'module': module, 

891 } 

892 continue 

893 

894 match = re.match(r'(selection):([\w.]+),([\w]+)', occurrence) 

895 if match: 

896 _logger.info("Skipped deprecated occurrence %s", occurrence) 

897 continue 

898 

899 match = re.match(r'(sql_constraint|constraint):([\w.]+)', occurrence) 

900 if match: 

901 _logger.info("Skipped deprecated occurrence %s", occurrence) 

902 continue 

903 _logger.error("malformed po file: unknown occurrence: %s", occurrence) 

904 

905def TranslationFileWriter(target, fileformat='po', lang=None): 

906 """ Iterate over translation file to return Odoo translation entries """ 

907 if fileformat == 'csv': 

908 return CSVFileWriter(target) 

909 

910 if fileformat == 'po': 

911 return PoFileWriter(target, lang=lang) 

912 

913 if fileformat == 'tgz': 

914 return TarFileWriter(target, lang=lang) 

915 

916 raise Exception(_('Unrecognized extension: must be one of ' 

917 '.csv, .po, or .tgz (received .%s).') % fileformat) 

918 

919 

920_writer = codecs.getwriter('utf-8') 

921class CSVFileWriter: 

922 def __init__(self, target): 

923 self.writer = csv.writer(_writer(target), dialect='UNIX') 

924 # write header first 

925 self.writer.writerow(("module","type","name","res_id","src","value","comments")) 

926 

927 

928 def write_rows(self, rows): 

929 for module, type, name, res_id, src, trad, comments in rows: 

930 comments = '\n'.join(comments) 

931 self.writer.writerow((module, type, name, res_id, src, trad, comments)) 

932 

933 

934class PoFileWriter: 

935 """ Iterate over po file to return Odoo translation entries """ 

936 def __init__(self, target, lang): 

937 

938 self.buffer = target 

939 self.lang = lang 

940 self.po = polib.POFile() 

941 

942 def write_rows(self, rows): 

943 # we now group the translations by source. That means one translation per source. 

944 grouped_rows = {} 

945 modules = set() 

946 for module, type, name, res_id, src, trad, comments in rows: 

947 row = grouped_rows.setdefault(src, {}) 

948 row.setdefault('modules', set()).add(module) 

949 if not row.get('translation') and trad != src: 

950 row['translation'] = trad 

951 row.setdefault('tnrs', []).append((type, name, res_id)) 

952 row.setdefault('comments', set()).update(comments) 

953 modules.add(module) 

954 

955 for src, row in sorted(grouped_rows.items()): 

956 if not self.lang: 

957 # translation template, so no translation value 

958 row['translation'] = '' 

959 elif not row.get('translation'): 

960 row['translation'] = '' 

961 self.add_entry(sorted(row['modules']), sorted(row['tnrs']), src, row['translation'], sorted(row['comments'])) 

962 

963 import odoo.release as release 

964 self.po.header = "Translation of %s.\n" \ 

965 "This file contains the translation of the following modules:\n" \ 

966 "%s" % (release.description, ''.join("\t* %s\n" % m for m in modules)) 

967 now = datetime.utcnow().strftime('%Y-%m-%d %H:%M+0000') 

968 self.po.metadata = { 

969 'Project-Id-Version': "%s %s" % (release.description, release.version), 

970 'Report-Msgid-Bugs-To': '', 

971 'POT-Creation-Date': now, 

972 'PO-Revision-Date': now, 

973 'Last-Translator': '', 

974 'Language-Team': '', 

975 'MIME-Version': '1.0', 

976 'Content-Type': 'text/plain; charset=UTF-8', 

977 'Content-Transfer-Encoding': '', 

978 'Plural-Forms': '', 

979 } 

980 

981 # buffer expects bytes 

982 self.buffer.write(str(self.po).encode()) 

983 

984 def add_entry(self, modules, tnrs, source, trad, comments=None): 

985 entry = polib.POEntry( 

986 msgid=source, 

987 msgstr=trad, 

988 ) 

989 plural = len(modules) > 1 and 's' or '' 

990 entry.comment = "module%s: %s" % (plural, ', '.join(modules)) 

991 if comments: 

992 entry.comment += "\n" + "\n".join(comments) 

993 occurrences = OrderedSet() 

994 for type_, *ref in tnrs: 

995 if type_ == "code": 

996 fpath, lineno = ref 

997 name = f"code:{fpath}" 

998 # lineno is set to 0 to avoid creating diff in PO files every 

999 # time the code is moved around 

1000 lineno = "0" 

1001 else: 

1002 field_name, xmlid = ref 

1003 name = f"{type_}:{field_name}:{xmlid}" 

1004 lineno = None # no lineno for model/model_terms sources 

1005 occurrences.add((name, lineno)) 

1006 entry.occurrences = list(occurrences) 

1007 self.po.append(entry) 

1008 

1009 

1010class TarFileWriter: 

1011 

1012 def __init__(self, target, lang): 

1013 self.tar = tarfile.open(fileobj=target, mode='w|gz') 

1014 self.lang = lang 

1015 

1016 def write_rows(self, rows): 

1017 rows_by_module = defaultdict(list) 

1018 for row in rows: 

1019 module = row[0] 

1020 rows_by_module[module].append(row) 

1021 

1022 for mod, modrows in rows_by_module.items(): 

1023 with io.BytesIO() as buf: 

1024 po = PoFileWriter(buf, lang=self.lang) 

1025 po.write_rows(modrows) 

1026 buf.seek(0) 

1027 

1028 info = tarfile.TarInfo( 

1029 join(mod, 'i18n', '{basename}.{ext}'.format( 

1030 basename=self.lang or mod, 

1031 ext='po' if self.lang else 'pot', 

1032 ))) 

1033 # addfile will read <size> bytes from the buffer so 

1034 # size *must* be set first 

1035 info.size = len(buf.getvalue()) 

1036 

1037 self.tar.addfile(info, fileobj=buf) 

1038 

1039 self.tar.close() 

1040 

1041 

1042# Methods to export the translation file 

1043# pylint: disable=redefined-builtin 

1044def trans_export(lang, modules, buffer, format, env): 

1045 reader = TranslationModuleReader(env.cr, modules=modules, lang=lang) 

1046 if not reader: 

1047 return False 

1048 writer = TranslationFileWriter(buffer, fileformat=format, lang=lang) 

1049 writer.write_rows(reader) 

1050 return True 

1051 

1052 

1053def trans_export_records(lang, model_name, ids, buffer, format, env): 

1054 reader = TranslationRecordReader(env.cr, model_name, ids, lang=lang) 

1055 if not reader: 

1056 return False 

1057 writer = TranslationFileWriter(buffer, fileformat=format, lang=lang) 

1058 writer.write_rows(reader) 

1059 return True 

1060 

1061 

1062def _push(callback, term, source_line): 

1063 """ Sanity check before pushing translation terms """ 

1064 term = (term or "").strip() 

1065 # Avoid non-char tokens like ':' '...' '.00' etc. 

1066 if len(term) > 8 or any(x.isalpha() for x in term): 

1067 callback(term, source_line) 

1068 

1069def _extract_translatable_qweb_terms(element, callback): 

1070 """ Helper method to walk an etree document representing 

1071 a QWeb template, and call ``callback(term)`` for each 

1072 translatable term that is found in the document. 

1073 

1074 :param etree._Element element: root of etree document to extract terms from 

1075 :param Callable callback: a callable in the form ``f(term, source_line)``, 

1076 that will be called for each extracted term. 

1077 """ 

1078 # not using elementTree.iterparse because we need to skip sub-trees in case 

1079 # the ancestor element had a reason to be skipped 

1080 for el in element: 

1081 if isinstance(el, SKIPPED_ELEMENT_TYPES): continue 

1082 if (el.tag.lower() not in SKIPPED_ELEMENTS 

1083 and "t-js" not in el.attrib 

1084 and not (el.tag == 'attribute' and not is_translatable_attrib(el.get('name'), el)) 

1085 and el.get("t-translation", '').strip() != "off"): 

1086 

1087 _push(callback, el.text, el.sourceline) 

1088 # heuristic: tags with names starting with an uppercase letter are 

1089 # component nodes 

1090 is_component = el.tag[0].isupper() or "t-component" in el.attrib or "t-set-slot" in el.attrib 

1091 for attr in el.attrib: 

1092 if (not is_component and attr in OWL_TRANSLATED_ATTRS) or (is_component and attr.endswith(".translate")): 

1093 _push(callback, el.attrib[attr], el.sourceline) 

1094 _extract_translatable_qweb_terms(el, callback) 

1095 _push(callback, el.tail, el.sourceline) 

1096 

1097 

1098def babel_extract_qweb(fileobj, keywords, comment_tags, options): 

1099 """Babel message extractor for qweb template files. 

1100 

1101 :param fileobj: the file-like object the messages should be extracted from 

1102 :param keywords: a list of keywords (i.e. function names) that should 

1103 be recognized as translation functions 

1104 :param comment_tags: a list of translator tags to search for and 

1105 include in the results 

1106 :param options: a dictionary of additional options (optional) 

1107 :return: an iterator over ``(lineno, funcname, message, comments)`` 

1108 tuples 

1109 :rtype: Iterable 

1110 """ 

1111 result = [] 

1112 def handle_text(text, lineno): 

1113 result.append((lineno, None, text, [])) 

1114 tree = etree.parse(fileobj) 

1115 _extract_translatable_qweb_terms(tree.getroot(), handle_text) 

1116 return result 

1117 

1118 

1119def extract_formula_terms(formula): 

1120 """Extract strings in a spreadsheet formula which are arguments to '_t' functions 

1121 

1122 >>> extract_formula_terms('=_t("Hello") + _t("Raoul")') 

1123 ["Hello", "Raoul"] 

1124 """ 

1125 tokens = generate_tokens(io.StringIO(formula).readline) 

1126 tokens = (token for token in tokens if token.type not in {NEWLINE, INDENT, DEDENT}) 

1127 for t1 in tokens: 

1128 if t1.string != '_t': 

1129 continue 

1130 t2 = next(tokens, None) 

1131 if t2 and t2.string == '(': 

1132 t3 = next(tokens, None) 

1133 t4 = next(tokens, None) 

1134 if t4 and t4.string == ')' and t3 and t3.type == STRING: 

1135 yield t3.string[1:][:-1] # strip leading and trailing quotes 

1136 

1137 

1138def extract_spreadsheet_terms(fileobj, keywords, comment_tags, options): 

1139 """Babel message extractor for spreadsheet data files. 

1140 

1141 :param fileobj: the file-like object the messages should be extracted from 

1142 :param keywords: a list of keywords (i.e. function names) that should 

1143 be recognized as translation functions 

1144 :param comment_tags: a list of translator tags to search for and 

1145 include in the results 

1146 :param options: a dictionary of additional options (optional) 

1147 :return: an iterator over ``(lineno, funcname, message, comments)`` 

1148 tuples 

1149 """ 

1150 terms = set() 

1151 data = json.load(fileobj) 

1152 for sheet in data.get('sheets', []): 

1153 for cell in sheet['cells'].values(): 

1154 # 'cell' was an object in versions <saas-18.1 

1155 content = cell if isinstance(cell, str) else cell.get('content', '') 

1156 if content.startswith('='): 

1157 terms.update(extract_formula_terms(content)) 

1158 else: 

1159 markdown_link = re.fullmatch(r'\[(.+)\]\(.+\)', content) 

1160 if markdown_link: 

1161 terms.add(markdown_link[1]) 

1162 for figure in sheet['figures']: 

1163 if figure['tag'] == 'chart': 

1164 title = figure['data']['title'] 

1165 if isinstance(title, str): 

1166 terms.add(title) 

1167 elif 'text' in title: 

1168 terms.add(title['text']) 

1169 if 'axesDesign' in figure['data']: 

1170 terms.update( 

1171 axes.get('title', {}).get('text', '') for axes in figure['data']['axesDesign'].values() 

1172 ) 

1173 if 'text' in (baselineDescr := figure['data'].get('baselineDescr', {})): 

1174 terms.add(baselineDescr['text']) 

1175 if 'text' in (keyDescr := figure['data'].get('keyDescr', {})): 

1176 terms.add(keyDescr['text']) 

1177 terms.update(global_filter['label'] for global_filter in data.get('globalFilters', [])) 

1178 return ( 

1179 (0, None, term, []) 

1180 for term in terms 

1181 if any(x.isalpha() for x in term) 

1182 ) 

1183 

1184ImdInfo = namedtuple('ExternalId', ['name', 'model', 'res_id', 'module']) 

1185 

1186 

1187class TranslationReader: 

1188 def __init__(self, cr, lang=None): 

1189 self._cr = cr 

1190 self._lang = lang or 'en_US' 

1191 from odoo import api # noqa: PLC0415 

1192 self.env = api.Environment(cr, api.SUPERUSER_ID, {}) 

1193 self._to_translate = [] 

1194 

1195 def __bool__(self): 

1196 return bool(self._to_translate) 

1197 

1198 def __iter__(self): 

1199 for module, source, name, res_id, ttype, comments, _record_id, value in self._to_translate: 

1200 yield (module, ttype, name, res_id, source, value, comments) 

1201 

1202 def _push_translation(self, module, ttype, name, res_id, source, comments=None, record_id=None, value=None): 

1203 """ Insert a translation that will be used in the file generation 

1204 In po file will create an entry 

1205 #: <ttype>:<name>:<res_id> 

1206 #, <comment> 

1207 msgid "<source>" 

1208 record_id is the database id of the record being translated 

1209 """ 

1210 # empty and one-letter terms are ignored, they probably are not meant to be 

1211 # translated, and would be very hard to translate anyway. 

1212 sanitized_term = (source or '').strip() 

1213 # remove non-alphanumeric chars 

1214 sanitized_term = re.sub(r'\W+', '', sanitized_term) 

1215 if not sanitized_term or len(sanitized_term) <= 1: 

1216 return 

1217 self._to_translate.append((module, source, name, res_id, ttype, tuple(comments or ()), record_id, value)) 

1218 

1219 def _export_imdinfo(self, model: str, imd_per_id: dict[int, ImdInfo]): 

1220 records = self._get_translatable_records(imd_per_id.values()) 

1221 if not records: 

1222 return 

1223 

1224 env = records.env 

1225 for record in records.with_context(check_translations=True): 

1226 module = imd_per_id[record.id].module 

1227 xml_name = "%s.%s" % (module, imd_per_id[record.id].name) 

1228 for field_name, field in record._fields.items(): 

1229 # ir_actions_actions.name is filtered because unlike other inherited fields, 

1230 # this field is inherited as postgresql inherited columns. 

1231 # From our business perspective, the parent column is no need to be translated, 

1232 # but it is need to be set to jsonb column, since the child columns need to be translated 

1233 # And export the parent field may make one value to be translated twice in transifex 

1234 # 

1235 # Some ir_model_fields.field_description are filtered 

1236 # because their fields have falsy attribute export_string_translation 

1237 if ( 

1238 not (field.translate and field.store) 

1239 or str(field) == 'ir.actions.actions.name' 

1240 or (str(field) == 'ir.model.fields.field_description' 

1241 and not env[record.model]._fields[record.name].export_string_translation) 

1242 ): 

1243 continue 

1244 name = model + "," + field_name 

1245 value_en = record[field_name] or '' 

1246 value_lang = record.with_context(lang=self._lang)[field_name] or '' 

1247 trans_type = 'model_terms' if callable(field.translate) else 'model' 

1248 try: 

1249 translation_dictionary = field.get_translation_dictionary(value_en, {self._lang: value_lang}) 

1250 except Exception: 

1251 _logger.exception("Failed to extract terms from %s %s", xml_name, name) 

1252 continue 

1253 for term_en, term_langs in translation_dictionary.items(): 

1254 term_lang = term_langs.get(self._lang) 

1255 self._push_translation(module, trans_type, name, xml_name, term_en, record_id=record.id, value=term_lang if term_lang != term_en else '') 

1256 

1257 def _get_translatable_records(self, imd_records): 

1258 """ Filter the records that are translatable 

1259 

1260 A record is considered as untranslatable if: 

1261 - it does not exist 

1262 - the model is flagged with _translate=False 

1263 - it is a field of a model flagged with _translate=False 

1264 - it is a selection of a field of a model flagged with _translate=False 

1265 

1266 :param records: a list of namedtuple ImdInfo belonging to the same model 

1267 """ 

1268 model = next(iter(imd_records)).model 

1269 if model not in self.env: 

1270 _logger.error("Unable to find object %r", model) 

1271 return self.env["_unknown"].browse() 

1272 

1273 if not self.env[model]._translate: 

1274 return self.env[model].browse() 

1275 

1276 res_ids = [r.res_id for r in imd_records] 

1277 records = self.env[model].browse(res_ids).exists() 

1278 if len(records) < len(res_ids): 

1279 missing_ids = set(res_ids) - set(records.ids) 

1280 missing_records = [f"{r.module}.{r.name}" for r in imd_records if r.res_id in missing_ids] 

1281 _logger.warning("Unable to find records of type %r with external ids %s", model, ', '.join(missing_records)) 

1282 if not records: 

1283 return records 

1284 

1285 if model == 'ir.model.fields.selection': 

1286 fields = defaultdict(list) 

1287 for selection in records: 

1288 fields[selection.field_id] = selection 

1289 for field, selection in fields.items(): 

1290 field_name = field.name 

1291 field_model = self.env.get(field.model) 

1292 if (field_model is None or not field_model._translate or 

1293 field_name not in field_model._fields): 

1294 # the selection is linked to a model with _translate=False, remove it 

1295 records -= selection 

1296 elif model == 'ir.model.fields': 

1297 for field in records: 

1298 field_name = field.name 

1299 field_model = self.env.get(field.model) 

1300 if (field_model is None or not field_model._translate or 

1301 field_name not in field_model._fields): 

1302 # the field is linked to a model with _translate=False, remove it 

1303 records -= field 

1304 

1305 return records 

1306 

1307 

1308class TranslationRecordReader(TranslationReader): 

1309 """ Retrieve translations for specified records, the reader will 

1310 1. create external ids for records without external ids 

1311 2. export translations for stored translated and inherited translated fields 

1312 :param cr: cursor to database to export 

1313 :param model_name: model_name for the records to export 

1314 :param ids: ids of the records to export 

1315 :param field_names: field names to export, if not set, export all translatable fields 

1316 :param lang: language code to retrieve the translations retrieve source terms only if not set 

1317 """ 

1318 def __init__(self, cr, model_name, ids, field_names=None, lang=None): 

1319 super().__init__(cr, lang) 

1320 self._records = self.env[model_name].browse(ids) 

1321 self._field_names = field_names or list(self._records._fields.keys()) 

1322 

1323 self._export_translatable_records(self._records, self._field_names) 

1324 

1325 def _export_translatable_records(self, records, field_names): 

1326 """ Export translations of all stored/inherited translated fields. Create external id if needed. """ 

1327 if not records: 

1328 return 

1329 

1330 fields = records._fields 

1331 

1332 if records._inherits: 

1333 inherited_fields = defaultdict(list) 

1334 for field_name in field_names: 

1335 field = records._fields[field_name] 

1336 if field.translate and not field.store and field.inherited_field: 

1337 inherited_fields[field.inherited_field.model_name].append(field_name) 

1338 for parent_mname, parent_fname in records._inherits.items(): 

1339 if parent_mname in inherited_fields: 

1340 self._export_translatable_records(records[parent_fname], inherited_fields[parent_mname]) 

1341 

1342 if not any(fields[field_name].translate and fields[field_name].store for field_name in field_names): 

1343 return 

1344 

1345 records._BaseModel__ensure_xml_id() 

1346 

1347 model_name = records._name 

1348 query = """SELECT min(concat(module, '.', name)), res_id 

1349 FROM ir_model_data 

1350 WHERE model = %s 

1351 AND res_id = ANY(%s) 

1352 GROUP BY model, res_id""" 

1353 

1354 self._cr.execute(query, (model_name, records.ids)) 

1355 

1356 imd_per_id = { 

1357 res_id: ImdInfo((tmp := module_xml_name.split('.', 1))[1], model_name, res_id, tmp[0]) 

1358 for module_xml_name, res_id in self._cr.fetchall() 

1359 } 

1360 

1361 self._export_imdinfo(model_name, imd_per_id) 

1362 

1363 

1364class TranslationModuleReader(TranslationReader): 

1365 """ Retrieve translated records per module 

1366 

1367 :param cr: cursor to database to export 

1368 :param modules: list of modules to filter the exported terms, can be ['all'] 

1369 records with no external id are always ignored 

1370 :param lang: language code to retrieve the translations 

1371 retrieve source terms only if not set 

1372 """ 

1373 

1374 def __init__(self, cr, modules=None, lang=None): 

1375 super().__init__(cr, lang) 

1376 self._modules = modules or ['all'] 

1377 import odoo.addons # noqa: PLC0415 

1378 self._path_list = [(path, True) for path in odoo.addons.__path__] 

1379 self._installed_modules = [ 

1380 m['name'] 

1381 for m in self.env['ir.module.module'].search_read([('state', '=', 'installed')], fields=['name']) 

1382 ] 

1383 

1384 self._export_translatable_records() 

1385 self._export_translatable_resources() 

1386 

1387 def _export_translatable_records(self): 

1388 """ Export translations of all translated records having an external id """ 

1389 modules = self._installed_modules if 'all' in self._modules else list(self._modules) 

1390 xml_defined = set() 

1391 for module in modules: 

1392 for filepath in get_datafile_translation_path(module): 

1393 fileformat = os.path.splitext(filepath)[-1][1:].lower() 

1394 with file_open(filepath, mode='rb') as source: 

1395 for entry in translation_file_reader(source, fileformat=fileformat, module=module): 

1396 xml_defined.add((entry['imd_model'], module, entry['imd_name'])) 

1397 

1398 query = """SELECT min(name), model, res_id, module 

1399 FROM ir_model_data 

1400 WHERE module = ANY(%s) 

1401 GROUP BY model, res_id, module 

1402 ORDER BY module, model, min(name)""" 

1403 

1404 self._cr.execute(query, (modules,)) 

1405 

1406 records_per_model = defaultdict(dict) 

1407 for (imd_name, model, res_id, module) in self._cr.fetchall(): 

1408 if (model, module, imd_name) in xml_defined: 

1409 continue 

1410 records_per_model[model][res_id] = ImdInfo(imd_name, model, res_id, module) 

1411 

1412 for model, imd_per_id in records_per_model.items(): 

1413 self._export_imdinfo(model, imd_per_id) 

1414 

1415 def _get_module_from_path(self, path): 

1416 for (mp, rec) in self._path_list: 

1417 mp = os.path.join(mp, '') 

1418 dirname = os.path.join(os.path.dirname(path), '') 

1419 if rec and path.startswith(mp) and dirname != mp: 

1420 path = path[len(mp):] 

1421 return path.split(os.path.sep)[0] 

1422 return 'base' # files that are not in a module are considered as being in 'base' module 

1423 

1424 def _verified_module_filepaths(self, fname, path, root): 

1425 fabsolutepath = join(root, fname) 

1426 frelativepath = fabsolutepath[len(path):] 

1427 display_path = "addons%s" % frelativepath 

1428 module = self._get_module_from_path(fabsolutepath) 

1429 if ('all' in self._modules or module in self._modules) and module in self._installed_modules: 

1430 if os.path.sep != '/': 

1431 display_path = display_path.replace(os.path.sep, '/') 

1432 return module, fabsolutepath, frelativepath, display_path 

1433 return None, None, None, None 

1434 

1435 def _babel_extract_terms(self, fname, path, root, extract_method='odoo.tools.babel:extract_python', trans_type='code', 

1436 extra_comments=None, extract_keywords={'_': None}): 

1437 

1438 module, fabsolutepath, _, display_path = self._verified_module_filepaths(fname, path, root) 

1439 if not module: 

1440 return 

1441 extra_comments = extra_comments or [] 

1442 src_file = file_open(fabsolutepath, 'rb') 

1443 options = {} 

1444 if 'python' in extract_method: 

1445 options['encoding'] = 'UTF-8' 

1446 translations = code_translations.get_python_translations(module, self._lang) 

1447 else: 

1448 translations = code_translations.get_web_translations(module, self._lang) 

1449 translations = {tran['id']: tran['string'] for tran in translations['messages']} 

1450 try: 

1451 for extracted in extract.extract(extract_method, src_file, keywords=extract_keywords, options=options): 

1452 # Babel 0.9.6 yields lineno, message, comments 

1453 # Babel 1.3 yields lineno, message, comments, context 

1454 lineno, message, comments = extracted[:3] 

1455 value = translations.get(message, '') 

1456 self._push_translation(module, trans_type, display_path, lineno, 

1457 message, comments + extra_comments, value=value) 

1458 except Exception: 

1459 _logger.exception("Failed to extract terms from %s", fabsolutepath) 

1460 finally: 

1461 src_file.close() 

1462 

1463 def _export_translatable_resources(self): 

1464 """ Export translations for static terms 

1465 

1466 This will include: 

1467 - the python strings marked with _() or _lt() 

1468 - the javascript strings marked with _t() inside static/src/js/ 

1469 - the strings inside Qweb files inside static/src/xml/ 

1470 - the spreadsheet data files 

1471 """ 

1472 

1473 # Also scan these non-addon paths 

1474 for bin_path in ['orm', 'osv', 'report', 'modules', 'service', 'tools']: 

1475 self._path_list.append((os.path.join(config.root_path, bin_path), True)) 

1476 # non-recursive scan for individual files in root directory but without 

1477 # scanning subdirectories that may contain addons 

1478 self._path_list.append((config.root_path, False)) 

1479 _logger.debug("Scanning modules at paths: %s", self._path_list) 

1480 

1481 spreadsheet_files_regex = re.compile(r".*_dashboard(\.osheet)?\.json$") 

1482 

1483 for (path, recursive) in self._path_list: 

1484 _logger.debug("Scanning files of modules at %s", path) 

1485 for root, _dummy, files in os.walk(path, followlinks=True): 

1486 for fname in fnmatch.filter(files, '*.py'): 

1487 self._babel_extract_terms(fname, path, root, 'odoo.tools.babel:extract_python', 

1488 extra_comments=[PYTHON_TRANSLATION_COMMENT], 

1489 extract_keywords={'_': None, '_lt': None}) 

1490 if fnmatch.fnmatch(root, '*/static/src*'): 

1491 # Javascript source files 

1492 for fname in fnmatch.filter(files, '*.js'): 

1493 self._babel_extract_terms(fname, path, root, 'odoo.tools.babel:extract_javascript', 

1494 extra_comments=[JAVASCRIPT_TRANSLATION_COMMENT], 

1495 extract_keywords={'_t': None}) 

1496 # QWeb template files 

1497 for fname in fnmatch.filter(files, '*.xml'): 

1498 self._babel_extract_terms(fname, path, root, 'odoo.tools.translate:babel_extract_qweb', 

1499 extra_comments=[JAVASCRIPT_TRANSLATION_COMMENT]) 

1500 if fnmatch.fnmatch(root, '*/data/*'): 

1501 for fname in filter(spreadsheet_files_regex.match, files): 

1502 self._babel_extract_terms(fname, path, root, 'odoo.tools.translate:extract_spreadsheet_terms', 

1503 extra_comments=[JAVASCRIPT_TRANSLATION_COMMENT]) 

1504 if not recursive: 

1505 # due to topdown, first iteration is in first level 

1506 break 

1507 

1508 IrModuleModule = self.env['ir.module.module'] 

1509 for module in self._modules: 

1510 for translation in IrModuleModule._extract_resource_attachment_translations(module, self._lang): 

1511 self._push_translation(*translation) 

1512 

1513 

1514def DeepDefaultDict(): 

1515 return defaultdict(DeepDefaultDict) 

1516 

1517 

1518class TranslationImporter: 

1519 """ Helper object for importing translation files to a database. 

1520 This class provides a convenient API to load the translations from many 

1521 files and import them all at once, which helps speeding up the whole import. 

1522 """ 

1523 

1524 def __init__(self, cr, verbose=True): 

1525 self.cr = cr 

1526 self.verbose = verbose 

1527 from odoo import api # noqa: PLC0415 

1528 self.env = api.Environment(cr, api.SUPERUSER_ID, {}) 

1529 

1530 # {model_name: {field_name: {xmlid: {lang: value}}}} 

1531 self.model_translations = DeepDefaultDict() 

1532 # {model_name: {field_name: {xmlid: {src: {lang: value}}}}} 

1533 self.model_terms_translations = DeepDefaultDict() 

1534 self.imported_langs = set() 

1535 

1536 def load_file(self, filepath, lang, xmlids=None, module=None): 

1537 """ Load translations from the given file path. 

1538 

1539 :param filepath: file path to open 

1540 :param lang: language code of the translations contained in the file; 

1541 the language must be present and activated in the database 

1542 :param xmlids: if given, only translations for records with xmlid in xmlids will be loaded 

1543 :param module: if given, the file will be interpreted as a data file containing translations 

1544 """ 

1545 with suppress(FileNotFoundError), file_open(filepath, mode='rb', env=self.env) as fileobj: 

1546 if self.verbose: 1546 ↛ 1547line 1546 didn't jump to line 1547 because the condition on line 1546 was never true

1547 _logger.info('loading base translation file %s for language %s', filepath, lang) 

1548 fileformat = os.path.splitext(filepath)[-1][1:].lower() 

1549 self.load(fileobj, fileformat, lang, xmlids=xmlids, module=module) 

1550 

1551 def load(self, fileobj, fileformat, lang, xmlids=None, module=None): 

1552 """Load translations from the given file object. 

1553 

1554 :param fileobj: buffer open to a translation file 

1555 :param fileformat: format of the `fielobj` file, one of 'po', 'csv', or 'xml' 

1556 :param lang: language code of the translations contained in `fileobj`; 

1557 the language must be present and activated in the database 

1558 :param xmlids: if given, only translations for records with xmlid in xmlids will be loaded 

1559 :param module: if given, the file will be interpreted as a data file containing translations 

1560 """ 

1561 if self.verbose: 1561 ↛ 1562line 1561 didn't jump to line 1562 because the condition on line 1561 was never true

1562 _logger.info('loading translation file for language %s', lang) 

1563 if not self.env['res.lang']._lang_get(lang): 1563 ↛ 1564line 1563 didn't jump to line 1564 because the condition on line 1563 was never true

1564 _logger.error("Couldn't read translation for lang '%s', language not found", lang) 

1565 return None 

1566 try: 

1567 fileobj.seek(0) 

1568 reader = translation_file_reader(fileobj, fileformat=fileformat, module=module) 

1569 self._load(reader, lang, xmlids) 

1570 except IOError: 

1571 iso_lang = get_iso_codes(lang) 

1572 filename = '[lang: %s][format: %s]' % (iso_lang or 'new', fileformat) 

1573 _logger.exception("couldn't read translation file %s", filename) 

1574 

1575 def _load(self, reader, lang, xmlids=None): 

1576 if xmlids and not isinstance(xmlids, set): 1576 ↛ 1577line 1576 didn't jump to line 1577 because the condition on line 1576 was never true

1577 xmlids = set(xmlids) 

1578 valid_langs = get_base_langs(lang) 

1579 for row in reader: 

1580 if not row.get('value') or not row.get('src'): # ignore empty translations 

1581 continue 

1582 if row.get('type') == 'code': # ignore code translations 

1583 continue 

1584 if row.get('lang', lang) not in valid_langs: 

1585 continue 

1586 model_name = row.get('imd_model') 

1587 module_name = row['module'] 

1588 if model_name not in self.env: 1588 ↛ 1589line 1588 didn't jump to line 1589 because the condition on line 1588 was never true

1589 continue 

1590 field_name = row['name'].split(',')[1] 

1591 field = self.env[model_name]._fields.get(field_name) 

1592 if not field or not field.translate or not field.store: 

1593 continue 

1594 xmlid = module_name + '.' + row['imd_name'] 

1595 if xmlids and xmlid not in xmlids: 1595 ↛ 1596line 1595 didn't jump to line 1596 because the condition on line 1595 was never true

1596 continue 

1597 if row.get('type') == 'model' and field.translate is True: 

1598 self.model_translations[model_name][field_name][xmlid][lang] = row['value'] 

1599 self.imported_langs.add(lang) 

1600 elif row.get('type') == 'model_terms' and callable(field.translate): 1600 ↛ 1579line 1600 didn't jump to line 1579 because the condition on line 1600 was always true

1601 self.model_terms_translations[model_name][field_name][xmlid][row['src']][lang] = row['value'] 

1602 self.imported_langs.add(lang) 

1603 

1604 def save(self, overwrite=False, force_overwrite=False): 

1605 """ Save translations to the database. 

1606 

1607 For a record with 'noupdate' in ``ir_model_data``, its existing translations 

1608 will be overwritten if ``force_overwrite or (not noupdate and overwrite)``. 

1609 

1610 An existing translation means: 

1611 * model translation: the ``jsonb`` value in database has the language code as key; 

1612 * model terms translation: the term value in the language is different from the term value in ``en_US``. 

1613 """ 

1614 if not self.model_translations and not self.model_terms_translations: 

1615 return 

1616 

1617 cr = self.cr 

1618 env = self.env 

1619 env.flush_all() 

1620 

1621 for model_name, model_dictionary in self.model_terms_translations.items(): 

1622 Model = env[model_name] 

1623 model_table = Model._table 

1624 fields = Model._fields 

1625 # field_name, {xmlid: {src: {lang: value}}} 

1626 for field_name, field_dictionary in model_dictionary.items(): 

1627 field = fields.get(field_name) 

1628 for sub_xmlids in split_every(cr.IN_MAX, field_dictionary.keys()): 

1629 # [module_name, imd_name, module_name, imd_name, ...] 

1630 params = [] 

1631 for xmlid in sub_xmlids: 

1632 params.extend(xmlid.split('.', maxsplit=1)) 

1633 cr.execute(f''' 

1634 SELECT m.id, imd.module || '.' || imd.name, m."{field_name}", imd.noupdate 

1635 FROM "{model_table}" m, "ir_model_data" imd 

1636 WHERE m.id = imd.res_id 

1637 AND ({" OR ".join(["(imd.module = %s AND imd.name = %s)"] * (len(params) // 2))}) 

1638 ''', params) 

1639 

1640 # [id, translations, id, translations, ...] 

1641 params = [] 

1642 for id_, xmlid, values, noupdate in cr.fetchall(): 

1643 if not values: 

1644 continue 

1645 _value_en = values.get('_en_US', values['en_US']) 

1646 if not _value_en: 1646 ↛ 1647line 1646 didn't jump to line 1647 because the condition on line 1646 was never true

1647 continue 

1648 

1649 # {src: {lang: value}} 

1650 record_dictionary = field_dictionary[xmlid] 

1651 langs = {lang for translations in record_dictionary.values() for lang in translations.keys()} 

1652 translation_dictionary = field.get_translation_dictionary( 

1653 _value_en, 

1654 { 

1655 k: values.get(f'_{k}', v) 

1656 for k, v in values.items() 

1657 if k in langs 

1658 } 

1659 ) 

1660 

1661 if force_overwrite or (not noupdate and overwrite): 

1662 # overwrite existing translations 

1663 for term_en, translations in record_dictionary.items(): 

1664 translation_dictionary[term_en].update(translations) 

1665 else: 

1666 # keep existing translations 

1667 for term_en, translations in record_dictionary.items(): 

1668 translations.update({k: v for k, v in translation_dictionary[term_en].items() if v != term_en}) 

1669 translation_dictionary[term_en] = translations 

1670 

1671 changed_values = {} 

1672 for lang in langs: 

1673 # translate and confirm model_terms translations 

1674 new_val = field.translate(lambda term: translation_dictionary.get(term, {}).get(lang), _value_en) 

1675 if values.get(lang, None) != new_val: 1675 ↛ 1677line 1675 didn't jump to line 1677 because the condition on line 1675 was always true

1676 changed_values[lang] = new_val 

1677 if f'_{lang}' in values: 1677 ↛ 1678line 1677 didn't jump to line 1678 because the condition on line 1677 was never true

1678 changed_values[f'_{lang}'] = None 

1679 if changed_values: 1679 ↛ 1642line 1679 didn't jump to line 1642 because the condition on line 1679 was always true

1680 params.extend((id_, Json(changed_values))) 

1681 if params: 

1682 env.cr.execute(f""" 

1683 UPDATE "{model_table}" AS m 

1684 SET "{field_name}" = jsonb_strip_nulls("{field_name}" || t.value) 

1685 FROM ( 

1686 VALUES {', '.join(['(%s, %s::jsonb)'] * (len(params) // 2))} 

1687 ) AS t(id, value) 

1688 WHERE m.id = t.id 

1689 """, params) 

1690 

1691 self.model_terms_translations.clear() 

1692 

1693 for model_name, model_dictionary in self.model_translations.items(): 

1694 Model = env[model_name] 

1695 model_table = Model._table 

1696 for field_name, field_dictionary in model_dictionary.items(): 

1697 for sub_field_dictionary in split_every(cr.IN_MAX, field_dictionary.items()): 

1698 # [xmlid, translations, xmlid, translations, ...] 

1699 params = [] 

1700 for xmlid, translations in sub_field_dictionary: 

1701 params.extend([*xmlid.split('.', maxsplit=1), Json(translations)]) 

1702 if not force_overwrite: 1702 ↛ 1707line 1702 didn't jump to line 1707 because the condition on line 1702 was always true

1703 value_query = f"""CASE WHEN {overwrite} IS TRUE AND imd.noupdate IS FALSE 

1704 THEN m."{field_name}" || t.value 

1705 ELSE t.value || m."{field_name}"END""" 

1706 else: 

1707 value_query = f'm."{field_name}" || t.value' 

1708 env.cr.execute(f""" 

1709 UPDATE "{model_table}" AS m 

1710 SET "{field_name}" = {value_query} 

1711 FROM ( 

1712 VALUES {', '.join(['(%s, %s, %s::jsonb)'] * (len(params) // 3))} 

1713 ) AS t(imd_module, imd_name, value) 

1714 JOIN "ir_model_data" AS imd 

1715 ON imd."model" = '{model_name}' AND imd.name = t.imd_name AND imd.module = t.imd_module 

1716 WHERE imd."res_id" = m."id" 

1717 """, params) 

1718 

1719 self.model_translations.clear() 

1720 

1721 env.invalidate_all() 

1722 env.registry.clear_cache() 

1723 if self.verbose: 1723 ↛ 1724line 1723 didn't jump to line 1724 because the condition on line 1723 was never true

1724 _logger.info("translations are loaded successfully") 

1725 

1726 

1727def get_locales(lang=None): 

1728 if lang is None: 1728 ↛ 1731line 1728 didn't jump to line 1731 because the condition on line 1728 was always true

1729 lang = locale.getlocale()[0] 

1730 

1731 def process(enc): 

1732 ln = locale._build_localename((lang, enc)) 

1733 yield ln 

1734 nln = locale.normalize(ln) 

1735 if nln != ln: 

1736 yield nln 

1737 

1738 for x in process('utf8'): yield x 

1739 

1740 prefenc = locale.getpreferredencoding() 

1741 if prefenc: 

1742 for x in process(prefenc): yield x 

1743 

1744 prefenc = { 

1745 'latin1': 'latin9', 

1746 'iso-8859-1': 'iso8859-15', 

1747 'cp1252': '1252', 

1748 }.get(prefenc.lower()) 

1749 if prefenc: 

1750 for x in process(prefenc): yield x 

1751 

1752 yield lang 

1753 

1754 

1755def resetlocale(): 

1756 # locale.resetlocale is bugged with some locales. 

1757 for ln in get_locales(): 1757 ↛ exitline 1757 didn't return from function 'resetlocale' because the loop on line 1757 didn't complete

1758 try: 

1759 return locale.setlocale(locale.LC_ALL, ln) 

1760 except locale.Error: 

1761 continue 

1762 

1763 

1764def load_language(cr, lang): 

1765 """ Loads a translation terms for a language. 

1766 

1767 Used mainly to automate language loading at db initialization. 

1768 

1769 :param cr: 

1770 :param str lang: language ISO code with optional underscore (``_``) and 

1771 l10n flavor (ex: 'fr', 'fr_BE', but not 'fr-BE') 

1772 """ 

1773 from odoo import api # noqa: PLC0415 

1774 env = api.Environment(cr, api.SUPERUSER_ID, {}) 

1775 lang_ids = env['res.lang'].with_context(active_test=False).search([('code', '=', lang)]).ids 

1776 installer = env['base.language.install'].create({'lang_ids': [(6, 0, lang_ids)]}) 

1777 installer.lang_install() 

1778 

1779 

1780def get_base_langs(lang: str) -> list[str]: 

1781 base_lang = lang.split('_', 1)[0] 

1782 langs = [base_lang] 

1783 # LAC (~non-peninsular) spanishes have a second base 

1784 if base_lang == 'es' and lang not in ('es_ES', 'es_419'): 

1785 langs.append('es_419') 

1786 # HK Chinese ~ Taiwan Chinese 

1787 if lang == 'zh_HK': 1787 ↛ 1788line 1787 didn't jump to line 1788 because the condition on line 1787 was never true

1788 langs.append('zh_TW') 

1789 if lang != base_lang: 

1790 langs.append(lang) 

1791 return langs 

1792 

1793 

1794def get_po_paths(module_name: str, lang: str) -> Iterator[str]: 

1795 po_paths = ( 

1796 join(module_name, dir_, filename + '.po') 

1797 for filename in get_base_langs(lang) 

1798 for dir_ in ('i18n', 'i18n_extra') 

1799 ) 

1800 for path in po_paths: 

1801 with suppress(FileNotFoundError): 

1802 yield file_path(path) 

1803 

1804 

1805def get_datafile_translation_path(module_name: str) -> Iterator[str]: 

1806 from odoo.modules import Manifest # noqa: PLC0415 

1807 # if we are importing a module, we have an env, hide warnings 

1808 manifest = Manifest.for_addon(module_name, display_warning=False) or {} 

1809 for data_type in ('data', 'demo'): 

1810 for path in manifest.get(data_type, ()): 

1811 if path.endswith(('.xml', '.csv')): 1811 ↛ 1810line 1811 didn't jump to line 1810 because the condition on line 1811 was always true

1812 yield file_path(join(module_name, path)) 

1813 

1814 

1815class CodeTranslations: 

1816 def __init__(self): 

1817 # {(module_name, lang): {src: value}} 

1818 self.python_translations = {} 

1819 # {(module_name, lang): {'message': [{'id': src, 'string': value}]} 

1820 self.web_translations = {} 

1821 

1822 @staticmethod 

1823 def _read_code_translations_file(fileobj, filter_func): 

1824 """ read and return code translations from fileobj with filter filter_func 

1825 

1826 :param func filter_func: a filter function to drop unnecessary code translations 

1827 """ 

1828 # current, we assume the fileobj is from the source code, which only contains the translation for the current module 

1829 # don't use it in the import logic 

1830 translations = {} 

1831 fileobj.seek(0) 

1832 reader = translation_file_reader(fileobj, fileformat='po') 

1833 for row in reader: 

1834 if row.get('type') == 'code' and row.get('src') and filter_func(row): 

1835 translations[row['src']] = row['value'] 

1836 return translations 

1837 

1838 @staticmethod 

1839 def _get_code_translations(module_name, lang, filter_func): 

1840 po_paths = get_po_paths(module_name, lang) 

1841 translations = {} 

1842 for po_path in po_paths: 

1843 try: 

1844 with file_open(po_path, mode='rb') as fileobj: 

1845 p = CodeTranslations._read_code_translations_file(fileobj, filter_func) 

1846 translations.update(p) 

1847 except IOError: 

1848 iso_lang = get_iso_codes(lang) 

1849 filename = '[lang: %s][format: %s]' % (iso_lang or 'new', 'po') 

1850 _logger.exception("couldn't read translation file %s", filename) 

1851 return translations 

1852 

1853 def _load_python_translations(self, module_name, lang): 

1854 def filter_func(row): 

1855 return row.get('value') and PYTHON_TRANSLATION_COMMENT in row['comments'] 

1856 translations = CodeTranslations._get_code_translations(module_name, lang, filter_func) 

1857 self.python_translations[(module_name, lang)] = ReadonlyDict(translations) 

1858 

1859 def _load_web_translations(self, module_name, lang): 

1860 def filter_func(row): 

1861 return row.get('value') and JAVASCRIPT_TRANSLATION_COMMENT in row['comments'] 

1862 translations = CodeTranslations._get_code_translations(module_name, lang, filter_func) 

1863 self.web_translations[(module_name, lang)] = ReadonlyDict({ 

1864 "messages": tuple( 

1865 ReadonlyDict({"id": src, "string": value}) 

1866 for src, value in translations.items()) 

1867 }) 

1868 

1869 def get_python_translations(self, module_name, lang): 

1870 if (module_name, lang) not in self.python_translations: 

1871 self._load_python_translations(module_name, lang) 

1872 return self.python_translations[(module_name, lang)] 

1873 

1874 def get_web_translations(self, module_name, lang): 

1875 if (module_name, lang) not in self.web_translations: 

1876 self._load_web_translations(module_name, lang) 

1877 return self.web_translations[(module_name, lang)] 

1878 

1879 

1880code_translations = CodeTranslations() 

1881 

1882 

1883def _get_translation_upgrade_queries(cr, field): 

1884 """ Return a pair of lists ``migrate_queries, cleanup_queries`` of SQL queries. The queries in 

1885 ``migrate_queries`` do migrate the data from table ``_ir_translation`` to the corresponding 

1886 field's column, while the queries in ``cleanup_queries`` remove the corresponding data from 

1887 table ``_ir_translation``. 

1888 """ 

1889 from odoo.modules.registry import Registry # noqa: PLC0415 

1890 Model = Registry(cr.dbname)[field.model_name] 

1891 translation_name = f"{field.model_name},{field.name}" 

1892 migrate_queries = [] 

1893 cleanup_queries = [] 

1894 

1895 if field.translate is True: 

1896 emtpy_src = """'{"en_US": ""}'::jsonb""" 

1897 query = f""" 

1898 WITH t AS ( 

1899 SELECT it.res_id as res_id, jsonb_object_agg(it.lang, it.value) AS value, bool_or(imd.noupdate) AS noupdate 

1900 FROM _ir_translation it 

1901 LEFT JOIN ir_model_data imd 

1902 ON imd.model = %s AND imd.res_id = it.res_id AND imd.module != '__export__' 

1903 WHERE it.type = 'model' AND it.name = %s AND it.state = 'translated' 

1904 GROUP BY it.res_id 

1905 ) 

1906 UPDATE {Model._table} m 

1907 SET "{field.name}" = CASE WHEN m."{field.name}" IS NULL THEN {emtpy_src} || t.value 

1908 WHEN t.noupdate IS FALSE THEN t.value || m."{field.name}" 

1909 ELSE m."{field.name}" || t.value 

1910 END 

1911 FROM t 

1912 WHERE t.res_id = m.id 

1913 """ 

1914 migrate_queries.append(cr.mogrify(query, [Model._name, translation_name]).decode()) 

1915 

1916 query = "DELETE FROM _ir_translation WHERE type = 'model' AND state = 'translated' AND name = %s" 

1917 cleanup_queries.append(cr.mogrify(query, [translation_name]).decode()) 

1918 

1919 # upgrade model_terms translation: one update per field per record 

1920 if callable(field.translate): 

1921 cr.execute("SELECT code FROM res_lang WHERE active = 't'") 

1922 languages = {l[0] for l in cr.fetchall()} 

1923 query = f""" 

1924 SELECT t.res_id, m."{field.name}", t.value, t.noupdate 

1925 FROM t 

1926 JOIN "{Model._table}" m ON t.res_id = m.id 

1927 """ 

1928 if translation_name == 'ir.ui.view,arch_db': 

1929 cr.execute("SELECT id from ir_module_module WHERE name = 'website' AND state='installed'") 

1930 if cr.fetchone(): 

1931 query = f""" 

1932 SELECT t.res_id, m."{field.name}", t.value, t.noupdate, l.code 

1933 FROM t 

1934 JOIN "{Model._table}" m ON t.res_id = m.id 

1935 JOIN website w ON m.website_id = w.id 

1936 JOIN res_lang l ON w.default_lang_id = l.id 

1937 UNION 

1938 SELECT t.res_id, m."{field.name}", t.value, t.noupdate, 'en_US' 

1939 FROM t 

1940 JOIN "{Model._table}" m ON t.res_id = m.id 

1941 WHERE m.website_id IS NULL 

1942 """ 

1943 cr.execute(f""" 

1944 WITH t0 AS ( 

1945 -- aggregate translations by source term -- 

1946 SELECT res_id, lang, jsonb_object_agg(src, value) AS value 

1947 FROM _ir_translation 

1948 WHERE type = 'model_terms' AND name = %s AND state = 'translated' 

1949 GROUP BY res_id, lang 

1950 ), 

1951 t AS ( 

1952 -- aggregate translations by lang -- 

1953 SELECT t0.res_id AS res_id, jsonb_object_agg(t0.lang, t0.value) AS value, bool_or(imd.noupdate) AS noupdate 

1954 FROM t0 

1955 LEFT JOIN ir_model_data imd 

1956 ON imd.model = %s AND imd.res_id = t0.res_id 

1957 GROUP BY t0.res_id 

1958 )""" + query, [translation_name, Model._name]) 

1959 for id_, new_translations, translations, noupdate, *extra in cr.fetchall(): 

1960 if not new_translations: 

1961 continue 

1962 # new_translations contains translations updated from the latest po files 

1963 src_value = new_translations.pop('en_US') 

1964 src_terms = field.get_trans_terms(src_value) 

1965 for lang, dst_value in new_translations.items(): 

1966 terms_mapping = translations.setdefault(lang, {}) 

1967 dst_terms = field.get_trans_terms(dst_value) 

1968 for src_term, dst_term in zip(src_terms, dst_terms): 

1969 if src_term == dst_term or noupdate: 

1970 terms_mapping.setdefault(src_term, dst_term) 

1971 else: 

1972 terms_mapping[src_term] = dst_term 

1973 new_values = { 

1974 lang: field.translate(terms_mapping.get, src_value) 

1975 for lang, terms_mapping in translations.items() 

1976 } 

1977 if "en_US" not in new_values: 

1978 new_values["en_US"] = field.translate(lambda v: None, src_value) 

1979 if extra and extra[0] not in new_values: 

1980 new_values[extra[0]] = field.translate(lambda v: None, src_value) 

1981 elif not extra: 

1982 missing_languages = languages - set(translations) 

1983 if missing_languages: 

1984 src_value = field.translate(lambda v: None, src_value) 

1985 for lang in sorted(missing_languages): 

1986 new_values[lang] = src_value 

1987 query = f'UPDATE "{Model._table}" SET "{field.name}" = %s WHERE id = %s' 

1988 migrate_queries.append(cr.mogrify(query, [Json(new_values), id_]).decode()) 

1989 

1990 query = "DELETE FROM _ir_translation WHERE type = 'model_terms' AND state = 'translated' AND name = %s" 

1991 cleanup_queries.append(cr.mogrify(query, [translation_name]).decode()) 

1992 

1993 return migrate_queries, cleanup_queries