Merge pull request #3144 from odoo-dev/8.0-translations-export-xmo
authorRaphael Collet <rco@openerp.com>
Tue, 2 Dec 2014 09:12:38 +0000 (10:12 +0100)
committerRaphael Collet <rco@openerp.com>
Tue, 2 Dec 2014 09:12:38 +0000 (10:12 +0100)
Translation docs + code improvements and fixes

addons/web_diagram/static/src/js/diagram.js
doc/reference.rst
doc/reference/translations.rst [new file with mode: 0644]
doc/reference/translations/po-export.png [new file with mode: 0644]
openerp/addons/base/module/wizard/base_export_language.py
openerp/addons/base/res/res_config.py
openerp/tools/translate.py

index 77876c9..567cc1a 100644 (file)
@@ -216,7 +216,7 @@ instance.web.DiagramView = instance.web.View.extend({
         };
 
         CuteEdge.creation_callback = function(node_start, node_end){
-            return {label:_t("")};
+            return {label: ''};
         };
         CuteEdge.new_edge_callback = function(cuteedge){
             self.add_connector(cuteedge.get_start().id,
index 3e1597d..9c995ef 100644 (file)
@@ -18,5 +18,6 @@ Reference
     reference/qweb
     reference/javascript
 
+    reference/translations
     reference/reports
     reference/workflows
diff --git a/doc/reference/translations.rst b/doc/reference/translations.rst
new file mode 100644 (file)
index 0000000..4561618
--- /dev/null
@@ -0,0 +1,96 @@
+.. _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/
diff --git a/doc/reference/translations/po-export.png b/doc/reference/translations/po-export.png
new file mode 100644 (file)
index 0000000..82f8d5a
Binary files /dev/null and b/doc/reference/translations/po-export.png differ
index eb9004b..e6df053 100644 (file)
@@ -20,6 +20,7 @@
 ##############################################################################
 
 import base64
+import contextlib
 import cStringIO
 
 from openerp import tools
@@ -51,29 +52,29 @@ class base_language_export(osv.osv_memory):
     }
     _defaults = { 
         'state': 'choose',
-        'name': 'lang.tar.gz',
         'lang': NEW_LANG_KEY,
         'format': 'csv',
     }
 
     def act_getfile(self, cr, uid, ids, context=None):
-        this = self.browse(cr, uid, ids)[0]
+        this = self.browse(cr, uid, ids, context=context)[0]
         lang = this.lang if this.lang != NEW_LANG_KEY else False
-        mods = map(lambda m: m.name, this.modules) or ['all']
-        mods.sort()
-        buf = cStringIO.StringIO()
-        tools.trans_export(lang, mods, buf, this.format, cr)
+        mods = sorted(map(lambda m: m.name, this.modules)) or ['all']
+
+        with contextlib.closing(cStringIO.StringIO()) as buf:
+            tools.trans_export(lang, mods, buf, this.format, cr)
+            out = base64.encodestring(buf.getvalue())
+
         filename = 'new'
         if lang:
             filename = get_iso_codes(lang)
         elif len(mods) == 1:
             filename = mods[0]
-        this.name = "%s.%s" % (filename, this.format)
-        out = base64.encodestring(buf.getvalue())
-        buf.close()
-        self.write(cr, uid, ids, {'state': 'get',
-                                  'data': out,
-                                  'name':this.name}, context=context)
+        extension = this.format
+        if not lang and extension == 'po':
+            extension = 'pot'
+        name = "%s.%s" % (filename, extension)
+        this.write({ 'state': 'get', 'data': out, 'name': name })
         return {
             'type': 'ir.actions.act_window',
             'res_model': 'base.language.export',
index b5438e7..1b6b17b 100644 (file)
@@ -466,10 +466,12 @@ class res_config_settings(osv.osv_memory, res_config_module_installation_mixin):
             dep_name = [x.shortdesc for x  in module_pool.browse(
                 cr, uid, dep_ids + module_ids, context=context)]
             message = '\n'.join(dep_name)
-            return {'warning': {'title': _('Warning!'),
-                    'message':
-                    _('Disabling this option will also uninstall the following modules \n%s' % message)
-                   }}
+            return {
+                'warning': {
+                    'title': _('Warning!'),
+                    'message': _('Disabling this option will also uninstall the following modules \n%s') % message,
+                }
+            }
         return {}
 
     def _get_classified_fields(self, cr, uid, context=None):
index 7bf1470..32fd5bc 100644 (file)
@@ -32,10 +32,10 @@ import tarfile
 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
@@ -505,13 +505,8 @@ def trans_export(lang, modules, buffer, format, cr):
             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
 
@@ -566,18 +561,17 @@ def trans_parse_view(element, callback):
         :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):
@@ -598,9 +592,9 @@ def _extract_translatable_qweb_terms(element, callback):
         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
@@ -609,8 +603,7 @@ def _extract_translatable_qweb_terms(element, callback):
         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:
@@ -620,6 +613,7 @@ def _extract_translatable_qweb_terms(element, callback):
 
 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
@@ -628,7 +622,7 @@ def babel_extract_qweb(fileobj, keywords, comment_tags, options):
     :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):
@@ -641,13 +635,11 @@ def trans_generate(lang, modules, cr):
     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
@@ -667,15 +659,15 @@ def trans_generate(lang, modules, cr):
 
     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):
@@ -704,15 +696,15 @@ def trans_generate(lang, modules, cr):
             _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))
@@ -830,9 +822,9 @@ def trans_generate(lang, modules, cr):
         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
@@ -841,14 +833,12 @@ def trans_generate(lang, modules, cr):
 
     _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)
@@ -863,20 +853,20 @@ def trans_generate(lang, modules, cr):
                                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)
@@ -899,11 +889,10 @@ def trans_generate(lang, modules, cr):
                                         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):
@@ -948,11 +937,11 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True,
             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.
@@ -961,10 +950,10 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True,
                     # 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
@@ -973,59 +962,57 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True,
             _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)
 
@@ -1037,10 +1024,11 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True,
         # 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)
 
@@ -1048,6 +1036,7 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True,
         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)