diff --git a/addons/web_diagram/static/src/js/diagram.js b/addons/web_diagram/static/src/js/diagram.js index 77876c9dc52..567cc1ae794 100644 --- a/addons/web_diagram/static/src/js/diagram.js +++ b/addons/web_diagram/static/src/js/diagram.js @@ -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, diff --git a/doc/reference.rst b/doc/reference.rst index 3e1597db1ef..9c995ef9638 100644 --- a/doc/reference.rst +++ b/doc/reference.rst @@ -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 index 00000000000..45616189c0d --- /dev/null +++ b/doc/reference/translations.rst @@ -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 index 00000000000..82f8d5ad011 Binary files /dev/null and b/doc/reference/translations/po-export.png differ diff --git a/openerp/addons/base/module/wizard/base_export_language.py b/openerp/addons/base/module/wizard/base_export_language.py index eb9004b9abe..e6df0536cb2 100644 --- a/openerp/addons/base/module/wizard/base_export_language.py +++ b/openerp/addons/base/module/wizard/base_export_language.py @@ -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', diff --git a/openerp/addons/base/res/res_config.py b/openerp/addons/base/res/res_config.py index b5438e7c400..1b6b17bb9e1 100644 --- a/openerp/addons/base/res/res_config.py +++ b/openerp/addons/base/res/res_config.py @@ -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): diff --git a/openerp/tools/translate.py b/openerp/tools/translate.py index 7bf147027ad..32fd5bcf6fe 100644 --- a/openerp/tools/translate.py +++ b/openerp/tools/translate.py @@ -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] - if '.' in res_id: - tmodule, tname = res_id.split('.', 1) - else: - tmodule = False - tname = res_id - dic['imd_model'] = tmodel - dic['imd_name'] = tname - dic['module'] = tmodule + # res_id is an xml id dic['res_id'] = None + dic['imd_model'] = dic['name'].split(',')[0] + if '.' in res_id: + dic['module'], dic['imd_name'] = res_id.split('.', 1) + else: + 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)