--- /dev/null
+.. _reference/translations:
+
+===================
+Translating Modules
+===================
+
+Exporting translatable term
+===========================
+
+A number of terms in your modules are "implicitly translatable" as a result,
+even if you haven't done any specific work towards translation you can export
+your module's translatable terms and may find content to work with.
+
+.. todo:: needs technical features
+
+Translations export is done via the administration interface by logging into
+the backend interface and opening :menuselection:`Settings --> Translations
+--> Import / Export --> Export Translations`
+
+* leave the language to the default (new language/empty template)
+* select the `PO File`_ format
+* select your module
+* click :guilabel:`Export` and download the file
+
+.. image:: translations/po-export.*
+ :align: center
+ :width: 75%
+
+This gives you a file called :file:`{yourmodule}.pot` which should be moved to
+the :file:`{yourmodule}/i18n/` directory. The file is a *PO Template* which
+simply lists translatable strings and from which actual translations (PO files)
+can be created. PO files can be created using msginit_, with a dedicated
+translation tool like POEdit_ or by simply copying the template to a new file
+called :file:`{language}.po`. Translation files should be put in
+:file:`{yourmodule}/i18n/`, next to :file:`{yourmodule}.pot`, and will be
+automatically loaded by Odoo when the corresponding language is installed (via
+:menuselection:`Settings --> Translations --> Load a Translation`)
+
+.. note:: translations for all loaded languages are also installed or updated
+ when installing or updating a module
+
+Implicit exports
+================
+
+Odoo automatically exports translatable strings from "data"-type content:
+
+* in non-QWeb views, all text nodes are exported as well as the content of
+ the ``string``, ``help``, ``sum``, ``confirm`` and ``placeholder``
+ attributes
+* QWeb templates (both server-side and client-side), all text nodes are
+ exported except inside ``t-translation="off"`` blocks, the content of the
+ ``title``, ``alt``, ``label`` and ``placeholder`` attributes are also
+ exported
+* for :class:`~openerp.fields.Field`, unless their model is marked with
+ ``_translate = False``:
+
+ * their ``string`` and ``help`` attributes are exported
+ * if ``selection`` is present and a list (or tuple), it's exported
+ * if their ``translate`` attribute is set to ``True``, all of their existing
+ values (across all records) are exported
+* help/error messages of :attr:`~openerp.models.Model._constraints` and
+ :attr:`~openerp.models.Model._sql_constraints` are exported
+
+Explicit exports
+================
+
+When it comes to more "imperative" situations in Python code or Javascript
+code, Odoo is not able to automatically export translatable terms and they
+must be marked explicitly for export. This is done by wrapping a literal
+string in a function call.
+
+In Python, the wrapping function is :func:`openerp._`::
+
+ title = _("Bank Accounts")
+
+In JavaScript, the wrapping function is generally :js:func:`openerp.web._t`:
+
+.. code-block:: javascript
+
+ title = _t("Bank Accounts")
+
+.. warning::
+
+ Only literal strings can be marked for exports, not expressions and not
+ variables. For situations where strings are formatted, this means the
+ format string must be marked, not the formatted string::
+
+ # bad, the extract may work but it will not correctly translate the text
+ _("Scheduled meeting with %s" % invitee.name)
+
+ # good
+ _("Scheduled meeting with %s") % invitee.name
+
+.. _PO File: http://en.wikipedia.org/wiki/Gettext#Translating
+.. _msginit: http://www.gnu.org/software/gettext/manual/gettext.html#Creating
+.. _POEdit: http://poedit.net/
import tempfile
import threading
from babel.messages import extract
-from os.path import join
-
+from collections import defaultdict
from datetime import datetime
from lxml import etree
+from os.path import join
import config
import misc
raise Exception(_('Unrecognized extension: must be one of '
'.csv, .po, or .tgz (received .%s).' % format))
- trans_lang = lang
- if not trans_lang and format == 'csv':
- # CSV files are meant for translators and they need a starting point,
- # so we at least put the original term in the translation column
- trans_lang = 'en_US'
translations = trans_generate(lang, modules, cr)
- modules = set([t[0] for t in translations[1:]])
+ modules = set(t[0] for t in translations)
_process(format, modules, translations, buffer, lang)
del translations
:param callable callback: a callable in the form ``f(term, source_line)``,
that will be called for each extracted term.
"""
- if (not isinstance(element, SKIPPED_ELEMENT_TYPES)
- and element.tag.lower() not in SKIPPED_ELEMENTS
- and element.text):
- _push(callback, element.text, element.sourceline)
- if element.tail:
- _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)
+ for el in element.iter():
+ if (not isinstance(el, SKIPPED_ELEMENT_TYPES)
+ and el.tag.lower() not in SKIPPED_ELEMENTS
+ and el.text):
+ _push(callback, el.text, el.sourceline)
+ if el.tail:
+ _push(callback, el.tail, el.sourceline)
+ for attr in ('string', 'help', 'sum', 'confirm', 'placeholder'):
+ value = el.get(attr)
+ if value:
+ _push(callback, value, el.sourceline)
# tests whether an object is in a list of modules
def in_modules(object_name, modules):
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.
+ :param etree._Element 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
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")):
+ and el.get("t-translation", '').strip() != "off"):
_push(callback, el.text, el.sourceline)
for att in ('title', 'alt', 'label', 'placeholder'):
if att in el.attrib:
def babel_extract_qweb(fileobj, keywords, comment_tags, options):
"""Babel message extractor for qweb template files.
+
:param fileobj: the file-like object the messages should be extracted from
:param keywords: a list of keywords (i.e. function names) that should
be recognized as translation functions
:param options: a dictionary of additional options (optional)
:return: an iterator over ``(lineno, funcname, message, comments)``
tuples
- :rtype: ``iterator``
+ :rtype: Iterable
"""
result = []
def handle_text(text, lineno):
dbname = cr.dbname
registry = openerp.registry(dbname)
- trans_obj = registry.get('ir.translation')
- model_data_obj = registry.get('ir.model.data')
+ trans_obj = registry['ir.translation']
+ model_data_obj = registry['ir.model.data']
uid = 1
- l = registry.models.items()
- l.sort()
- query = 'SELECT name, model, res_id, module' \
+ query = 'SELECT name, model, res_id, module' \
' FROM ir_model_data'
query_models = """SELECT m.id, m.model, imd.module
cr.execute(query, query_param)
- _to_translate = []
+ _to_translate = set()
def push_translation(module, type, name, id, source, comments=None):
- tuple = (module, source, name, id, type, comments or [])
# empty and one-letter terms are ignored, they probably are not meant to be
# translated, and would be very hard to translate anyway.
if not source or len(source.strip()) <= 1:
return
- if tuple not in _to_translate:
- _to_translate.append(tuple)
+
+ tnx = (module, source, name, id, type, tuple(comments or ()))
+ _to_translate.add(tnx)
def encode(s):
if isinstance(s, unicode):
_logger.error("Unable to find object %r", model)
continue
- if not registry[model]._translate:
+ Model = registry[model]
+ if not Model._translate:
# explicitly disabled
continue
- exists = registry[model].exists(cr, uid, res_id)
- if not exists:
+ obj = Model.browse(cr, uid, res_id)
+ if not obj.exists():
_logger.warning("Unable to find object %r with id %d", model, res_id)
continue
- obj = registry[model].browse(cr, uid, res_id)
if model=='ir.ui.view':
d = etree.XML(encode(obj.arch))
if model_obj._sql_constraints:
push_local_constraints(module, model_obj, 'sql_constraints')
- modobj = registry['ir.module.module']
- installed_modids = modobj.search(cr, uid, [('state', '=', 'installed')])
- installed_modules = map(lambda m: m['name'], modobj.read(cr, uid, installed_modids, ['name']))
+ installed_modules = map(
+ lambda m: m['name'],
+ registry['ir.module.module'].search_read(cr, uid, [('state', '=', 'installed')], fields=['name']))
path_list = list(openerp.modules.module.ad_paths)
# Also scan these non-addon paths
_logger.debug("Scanning modules at paths: %s", path_list)
- mod_paths = list(path_list)
-
def get_module_from_path(path):
- for mp in mod_paths:
- if path.startswith(mp) and (os.path.dirname(path) != mp):
+ for mp in path_list:
+ if path.startswith(mp) and os.path.dirname(path) != mp:
path = path[len(mp)+1:]
return path.split(os.path.sep)[0]
- return 'base' # files that are not in a module are considered as being in 'base' module
+ return 'base' # files that are not in a module are considered as being in 'base' module
def verified_module_filepaths(fname, path, root):
fabsolutepath = join(root, fname)
extra_comments=None, extract_keywords={'_': None}):
module, fabsolutepath, _, display_path = verified_module_filepaths(fname, path, root)
extra_comments = extra_comments or []
- if module:
- src_file = open(fabsolutepath, 'r')
- try:
- for extracted in extract.extract(extract_method, src_file,
- keywords=extract_keywords):
- # Babel 0.9.6 yields lineno, message, comments
- # Babel 1.3 yields lineno, message, comments, context
- lineno, message, comments = extracted[:3]
- push_translation(module, trans_type, display_path, lineno,
- encode(message), comments + extra_comments)
- except Exception:
- _logger.exception("Failed to extract terms from %s", fabsolutepath)
- finally:
- src_file.close()
+ if not module: return
+ src_file = open(fabsolutepath, 'r')
+ try:
+ for extracted in extract.extract(extract_method, src_file,
+ keywords=extract_keywords):
+ # Babel 0.9.6 yields lineno, message, comments
+ # Babel 1.3 yields lineno, message, comments, context
+ lineno, message, comments = extracted[:3]
+ push_translation(module, trans_type, display_path, lineno,
+ encode(message), comments + extra_comments)
+ except Exception:
+ _logger.exception("Failed to extract terms from %s", fabsolutepath)
+ finally:
+ src_file.close()
for path in path_list:
_logger.debug("Scanning files of modules at %s", path)
extra_comments=[WEB_TRANSLATION_COMMENT])
out = []
- _to_translate.sort()
# translate strings marked as to be translated
- for module, source, name, id, type, comments in _to_translate:
+ for module, source, name, id, type, comments in sorted(_to_translate):
trans = '' if not lang else trans_obj._get_source(cr, uid, name, type, lang, source)
- out.append([module, type, name, id, source, encode(trans) or '', comments])
+ out.append((module, type, name, id, source, encode(trans) or '', comments))
return out
def trans_load(cr, filename, lang, verbose=True, module_name=None, context=None):
reader = csv.reader(fileobj, quotechar='"', delimiter=',')
# read the first line of the file (it contains columns titles)
for row in reader:
- f = row
+ fields = row
break
elif fileformat == 'po':
reader = TinyPoFile(fileobj)
- f = ['type', 'name', 'res_id', 'src', 'value', 'comments']
+ fields = ['type', 'name', 'res_id', 'src', 'value', 'comments']
# Make a reader for the POT file and be somewhat defensive for the
# stable branch.
# Normally the path looks like /path/to/xxx/i18n/lang.po
# and we try to find the corresponding
# /path/to/xxx/i18n/xxx.pot file.
- head, _ = os.path.split(fileobj.name)
- head2, _ = os.path.split(head)
- head3, tail3 = os.path.split(head2)
- pot_handle = misc.file_open(os.path.join(head3, tail3, 'i18n', tail3 + '.pot'))
+ addons_module_i18n, _ = os.path.split(fileobj.name)
+ addons_module, _ = os.path.split(addons_module_i18n)
+ addons, module = os.path.split(addons_module)
+ pot_handle = misc.file_open(os.path.join(addons, module, 'i18n', module + '.pot'))
pot_reader = TinyPoFile(pot_handle)
except:
pass
_logger.error('Bad file format: %s', fileformat)
raise Exception(_('Bad file format'))
- # Read the POT `reference` comments, and keep them indexed by source
- # string.
- pot_targets = {}
+ # Read the POT references, and keep them indexed by source string.
+ class Target(object):
+ def __init__(self):
+ self.value = None
+ self.targets = set() # set of (type, name, res_id)
+ self.comments = None
+
+ pot_targets = defaultdict(Target)
for type, name, res_id, src, _, comments in pot_reader:
if type is not None:
- pot_targets.setdefault(src, {'value': None, 'targets': []})
- pot_targets[src]['targets'].append((type, name, res_id))
+ target = pot_targets[src]
+ target.targets.add((type, name, res_id))
+ target.comments = comments
# read the rest of the file
irt_cursor = trans_obj._get_import_cursor(cr, SUPERUSER_ID, context=context)
def process_row(row):
"""Process a single PO (or POT) entry."""
- # skip empty rows and rows where the translation field (=last fiefd) is empty
- #if (not row) or (not row[-1]):
- # return
-
# dictionary which holds values for this line of the csv file
# {'lang': ..., 'type': ..., 'name': ..., 'res_id': ...,
# 'src': ..., 'value': ..., 'module':...}
- dic = dict.fromkeys(('name', 'res_id', 'src', 'type', 'imd_model', 'imd_name', 'module', 'value', 'comments'))
+ dic = dict.fromkeys(('type', 'name', 'res_id', 'src', 'value',
+ 'comments', 'imd_model', 'imd_name', 'module'))
dic['lang'] = lang
- for i, field in enumerate(f):
- dic[field] = row[i]
+ dic.update(zip(fields, row))
- # Get the `reference` comments from the POT.
- src = row[3]
- if pot_reader and src in pot_targets:
- pot_targets[src]['targets'] = filter(lambda x: x != row[:3], pot_targets[src]['targets'])
- pot_targets[src]['value'] = row[4]
- if not pot_targets[src]['targets']:
- del pot_targets[src]
+ # discard the target from the POT targets.
+ src = dic['src']
+ if src in pot_targets:
+ target = pot_targets[src]
+ target.value = dic['value']
+ target.targets.discard((dic['type'], dic['name'], dic['res_id']))
# This would skip terms that fail to specify a res_id
- if not dic.get('res_id'):
+ res_id = dic['res_id']
+ if not res_id:
return
- res_id = dic.pop('res_id')
- if res_id and isinstance(res_id, (int, long)) \
- or (isinstance(res_id, basestring) and res_id.isdigit()):
- dic['res_id'] = int(res_id)
- dic['module'] = module_name
+ if isinstance(res_id, (int, long)) or \
+ (isinstance(res_id, basestring) and res_id.isdigit()):
+ dic['res_id'] = int(res_id)
+ dic['module'] = module_name
else:
- tmodel = dic['name'].split(',')[0]
+ # res_id is an xml id
+ dic['res_id'] = None
+ dic['imd_model'] = dic['name'].split(',')[0]
if '.' in res_id:
- tmodule, tname = res_id.split('.', 1)
+ dic['module'], dic['imd_name'] = res_id.split('.', 1)
else:
- tmodule = False
- tname = res_id
- dic['imd_model'] = tmodel
- dic['imd_name'] = tname
- dic['module'] = tmodule
- dic['res_id'] = None
+ dic['module'], dic['imd_name'] = False, res_id
irt_cursor.push(dic)
# Then process the entries implied by the POT file (which is more
# correct w.r.t. the targets) if some of them remain.
pot_rows = []
- for src in pot_targets:
- value = pot_targets[src]['value']
- for type, name, res_id in pot_targets[src]['targets']:
- pot_rows.append((type, name, res_id, src, value, comments))
+ for src, target in pot_targets.iteritems():
+ if target.value:
+ for type, name, res_id in target.targets:
+ pot_rows.append((type, name, res_id, src, target.value, target.comments))
+ pot_targets.clear()
for row in pot_rows:
process_row(row)
trans_obj.clear_caches()
if verbose:
_logger.info("translation file loaded succesfully")
+
except IOError:
filename = '[lang: %s][format: %s]' % (iso_lang or 'new', fileformat)
_logger.exception("couldn't read translation file %s", filename)