[FIX] ir.translation: export/import of QWeb terms

Fixes the translation term import/export logic to
support terms inside QWeb templates.

Refactored a bit the export code so the babel-based
QWeb terms extractor for ./static/src/*.xml files
uses the same logic as the regular extractor for
ir.ui.views with type QWeb.

Server-side QWeb rendering uses a mix of the native
 view inheritance mechanism and the template inclusion
(t-call) mechanism. During rendering the translations
are only applied at "template" level, *after* the
view inheritance has already been resolved.
As a result translations are local to a template,
not to the inherited view in which they are actually
written.
In terms of exporting PO[T] files, this is done by
resolving the "root" QWeb template a view belongs
to, and using it as the location of the translated term.

During import there is one extra quirk for QWeb
terms: they need to be linked to the `website` model
rather than the actual `ir.ui.view` model they
are really pointing to, so the rendering phase can
properly recognize them.
This commit is contained in:
Olivier Dony 2014-08-13 11:08:02 +02:00
parent 4114c761dd
commit 868a77616d
3 changed files with 93 additions and 57 deletions

View File

@ -78,6 +78,11 @@ class ir_translation_import_cursor(object):
"""Feed a translation, as a dictionary, into the cursor """Feed a translation, as a dictionary, into the cursor
""" """
params = dict(trans_dict, state="translated" if trans_dict['value'] else "to_translate") params = dict(trans_dict, state="translated" if trans_dict['value'] else "to_translate")
# ugly hack for QWeb views - pending refactoring of translations in master
if params['imd_model'] == 'website' and params['type'] == 'view':
params['imd_model'] = "ir.ui.view"
self._cr.execute("""INSERT INTO %s (name, lang, res_id, src, type, imd_model, module, imd_name, value, state, comments) self._cr.execute("""INSERT INTO %s (name, lang, res_id, src, type, imd_model, module, imd_name, value, state, comments)
VALUES (%%(name)s, %%(lang)s, %%(res_id)s, %%(src)s, %%(type)s, %%(imd_model)s, %%(module)s, VALUES (%%(name)s, %%(lang)s, %%(res_id)s, %%(src)s, %%(type)s, %%(imd_model)s, %%(module)s,
%%(imd_name)s, %%(value)s, %%(state)s, %%(comments)s)""" % self._table_name, %%(imd_name)s, %%(value)s, %%(state)s, %%(comments)s)""" % self._table_name,
@ -98,15 +103,14 @@ class ir_translation_import_cursor(object):
FROM ir_model_data AS imd FROM ir_model_data AS imd
WHERE ti.res_id IS NULL WHERE ti.res_id IS NULL
AND ti.module IS NOT NULL AND ti.imd_name IS NOT NULL AND ti.module IS NOT NULL AND ti.imd_name IS NOT NULL
AND ti.module = imd.module AND ti.imd_name = imd.name AND ti.module = imd.module AND ti.imd_name = imd.name
AND ti.imd_model = imd.model; """ % self._table_name) AND ti.imd_model = imd.model; """ % self._table_name)
if self._debug: if self._debug:
cr.execute("SELECT module, imd_model, imd_name FROM %s " \ cr.execute("SELECT module, imd_name, imd_model FROM %s " \
"WHERE res_id IS NULL AND module IS NOT NULL" % self._table_name) "WHERE res_id IS NULL AND module IS NOT NULL" % self._table_name)
for row in cr.fetchall(): for row in cr.fetchall():
_logger.debug("ir.translation.cursor: missing res_id for %s. %s/%s ", *row) _logger.info("ir.translation.cursor: missing res_id for %s.%s <%s> ", *row)
# Records w/o res_id must _not_ be inserted into our db, because they are # Records w/o res_id must _not_ be inserted into our db, because they are
# referencing non-existent data. # referencing non-existent data.
@ -297,7 +301,7 @@ class ir_translation(osv.osv):
AND src=%s""" AND src=%s"""
params = (lang or '', types, tools.ustr(source)) params = (lang or '', types, tools.ustr(source))
if res_id: if res_id:
query += "AND res_id=%s" query += " AND res_id=%s"
params += (res_id,) params += (res_id,)
if name: if name:
query += " AND name=%s" query += " AND name=%s"

View File

@ -38,7 +38,7 @@ import openerp
from openerp import tools, api from openerp import tools, api
from openerp.http import request from openerp.http import request
from openerp.osv import fields, osv, orm from openerp.osv import fields, osv, orm
from openerp.tools import graph, SKIPPED_ELEMENT_TYPES from openerp.tools import graph, SKIPPED_ELEMENT_TYPES, SKIPPED_ELEMENTS
from openerp.tools.parse_version import parse_version from openerp.tools.parse_version import parse_version
from openerp.tools.safe_eval import safe_eval as eval from openerp.tools.safe_eval import safe_eval as eval
from openerp.tools.view_validation import valid_view from openerp.tools.view_validation import valid_view
@ -957,7 +957,7 @@ class view(osv.osv):
return None return None
return Translations._get_source(cr, uid, 'website', 'view', lang, text, id_) return Translations._get_source(cr, uid, 'website', 'view', lang, text, id_)
if arch.tag not in ['script']: if type(arch) not in SKIPPED_ELEMENT_TYPES and arch.tag not in SKIPPED_ELEMENTS:
text = get_trans(arch.text) text = get_trans(arch.text)
if text: if text:
arch.text = arch.text.replace(arch.text.strip(), text) arch.text = arch.text.replace(arch.text.strip(), text)
@ -965,7 +965,7 @@ class view(osv.osv):
if tail: if tail:
arch.tail = arch.tail.replace(arch.tail.strip(), tail) arch.tail = arch.tail.replace(arch.tail.strip(), tail)
for attr_name in ('title', 'alt', 'placeholder'): for attr_name in ('title', 'alt', 'label', 'placeholder'):
attr = get_trans(arch.get(attr_name)) attr = get_trans(arch.get(attr_name))
if attr: if attr:
arch.set(attr_name, attr) arch.set(attr_name, attr)

View File

@ -49,6 +49,8 @@ _logger = logging.getLogger(__name__)
# used to notify web client that these translations should be loaded in the UI # used to notify web client that these translations should be loaded in the UI
WEB_TRANSLATION_COMMENT = "openerp-web" WEB_TRANSLATION_COMMENT = "openerp-web"
SKIPPED_ELEMENTS = ('script', 'style')
_LOCALE2WIN32 = { _LOCALE2WIN32 = {
'af_ZA': 'Afrikaans_South Africa', 'af_ZA': 'Afrikaans_South Africa',
'sq_AL': 'Albanian_Albania', 'sq_AL': 'Albanian_Albania',
@ -536,28 +538,34 @@ def trans_parse_rml(de):
res.extend(trans_parse_rml(n)) res.extend(trans_parse_rml(n))
return res return res
def trans_parse_view(de): def _push(callback, term, source_line):
res = [] """ Sanity check before pushing translation terms """
if not isinstance(de, SKIPPED_ELEMENT_TYPES) and de.text and de.text.strip(): term = (term or "").strip().encode('utf8')
res.append(de.text.strip().encode("utf8")) # Avoid non-char tokens like ':' '...' '.00' etc.
if de.tail and de.tail.strip(): if len(term) > 8 or any(x.isalpha() for x in term):
res.append(de.tail.strip().encode("utf8")) callback(term, source_line)
if de.tag == 'attribute' and de.get("name") == 'string':
if de.text: def trans_parse_view(element, callback):
res.append(de.text.encode("utf8")) """ Helper method to recursively walk an etree document representing a
if de.get("string"): regular view and call ``callback(term)`` for each translatable term
res.append(de.get('string').encode("utf8")) that is found in the document.
if de.get("help"):
res.append(de.get('help').encode("utf8")) :param ElementTree element: root of etree document to extract terms from
if de.get("sum"): :param callable callback: a callable in the form ``f(term, source_line)``,
res.append(de.get('sum').encode("utf8")) that will be called for each extracted term.
if de.get("confirm"): """
res.append(de.get('confirm').encode("utf8")) if (not isinstance(element, SKIPPED_ELEMENT_TYPES)
if de.get("placeholder"): and element.tag.lower() not in SKIPPED_ELEMENTS
res.append(de.get('placeholder').encode("utf8")) and element.text):
for n in de: _push(callback, element.text, element.sourceline)
res.extend(trans_parse_view(n)) if element.tail:
return res _push(callback, element.tail, element.sourceline)
for attr in ('string', 'help', 'sum', 'confirm', 'placeholder'):
value = element.get(attr)
if value:
_push(callback, value, element.sourceline)
for n in element:
trans_parse_view(n, callback)
# tests whether an object is in a list of modules # tests whether an object is in a list of modules
def in_modules(object_name, modules): def in_modules(object_name, modules):
@ -573,6 +581,30 @@ def in_modules(object_name, modules):
module = module_dict.get(module, module) module = module_dict.get(module, module)
return module in modules return module in modules
def _extract_translatable_qweb_terms(element, callback):
""" Helper method to walk an etree document representing
a QWeb template, and call ``callback(term)`` for each
translatable term that is found in the document.
:param ElementTree element: root of etree document to extract terms from
:param callable callback: a callable in the form ``f(term, source_line)``,
that will be called for each extracted term.
"""
# not using elementTree.iterparse because we need to skip sub-trees in case
# the ancestor element had a reason to be skipped
for el in element:
if isinstance(el, SKIPPED_ELEMENT_TYPES): continue
if (el.tag.lower() not in SKIPPED_ELEMENTS
and "t-js" not in el.attrib
and not ("t-jquery" in el.attrib and "t-operation" not in el.attrib)
and not ("t-translation" in el.attrib and
el.attrib["t-translation"].strip() == "off")):
_push(callback, el.text, el.sourceline)
for att in ('title', 'alt', 'label', 'placeholder'):
if att in el.attrib:
_push(callback, el.attrib[att], el.sourceline)
_extract_translatable_qweb_terms(el, callback)
_push(callback, el.tail, el.sourceline)
def babel_extract_qweb(fileobj, keywords, comment_tags, options): def babel_extract_qweb(fileobj, keywords, comment_tags, options):
"""Babel message extractor for qweb template files. """Babel message extractor for qweb template files.
@ -588,31 +620,11 @@ def babel_extract_qweb(fileobj, keywords, comment_tags, options):
""" """
result = [] result = []
def handle_text(text, lineno): def handle_text(text, lineno):
text = (text or "").strip() result.append((lineno, None, text, []))
if len(text) > 1: # Avoid mono-char tokens like ':' ',' etc.
result.append((lineno, None, text, []))
# not using elementTree.iterparse because we need to skip sub-trees in case
# the ancestor element had a reason to be skipped
def iter_elements(current_element):
for el in current_element:
if isinstance(el, SKIPPED_ELEMENT_TYPES): continue
if "t-js" not in el.attrib and \
not ("t-jquery" in el.attrib and "t-operation" not in el.attrib) and \
not ("t-translation" in el.attrib and el.attrib["t-translation"].strip() == "off"):
handle_text(el.text, el.sourceline)
for att in ('title', 'alt', 'label', 'placeholder'):
if att in el.attrib:
handle_text(el.attrib[att], el.sourceline)
iter_elements(el)
handle_text(el.tail, el.sourceline)
tree = etree.parse(fileobj) tree = etree.parse(fileobj)
iter_elements(tree.getroot()) _extract_translatable_qweb_terms(tree.getroot(), handle_text)
return result return result
def trans_generate(lang, modules, cr): def trans_generate(lang, modules, cr):
dbname = cr.dbname dbname = cr.dbname
@ -649,7 +661,6 @@ def trans_generate(lang, modules, cr):
# empty and one-letter terms are ignored, they probably are not meant to be # empty and one-letter terms are ignored, they probably are not meant to be
# translated, and would be very hard to translate anyway. # translated, and would be very hard to translate anyway.
if not source or len(source.strip()) <= 1: if not source or len(source.strip()) <= 1:
_logger.debug("Ignoring empty or 1-letter source term: %r", tuple)
return return
if tuple not in _to_translate: if tuple not in _to_translate:
_to_translate.append(tuple) _to_translate.append(tuple)
@ -659,6 +670,19 @@ def trans_generate(lang, modules, cr):
return s.encode('utf8') return s.encode('utf8')
return s return s
def push(mod, type, name, res_id, term):
term = (term or '').strip()
if len(term) > 2:
push_translation(mod, type, name, res_id, term)
def get_root_view(xml_id):
view = model_data_obj.xmlid_to_object(cr, uid, xml_id)
if view:
while view.mode != 'primary':
view = view.inherit_id
xml_id = view.get_external_id(cr, uid).get(view.id, xml_id)
return xml_id
for (xml_name,model,res_id,module) in cr.fetchall(): for (xml_name,model,res_id,module) in cr.fetchall():
module = encode(module) module = encode(module)
model = encode(model) model = encode(model)
@ -680,8 +704,13 @@ def trans_generate(lang, modules, cr):
if model=='ir.ui.view': if model=='ir.ui.view':
d = etree.XML(encode(obj.arch)) d = etree.XML(encode(obj.arch))
for t in trans_parse_view(d): if obj.type == 'qweb':
push_translation(module, 'view', encode(obj.model), 0, t) view_id = get_root_view(xml_name)
push_qweb = lambda t,l: push(module, 'view', 'website', view_id, t)
_extract_translatable_qweb_terms(d, push_qweb)
else:
push_view = lambda t,l: push(module, 'view', obj.model, xml_name, t)
trans_parse_view(d, push_view)
elif model=='ir.actions.wizard': elif model=='ir.actions.wizard':
pass # TODO Can model really be 'ir.actions.wizard' ? pass # TODO Can model really be 'ir.actions.wizard' ?
@ -745,13 +774,16 @@ def trans_generate(lang, modules, cr):
_logger.exception("couldn't export translation for report %s %s %s", name, report_type, fname) _logger.exception("couldn't export translation for report %s %s %s", name, report_type, fname)
for field_name, field_def in obj._columns.items(): for field_name, field_def in obj._columns.items():
if model == 'ir.model' and field_name == 'name' and obj.name == obj.model:
# ignore model name if it is the technical one, nothing to translate
continue
if field_def.translate: if field_def.translate:
name = model + "," + field_name name = model + "," + field_name
try: try:
trad = getattr(obj, field_name) or '' term = obj[field_name] or ''
except: except:
trad = '' term = ''
push_translation(module, 'model', name, xml_name, encode(trad)) push_translation(module, 'model', name, xml_name, encode(term))
# End of data for ir.model.data query results # End of data for ir.model.data query results