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:15 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:15 +0000
1# Part of Odoo. See LICENSE file for full copyright and licensing details.
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 ”
8from __future__ import annotations
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
31from babel.messages import extract
32from lxml import etree, html
33from markupsafe import escape, Markup
34from psycopg2.extras import Json
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
42if typing.TYPE_CHECKING:
43 from odoo.api import Environment
45__all__ = [
46 "_",
47 "LazyTranslate",
48 "html_translate",
49 "xml_translate",
50]
52_logger = logging.getLogger(__name__)
54PYTHON_TRANSLATION_COMMENT = 'odoo-python'
56# translation used for javascript code in web client
57JAVASCRIPT_TRANSLATION_COMMENT = 'odoo-javascript'
59SKIPPED_ELEMENTS = ('script', 'style', 'title')
61# these direct uses of CSV are ok.
62import csv # pylint: disable=deprecated-module
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}
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}
80TRANSLATED_ATTRS.update({f't-attf-{attr}' for attr in TRANSLATED_ATTRS})
82# {column value of "ir_model_fields"."translate": orm field.translate}
83FIELD_TRANSLATE = {
84 None: False,
85 'standard': True,
86}
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')
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 )
106def is_translatable_attrib_text(node):
107 return node.tag == 'field' and node.attrib.get('widget', '') == 'url'
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}
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
126# regexpr for string formatting and extract ( ruby-style )|( jinja-style ) used in `_compile_format`
127FORMAT_REGEX = re.compile(r'(?:#\{(.+?)\})|(?:\{\{(.+?)\}\})')
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:
138 return FORMAT_REGEX.sub(lambda g: expressions.get(g.group(0)[2:-2], 'None'), translated_value)
140def translate_xml_node(node, callback, parse, serialize):
141 """ Return the translation of the given XML/HTML node.
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 """
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)
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()
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 )
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 )
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
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])
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
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
250 if pos >= len(node):
251 break
253 # node[pos] is not translatable as a whole, process it recursively
254 process(node[pos])
255 pos += 1
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)
271 process(node)
273 return node
276def parse_xml(text):
277 return etree.fromstring(text)
279def serialize_xml(node):
280 return etree.tostring(node, method='xml', encoding='unicode')
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>")
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)
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
315 # remove tags <div> and </div> from result
316 return serialize_xml(new_node)[5:-6]
318 return adapter
321_HTML_PARSER = etree.HTMLParser(encoding='utf8')
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
330def serialize_html(node):
331 return etree.tostring(node, method='html', encoding='unicode')
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
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]
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]
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
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', ' ')
375 except ValueError:
376 _logger.exception("Cannot translate malformed HTML, using source value instead")
378 return value
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]
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())
396def is_text(term):
397 """ Return whether the term has only text. """
398 return len(html.fromstring(f"<_>{term}</_>")) == 0
400xml_translate.get_text_content = get_text_content
401html_translate.get_text_content = get_text_content
403xml_translate.term_converter = xml_term_converter
404html_translate.term_converter = html_term_converter
406xml_translate.is_text = is_text
407html_translate.is_text = is_text
409xml_translate.term_adapter = xml_term_adapter
411FIELD_TRANSLATE['html_translate'] = html_translate
412FIELD_TRANSLATE['xml_translate'] = xml_translate
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
455def get_translated_module(arg: str | int | typing.Any) -> str: # frame not represented as hint
456 """Get the addons name.
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'
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
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:
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
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 ''
561def _get_translation_source(stack_level: int, module: str = '', lang: str = '', default_lang: str = '') -> tuple[str, str]:
562 if not (module and lang): 562 ↛ 567line 562 didn't jump to line 567 because the condition on line 562 was always true
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'
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)
581@functools.total_ordering
582class LazyGettext:
583 """ Lazy code translated term.
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.
590 A code using translated global variables such as:
592 ```
593 _lt = LazyTranslate(__name__)
594 LABEL = _lt("User")
596 def _compute_label(self):
597 env = self.with_env(lang=self.partner_id.lang).env
598 self.user_label = env._(LABEL)
599 ```
601 works as expected (unlike the classic get_text_alias implementation).
602 """
604 __slots__ = ('_args', '_default_lang', '_module', '_source')
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
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)
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})"
623 def __str__(self):
624 """ Translate."""
625 return self._translate()
627 def __eq__(self, other):
628 """ Prevent using equal operators
630 Prevent direct comparisons with ``self``.
631 One should compare the translation of ``self._source`` as ``str(self) == X``.
632 """
633 raise NotImplementedError()
635 def __hash__(self):
636 raise NotImplementedError()
638 def __lt__(self, other):
639 raise NotImplementedError()
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
648 def __radd__(self, other):
649 if isinstance(other, str):
650 return other + self._translate()
651 return NotImplemented
654class LazyTranslate:
655 """ Lazy translation template.
657 Usage:
658 ```
659 _lt = LazyTranslate(__name__)
660 MYSTR = _lt('Translate X')
661 ```
663 You may specify a `default_lang` to fallback to a given language on error
664 """
665 module: str
666 default_lang: str
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 '')
673 def __call__(self, source: str, *args, **kwargs) -> LazyGettext:
674 return LazyGettext(source, *args, **kwargs, _module=self.module, _default_lang=self.default_lang)
677_ = get_text_alias
678_lt = LazyGettext
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"')
688re_escaped_char = re.compile(r"(\\.)")
689re_escaped_replacements = {'n': '\n', 't': '\t',}
691def _sub_replacement(match_obj):
692 return re_escaped_replacements.get(match_obj.group(1)[1], match_obj.group(1)[1])
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])
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]
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))
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 = ""
727 def __iter__(self):
728 for entry in self.source:
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]
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"]
747 yield entry
750class CSVDataFileReader:
751 def __init__(self, source, module: str):
752 """Read the translations in CSV data file.
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 = ""
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 }
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
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 }
815class PoFileReader:
816 """ Iterate over po file to return Odoo translation entries """
817 def __init__(self, source):
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
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)
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))
847 def __iter__(self):
848 for entry in self.pofile:
849 if entry.obsolete:
850 continue
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
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
894 match = re.match(r'(selection):([\w.]+),([\w]+)', occurrence)
895 if match:
896 _logger.info("Skipped deprecated occurrence %s", occurrence)
897 continue
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)
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)
910 if fileformat == 'po':
911 return PoFileWriter(target, lang=lang)
913 if fileformat == 'tgz':
914 return TarFileWriter(target, lang=lang)
916 raise Exception(_('Unrecognized extension: must be one of '
917 '.csv, .po, or .tgz (received .%s).') % fileformat)
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"))
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))
934class PoFileWriter:
935 """ Iterate over po file to return Odoo translation entries """
936 def __init__(self, target, lang):
938 self.buffer = target
939 self.lang = lang
940 self.po = polib.POFile()
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)
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']))
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 }
981 # buffer expects bytes
982 self.buffer.write(str(self.po).encode())
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)
1010class TarFileWriter:
1012 def __init__(self, target, lang):
1013 self.tar = tarfile.open(fileobj=target, mode='w|gz')
1014 self.lang = lang
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)
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)
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())
1037 self.tar.addfile(info, fileobj=buf)
1039 self.tar.close()
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
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
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)
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.
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"):
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)
1098def babel_extract_qweb(fileobj, keywords, comment_tags, options):
1099 """Babel message extractor for qweb template files.
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
1119def extract_formula_terms(formula):
1120 """Extract strings in a spreadsheet formula which are arguments to '_t' functions
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
1138def extract_spreadsheet_terms(fileobj, keywords, comment_tags, options):
1139 """Babel message extractor for spreadsheet data files.
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 )
1184ImdInfo = namedtuple('ExternalId', ['name', 'model', 'res_id', 'module'])
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 = []
1195 def __bool__(self):
1196 return bool(self._to_translate)
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)
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))
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
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 '')
1257 def _get_translatable_records(self, imd_records):
1258 """ Filter the records that are translatable
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
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()
1273 if not self.env[model]._translate:
1274 return self.env[model].browse()
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
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
1305 return records
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())
1323 self._export_translatable_records(self._records, self._field_names)
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
1330 fields = records._fields
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])
1342 if not any(fields[field_name].translate and fields[field_name].store for field_name in field_names):
1343 return
1345 records._BaseModel__ensure_xml_id()
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"""
1354 self._cr.execute(query, (model_name, records.ids))
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 }
1361 self._export_imdinfo(model_name, imd_per_id)
1364class TranslationModuleReader(TranslationReader):
1365 """ Retrieve translated records per module
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 """
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 ]
1384 self._export_translatable_records()
1385 self._export_translatable_resources()
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']))
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)"""
1404 self._cr.execute(query, (modules,))
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)
1412 for model, imd_per_id in records_per_model.items():
1413 self._export_imdinfo(model, imd_per_id)
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
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
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}):
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()
1463 def _export_translatable_resources(self):
1464 """ Export translations for static terms
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 """
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)
1481 spreadsheet_files_regex = re.compile(r".*_dashboard(\.osheet)?\.json$")
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
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)
1514def DeepDefaultDict():
1515 return defaultdict(DeepDefaultDict)
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 """
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, {})
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()
1536 def load_file(self, filepath, lang, xmlids=None, module=None):
1537 """ Load translations from the given file path.
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)
1551 def load(self, fileobj, fileformat, lang, xmlids=None, module=None):
1552 """Load translations from the given file object.
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)
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)
1604 def save(self, overwrite=False, force_overwrite=False):
1605 """ Save translations to the database.
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)``.
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
1617 cr = self.cr
1618 env = self.env
1619 env.flush_all()
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)
1640 # [id, translations, id, translations, ...]
1641 params = []
1642 for id_, xmlid, values, noupdate in cr.fetchall():
1643 if not values: 1643 ↛ 1644line 1643 didn't jump to line 1644 because the condition on line 1643 was never true
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
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 )
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
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)
1691 self.model_terms_translations.clear()
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)
1719 self.model_translations.clear()
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")
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]
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
1738 for x in process('utf8'): yield x
1740 prefenc = locale.getpreferredencoding()
1741 if prefenc:
1742 for x in process(prefenc): yield x
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
1752 yield lang
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
1764def load_language(cr, lang):
1765 """ Loads a translation terms for a language.
1767 Used mainly to automate language loading at db initialization.
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()
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
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)
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))
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 = {}
1822 @staticmethod
1823 def _read_code_translations_file(fileobj, filter_func):
1824 """ read and return code translations from fileobj with filter filter_func
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
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
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)
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 })
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)]
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)]
1880code_translations = CodeTranslations()
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 = []
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())
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())
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())
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())
1993 return migrate_queries, cleanup_queries