1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>).
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as
9 # published by the Free Software Foundation, either version 3 of the
10 # License, or (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 ##############################################################################
28 import openerp.sql_db as sql_db
34 from babel.messages import extract
35 from os.path import join
37 from datetime import datetime
38 from lxml import etree
42 from misc import SKIPPED_ELEMENT_TYPES
45 from openerp import SUPERUSER_ID
47 _logger = logging.getLogger(__name__)
49 # used to notify web client that these translations should be loaded in the UI
50 WEB_TRANSLATION_COMMENT = "openerp-web"
52 SKIPPED_ELEMENTS = ('script', 'style')
55 'af_ZA': 'Afrikaans_South Africa',
56 'sq_AL': 'Albanian_Albania',
57 'ar_SA': 'Arabic_Saudi Arabia',
58 'eu_ES': 'Basque_Spain',
59 'be_BY': 'Belarusian_Belarus',
60 'bs_BA': 'Serbian (Latin)',
61 'bg_BG': 'Bulgarian_Bulgaria',
62 'ca_ES': 'Catalan_Spain',
63 'hr_HR': 'Croatian_Croatia',
64 'zh_CN': 'Chinese_China',
65 'zh_TW': 'Chinese_Taiwan',
66 'cs_CZ': 'Czech_Czech Republic',
67 'da_DK': 'Danish_Denmark',
68 'nl_NL': 'Dutch_Netherlands',
69 'et_EE': 'Estonian_Estonia',
70 'fa_IR': 'Farsi_Iran',
71 'ph_PH': 'Filipino_Philippines',
72 'fi_FI': 'Finnish_Finland',
73 'fr_FR': 'French_France',
74 'fr_BE': 'French_France',
75 'fr_CH': 'French_France',
76 'fr_CA': 'French_France',
77 'ga': 'Scottish Gaelic',
78 'gl_ES': 'Galician_Spain',
79 'ka_GE': 'Georgian_Georgia',
80 'de_DE': 'German_Germany',
81 'el_GR': 'Greek_Greece',
82 'gu': 'Gujarati_India',
83 'he_IL': 'Hebrew_Israel',
85 'hu': 'Hungarian_Hungary',
86 'is_IS': 'Icelandic_Iceland',
87 'id_ID': 'Indonesian_indonesia',
88 'it_IT': 'Italian_Italy',
89 'ja_JP': 'Japanese_Japan',
92 'ko_KR': 'Korean_Korea',
94 'lt_LT': 'Lithuanian_Lithuania',
95 'lat': 'Latvian_Latvia',
96 'ml_IN': 'Malayalam_India',
98 'mn': 'Cyrillic_Mongolian',
99 'no_NO': 'Norwegian_Norway',
100 'nn_NO': 'Norwegian-Nynorsk_Norway',
101 'pl': 'Polish_Poland',
102 'pt_PT': 'Portuguese_Portugal',
103 'pt_BR': 'Portuguese_Brazil',
104 'ro_RO': 'Romanian_Romania',
105 'ru_RU': 'Russian_Russia',
106 'sr_CS': 'Serbian (Cyrillic)_Serbia and Montenegro',
107 'sk_SK': 'Slovak_Slovakia',
108 'sl_SI': 'Slovenian_Slovenia',
109 #should find more specific locales for spanish countries,
110 #but better than nothing
111 'es_AR': 'Spanish_Spain',
112 'es_BO': 'Spanish_Spain',
113 'es_CL': 'Spanish_Spain',
114 'es_CO': 'Spanish_Spain',
115 'es_CR': 'Spanish_Spain',
116 'es_DO': 'Spanish_Spain',
117 'es_EC': 'Spanish_Spain',
118 'es_ES': 'Spanish_Spain',
119 'es_GT': 'Spanish_Spain',
120 'es_HN': 'Spanish_Spain',
121 'es_MX': 'Spanish_Spain',
122 'es_NI': 'Spanish_Spain',
123 'es_PA': 'Spanish_Spain',
124 'es_PE': 'Spanish_Spain',
125 'es_PR': 'Spanish_Spain',
126 'es_PY': 'Spanish_Spain',
127 'es_SV': 'Spanish_Spain',
128 'es_UY': 'Spanish_Spain',
129 'es_VE': 'Spanish_Spain',
130 'sv_SE': 'Swedish_Sweden',
131 'ta_IN': 'English_Australia',
132 'th_TH': 'Thai_Thailand',
133 'tr_TR': 'Turkish_Turkey',
134 'uk_UA': 'Ukrainian_Ukraine',
135 'vi_VN': 'Vietnamese_Viet Nam',
136 'tlh_TLH': 'Klingon',
141 class UNIX_LINE_TERMINATOR(csv.excel):
142 lineterminator = '\n'
144 csv.register_dialect("UNIX", UNIX_LINE_TERMINATOR)
147 # Warning: better use self.pool.get('ir.translation')._get_source if you can
149 def translate(cr, name, source_type, lang, source=None):
151 cr.execute('select value from ir_translation where lang=%s and type=%s and name=%s and src=%s', (lang, source_type, str(name), source))
153 cr.execute('select value from ir_translation where lang=%s and type=%s and name=%s', (lang, source_type, str(name)))
155 cr.execute('select value from ir_translation where lang=%s and type=%s and src=%s', (lang, source_type, source))
156 res_trans = cr.fetchone()
157 res = res_trans and res_trans[0] or False
160 class GettextAlias(object):
163 # find current DB based on thread/worker db name (see netsvc)
164 db_name = getattr(threading.currentThread(), 'dbname', None)
166 return sql_db.db_connect(db_name)
168 def _get_cr(self, frame, allow_create=True):
169 # try, in order: cr, cursor, self.env.cr, self.cr
170 if 'cr' in frame.f_locals:
171 return frame.f_locals['cr'], False
172 if 'cursor' in frame.f_locals:
173 return frame.f_locals['cursor'], False
174 s = frame.f_locals.get('self')
175 if hasattr(s, 'env'):
176 return s.env.cr, False
180 # create a new cursor
183 return db.cursor(), True
186 def _get_uid(self, frame):
187 # try, in order: uid, user, self.env.uid
188 if 'uid' in frame.f_locals:
189 return frame.f_locals['uid']
190 if 'user' in frame.f_locals:
191 return int(frame.f_locals['user']) # user may be a record
192 s = frame.f_locals.get('self')
195 def _get_lang(self, frame):
196 # try, in order: context.get('lang'), kwargs['context'].get('lang'),
197 # self.env.lang, self.localcontext.get('lang')
198 if 'context' in frame.f_locals:
199 return frame.f_locals['context'].get('lang')
200 kwargs = frame.f_locals.get('kwargs', {})
201 if 'context' in kwargs:
202 return kwargs['context'].get('lang')
203 s = frame.f_locals.get('self')
204 if hasattr(s, 'env'):
206 if hasattr(s, 'localcontext'):
207 return s.localcontext.get('lang')
208 # Last resort: attempt to guess the language of the user
209 # Pitfall: some operations are performed in sudo mode, and we
210 # don't know the originial uid, so the language may
211 # be wrong when the admin language differs.
212 pool = getattr(s, 'pool', None)
213 (cr, dummy) = self._get_cr(frame, allow_create=False)
214 uid = self._get_uid(frame)
215 if pool and cr and uid:
216 return pool['res.users'].context_get(cr, uid)['lang']
219 def __call__(self, source):
224 frame = inspect.currentframe()
230 lang = self._get_lang(frame)
232 cr, is_new_cr = self._get_cr(frame)
234 # Try to use ir.translation to benefit from global cache if possible
235 registry = openerp.registry(cr.dbname)
236 res = registry['ir.translation']._get_source(cr, SUPERUSER_ID, None, ('code','sql_constraint'), lang, source)
238 _logger.debug('no context cursor detected, skipping translation for "%r"', source)
240 _logger.debug('no translation language detected, skipping translation for "%r" ', source)
242 _logger.debug('translation went wrong for "%r", skipped', source)
243 # if so, double-check the root/base translations filenames
253 """Returns quoted PO term string, with special PO characters escaped"""
254 assert r"\n" not in s, "Translation terms may not include escaped newlines ('\\n'), please use only literal newlines! (in '%s')" % s
255 return '"%s"' % s.replace('\\','\\\\') \
256 .replace('"','\\"') \
257 .replace('\n', '\\n"\n"')
259 re_escaped_char = re.compile(r"(\\.)")
260 re_escaped_replacements = {'n': '\n', }
262 def _sub_replacement(match_obj):
263 return re_escaped_replacements.get(match_obj.group(1)[1], match_obj.group(1)[1])
266 """Returns unquoted PO term string, with special PO characters unescaped"""
267 return re_escaped_char.sub(_sub_replacement, str[1:-1])
269 # class to handle po files
270 class TinyPoFile(object):
271 def __init__(self, buffer):
274 def warn(self, msg, *args):
275 _logger.warning(msg, *args)
279 self.lines = self._get_lines()
280 self.lines_count = len(self.lines)
286 def _get_lines(self):
287 lines = self.buffer.readlines()
288 # remove the BOM (Byte Order Mark):
290 lines[0] = unicode(lines[0], 'utf8').lstrip(unicode( codecs.BOM_UTF8, "utf8"))
292 lines.append('') # ensure that the file ends with at least an empty line
296 return self.lines_count - len(self.lines)
299 trans_type = name = res_id = source = trad = None
301 trans_type, name, res_id, source, trad, comments = self.extra_lines.pop(0)
310 if 0 == len(self.lines):
311 raise StopIteration()
312 line = self.lines.pop(0).strip()
313 while line.startswith('#'):
314 if line.startswith('#~ '):
316 if line.startswith('#.'):
317 line = line[2:].strip()
318 if not line.startswith('module:'):
319 comments.append(line)
320 elif line.startswith('#:'):
321 # Process the `reference` comments. Each line can specify
322 # multiple targets (e.g. model, view, code, selection,
323 # ...). For each target, we will return an additional
325 for lpart in line[2:].strip().split(' '):
326 trans_info = lpart.strip().split(':',2)
327 if trans_info and len(trans_info) == 2:
328 # looks like the translation trans_type is missing, which is not
329 # unexpected because it is not a GetText standard. Default: 'code'
330 trans_info[:0] = ['code']
331 if trans_info and len(trans_info) == 3:
332 # this is a ref line holding the destination info (model, field, record)
333 targets.append(trans_info)
334 elif line.startswith('#,') and (line[2:].strip() == 'fuzzy'):
336 line = self.lines.pop(0).strip()
338 # allow empty lines between comments and msgid
339 line = self.lines.pop(0).strip()
340 if line.startswith('#~ '):
341 while line.startswith('#~ ') or not line.strip():
342 if 0 == len(self.lines):
343 raise StopIteration()
344 line = self.lines.pop(0)
345 # This has been a deprecated entry, don't return anything
348 if not line.startswith('msgid'):
349 raise Exception("malformed file: bad line: %s" % line)
350 source = unquote(line[6:])
351 line = self.lines.pop(0).strip()
352 if not source and self.first:
353 # if the source is "" and it's the first msgid, it's the special
354 # msgstr with the informations about the traduction and the
355 # traductor; we skip it
356 self.extra_lines = []
358 line = self.lines.pop(0).strip()
361 while not line.startswith('msgstr'):
363 raise Exception('malformed file at %d'% self.cur_line())
364 source += unquote(line)
365 line = self.lines.pop(0).strip()
367 trad = unquote(line[7:])
368 line = self.lines.pop(0).strip()
370 trad += unquote(line)
371 line = self.lines.pop(0).strip()
373 if targets and not fuzzy:
374 # Use the first target for the current entry (returned at the
375 # end of this next() call), and keep the others to generate
376 # additional entries (returned the next next() calls).
377 trans_type, name, res_id = targets.pop(0)
378 for t, n, r in targets:
379 if t == trans_type == 'code': continue
380 self.extra_lines.append((t, n, r, source, trad, comments))
386 self.warn('Missing "#:" formated comment at line %d for the following source:\n\t%s',
387 self.cur_line(), source[:30])
389 return trans_type, name, res_id, source, trad, '\n'.join(comments)
391 def write_infos(self, modules):
392 import openerp.release as release
393 self.buffer.write("# Translation of %(project)s.\n" \
394 "# This file contains the translation of the following modules:\n" \
399 '''"Project-Id-Version: %(project)s %(version)s\\n"\n''' \
400 '''"Report-Msgid-Bugs-To: \\n"\n''' \
401 '''"POT-Creation-Date: %(now)s\\n"\n''' \
402 '''"PO-Revision-Date: %(now)s\\n"\n''' \
403 '''"Last-Translator: <>\\n"\n''' \
404 '''"Language-Team: \\n"\n''' \
405 '''"MIME-Version: 1.0\\n"\n''' \
406 '''"Content-Type: text/plain; charset=UTF-8\\n"\n''' \
407 '''"Content-Transfer-Encoding: \\n"\n''' \
408 '''"Plural-Forms: \\n"\n''' \
411 % { 'project': release.description,
412 'version': release.version,
413 'modules': reduce(lambda s, m: s + "#\t* %s\n" % m, modules, ""),
414 'now': datetime.utcnow().strftime('%Y-%m-%d %H:%M')+"+0000",
418 def write(self, modules, tnrs, source, trad, comments=None):
420 plurial = len(modules) > 1 and 's' or ''
421 self.buffer.write("#. module%s: %s\n" % (plurial, ', '.join(modules)))
424 self.buffer.write(''.join(('#. %s\n' % c for c in comments)))
427 for typy, name, res_id in tnrs:
428 self.buffer.write("#: %s:%s:%s\n" % (typy, name, res_id))
433 # only strings in python code are python formated
434 self.buffer.write("#, python-format\n")
436 if not isinstance(trad, unicode):
437 trad = unicode(trad, 'utf8')
438 if not isinstance(source, unicode):
439 source = unicode(source, 'utf8')
443 % (quote(source), quote(trad))
444 self.buffer.write(msg.encode('utf8'))
447 # Methods to export the translation file
449 def trans_export(lang, modules, buffer, format, cr):
451 def _process(format, modules, rows, buffer, lang):
453 writer = csv.writer(buffer, 'UNIX')
455 writer.writerow(("module","type","name","res_id","src","value"))
456 for module, type, name, res_id, src, trad, comments in rows:
457 # Comments are ignored by the CSV writer
458 writer.writerow((module, type, name, res_id, src, trad))
460 writer = TinyPoFile(buffer)
461 writer.write_infos(modules)
463 # we now group the translations by source. That means one translation per source.
465 for module, type, name, res_id, src, trad, comments in rows:
466 row = grouped_rows.setdefault(src, {})
467 row.setdefault('modules', set()).add(module)
468 if not row.get('translation') and trad != src:
469 row['translation'] = trad
470 row.setdefault('tnrs', []).append((type, name, res_id))
471 row.setdefault('comments', set()).update(comments)
473 for src, row in sorted(grouped_rows.items()):
475 # translation template, so no translation value
476 row['translation'] = ''
477 elif not row.get('translation'):
478 row['translation'] = src
479 writer.write(row['modules'], row['tnrs'], src, row['translation'], row['comments'])
481 elif format == 'tgz':
485 rows_by_module.setdefault(module, []).append(row)
486 tmpdir = tempfile.mkdtemp()
487 for mod, modrows in rows_by_module.items():
488 tmpmoddir = join(tmpdir, mod, 'i18n')
489 os.makedirs(tmpmoddir)
490 pofilename = (lang if lang else mod) + ".po" + ('t' if not lang else '')
491 buf = file(join(tmpmoddir, pofilename), 'w')
492 _process('po', [mod], modrows, buf, lang)
495 tar = tarfile.open(fileobj=buffer, mode='w|gz')
500 raise Exception(_('Unrecognized extension: must be one of '
501 '.csv, .po, or .tgz (received .%s).' % format))
504 if not trans_lang and format == 'csv':
505 # CSV files are meant for translators and they need a starting point,
506 # so we at least put the original term in the translation column
508 translations = trans_generate(lang, modules, cr)
509 modules = set([t[0] for t in translations[1:]])
510 _process(format, modules, translations, buffer, lang)
513 def trans_parse_xsl(de):
514 return list(set(trans_parse_xsl_aux(de, False)))
516 def trans_parse_xsl_aux(de, t):
522 if isinstance(n, SKIPPED_ELEMENT_TYPES) or n.tag.startswith('{http://www.w3.org/1999/XSL/Transform}'):
525 l = n.text.strip().replace('\n',' ')
527 res.append(l.encode("utf8"))
529 l = n.tail.strip().replace('\n',' ')
531 res.append(l.encode("utf8"))
532 res.extend(trans_parse_xsl_aux(n, t))
535 def trans_parse_rml(de):
539 if isinstance(m, SKIPPED_ELEMENT_TYPES) or not m.text:
541 string_list = [s.replace('\n', ' ').strip() for s in re.split('\[\[.+?\]\]', m.text)]
542 for s in string_list:
544 res.append(s.encode("utf8"))
545 res.extend(trans_parse_rml(n))
548 def _push(callback, term, source_line):
549 """ Sanity check before pushing translation terms """
550 term = (term or "").strip().encode('utf8')
551 # Avoid non-char tokens like ':' '...' '.00' etc.
552 if len(term) > 8 or any(x.isalpha() for x in term):
553 callback(term, source_line)
555 def trans_parse_view(element, callback):
556 """ Helper method to recursively walk an etree document representing a
557 regular view and call ``callback(term)`` for each translatable term
558 that is found in the document.
560 :param ElementTree element: root of etree document to extract terms from
561 :param callable callback: a callable in the form ``f(term, source_line)``,
562 that will be called for each extracted term.
564 if (not isinstance(element, SKIPPED_ELEMENT_TYPES)
565 and element.tag.lower() not in SKIPPED_ELEMENTS
567 _push(callback, element.text, element.sourceline)
569 _push(callback, element.tail, element.sourceline)
570 for attr in ('string', 'help', 'sum', 'confirm', 'placeholder'):
571 value = element.get(attr)
573 _push(callback, value, element.sourceline)
575 trans_parse_view(n, callback)
577 # tests whether an object is in a list of modules
578 def in_modules(object_name, modules):
587 module = object_name.split('.')[0]
588 module = module_dict.get(module, module)
589 return module in modules
591 def _extract_translatable_qweb_terms(element, callback):
592 """ Helper method to walk an etree document representing
593 a QWeb template, and call ``callback(term)`` for each
594 translatable term that is found in the document.
596 :param ElementTree element: root of etree document to extract terms from
597 :param callable callback: a callable in the form ``f(term, source_line)``,
598 that will be called for each extracted term.
600 # not using elementTree.iterparse because we need to skip sub-trees in case
601 # the ancestor element had a reason to be skipped
603 if isinstance(el, SKIPPED_ELEMENT_TYPES): continue
604 if (el.tag.lower() not in SKIPPED_ELEMENTS
605 and "t-js" not in el.attrib
606 and not ("t-jquery" in el.attrib and "t-operation" not in el.attrib)
607 and not ("t-translation" in el.attrib and
608 el.attrib["t-translation"].strip() == "off")):
609 _push(callback, el.text, el.sourceline)
610 for att in ('title', 'alt', 'label', 'placeholder'):
612 _push(callback, el.attrib[att], el.sourceline)
613 _extract_translatable_qweb_terms(el, callback)
614 _push(callback, el.tail, el.sourceline)
616 def babel_extract_qweb(fileobj, keywords, comment_tags, options):
617 """Babel message extractor for qweb template files.
618 :param fileobj: the file-like object the messages should be extracted from
619 :param keywords: a list of keywords (i.e. function names) that should
620 be recognized as translation functions
621 :param comment_tags: a list of translator tags to search for and
622 include in the results
623 :param options: a dictionary of additional options (optional)
624 :return: an iterator over ``(lineno, funcname, message, comments)``
629 def handle_text(text, lineno):
630 result.append((lineno, None, text, []))
631 tree = etree.parse(fileobj)
632 _extract_translatable_qweb_terms(tree.getroot(), handle_text)
635 def trans_generate(lang, modules, cr):
638 registry = openerp.registry(dbname)
639 trans_obj = registry.get('ir.translation')
640 model_data_obj = registry.get('ir.model.data')
642 l = registry.models.items()
645 query = 'SELECT name, model, res_id, module' \
646 ' FROM ir_model_data'
648 query_models = """SELECT m.id, m.model, imd.module
649 FROM ir_model AS m, ir_model_data AS imd
650 WHERE m.id = imd.res_id AND imd.model = 'ir.model' """
652 if 'all_installed' in modules:
653 query += ' WHERE module IN ( SELECT name FROM ir_module_module WHERE state = \'installed\') '
654 query_models += " AND imd.module in ( SELECT name FROM ir_module_module WHERE state = 'installed') "
656 if 'all' not in modules:
657 query += ' WHERE module IN %s'
658 query_models += ' AND imd.module in %s'
659 query_param = (tuple(modules),)
660 query += ' ORDER BY module, model, name'
661 query_models += ' ORDER BY module, model'
663 cr.execute(query, query_param)
666 def push_translation(module, type, name, id, source, comments=None):
667 tuple = (module, source, name, id, type, comments or [])
668 # empty and one-letter terms are ignored, they probably are not meant to be
669 # translated, and would be very hard to translate anyway.
670 if not source or len(source.strip()) <= 1:
672 if tuple not in _to_translate:
673 _to_translate.append(tuple)
676 if isinstance(s, unicode):
677 return s.encode('utf8')
680 def push(mod, type, name, res_id, term):
681 term = (term or '').strip()
683 push_translation(mod, type, name, res_id, term)
685 def get_root_view(xml_id):
686 view = model_data_obj.xmlid_to_object(cr, uid, xml_id)
688 while view.mode != 'primary':
689 view = view.inherit_id
690 xml_id = view.get_external_id(cr, uid).get(view.id, xml_id)
693 for (xml_name,model,res_id,module) in cr.fetchall():
694 module = encode(module)
695 model = encode(model)
696 xml_name = "%s.%s" % (module, encode(xml_name))
698 if model not in registry:
699 _logger.error("Unable to find object %r", model)
702 if not registry[model]._translate:
703 # explicitly disabled
706 exists = registry[model].exists(cr, uid, res_id)
708 _logger.warning("Unable to find object %r with id %d", model, res_id)
710 obj = registry[model].browse(cr, uid, res_id)
712 if model=='ir.ui.view':
713 d = etree.XML(encode(obj.arch))
714 if obj.type == 'qweb':
715 view_id = get_root_view(xml_name)
716 push_qweb = lambda t,l: push(module, 'view', 'website', view_id, t)
717 _extract_translatable_qweb_terms(d, push_qweb)
719 push_view = lambda t,l: push(module, 'view', obj.model, xml_name, t)
720 trans_parse_view(d, push_view)
721 elif model=='ir.actions.wizard':
722 pass # TODO Can model really be 'ir.actions.wizard' ?
724 elif model=='ir.model.fields':
726 field_name = encode(obj.name)
727 except AttributeError, exc:
728 _logger.error("name error in %s: %s", xml_name, str(exc))
730 objmodel = registry.get(obj.model)
731 if (objmodel is None or field_name not in objmodel._columns
732 or not objmodel._translate):
734 field_def = objmodel._columns[field_name]
736 name = "%s,%s" % (encode(obj.model), field_name)
737 push_translation(module, 'field', name, 0, encode(field_def.string))
740 push_translation(module, 'help', name, 0, encode(field_def.help))
742 if field_def.translate:
743 ids = objmodel.search(cr, uid, [])
744 obj_values = objmodel.read(cr, uid, ids, [field_name])
745 for obj_value in obj_values:
746 res_id = obj_value['id']
747 if obj.name in ('ir.model', 'ir.ui.menu'):
749 model_data_ids = model_data_obj.search(cr, uid, [
750 ('model', '=', model),
751 ('res_id', '=', res_id),
753 if not model_data_ids:
754 push_translation(module, 'model', name, 0, encode(obj_value[field_name]))
756 if hasattr(field_def, 'selection') and isinstance(field_def.selection, (list, tuple)):
757 for dummy, val in field_def.selection:
758 push_translation(module, 'selection', name, 0, encode(val))
760 elif model=='ir.actions.report.xml':
761 name = encode(obj.report_name)
764 fname = obj.report_rml
765 parse_func = trans_parse_rml
766 report_type = "report"
768 fname = obj.report_xsl
769 parse_func = trans_parse_xsl
771 if fname and obj.report_type in ('pdf', 'xsl'):
773 report_file = misc.file_open(fname)
775 d = etree.parse(report_file)
776 for t in parse_func(d.iter()):
777 push_translation(module, report_type, name, 0, t)
780 except (IOError, etree.XMLSyntaxError):
781 _logger.exception("couldn't export translation for report %s %s %s", name, report_type, fname)
783 for field_name, field_def in obj._columns.items():
784 if model == 'ir.model' and field_name == 'name' and obj.name == obj.model:
785 # ignore model name if it is the technical one, nothing to translate
787 if field_def.translate:
788 name = model + "," + field_name
790 term = obj[field_name] or ''
793 push_translation(module, 'model', name, xml_name, encode(term))
795 # End of data for ir.model.data query results
797 cr.execute(query_models, query_param)
799 def push_constraint_msg(module, term_type, model, msg):
800 if not hasattr(msg, '__call__'):
801 push_translation(encode(module), term_type, encode(model), 0, encode(msg))
803 def push_local_constraints(module, model, cons_type='sql_constraints'):
804 """Climb up the class hierarchy and ignore inherited constraints
805 from other modules"""
806 term_type = 'sql_constraint' if cons_type == 'sql_constraints' else 'constraint'
807 msg_pos = 2 if cons_type == 'sql_constraints' else 1
808 for cls in model.__class__.__mro__:
809 if getattr(cls, '_module', None) != module:
811 constraints = getattr(cls, '_local_' + cons_type, [])
812 for constraint in constraints:
813 push_constraint_msg(module, term_type, model._name, constraint[msg_pos])
815 for (_, model, module) in cr.fetchall():
816 if model not in registry:
817 _logger.error("Unable to find object %r", model)
820 model_obj = registry[model]
822 if model_obj._constraints:
823 push_local_constraints(module, model_obj, 'constraints')
825 if model_obj._sql_constraints:
826 push_local_constraints(module, model_obj, 'sql_constraints')
828 modobj = registry['ir.module.module']
829 installed_modids = modobj.search(cr, uid, [('state', '=', 'installed')])
830 installed_modules = map(lambda m: m['name'], modobj.read(cr, uid, installed_modids, ['name']))
832 path_list = list(openerp.modules.module.ad_paths)
833 # Also scan these non-addon paths
834 for bin_path in ['osv', 'report' ]:
835 path_list.append(os.path.join(config.config['root_path'], bin_path))
837 _logger.debug("Scanning modules at paths: %s", path_list)
839 mod_paths = list(path_list)
841 def get_module_from_path(path):
843 if path.startswith(mp) and (os.path.dirname(path) != mp):
844 path = path[len(mp)+1:]
845 return path.split(os.path.sep)[0]
846 return 'base' # files that are not in a module are considered as being in 'base' module
848 def verified_module_filepaths(fname, path, root):
849 fabsolutepath = join(root, fname)
850 frelativepath = fabsolutepath[len(path):]
851 display_path = "addons%s" % frelativepath
852 module = get_module_from_path(fabsolutepath)
853 if ('all' in modules or module in modules) and module in installed_modules:
854 return module, fabsolutepath, frelativepath, display_path
855 return None, None, None, None
857 def babel_extract_terms(fname, path, root, extract_method="python", trans_type='code',
858 extra_comments=None, extract_keywords={'_': None}):
859 module, fabsolutepath, _, display_path = verified_module_filepaths(fname, path, root)
860 extra_comments = extra_comments or []
862 src_file = open(fabsolutepath, 'r')
864 for extracted in extract.extract(extract_method, src_file,
865 keywords=extract_keywords):
866 # Babel 0.9.6 yields lineno, message, comments
867 # Babel 1.3 yields lineno, message, comments, context
868 lineno, message, comments = extracted[:3]
869 push_translation(module, trans_type, display_path, lineno,
870 encode(message), comments + extra_comments)
872 _logger.exception("Failed to extract terms from %s", fabsolutepath)
876 for path in path_list:
877 _logger.debug("Scanning files of modules at %s", path)
878 for root, dummy, files in osutil.walksymlinks(path):
879 for fname in fnmatch.filter(files, '*.py'):
880 babel_extract_terms(fname, path, root)
881 # mako provides a babel extractor: http://docs.makotemplates.org/en/latest/usage.html#babel
882 for fname in fnmatch.filter(files, '*.mako'):
883 babel_extract_terms(fname, path, root, 'mako', trans_type='report')
884 # Javascript source files in the static/src/js directory, rest is ignored (libs)
885 if fnmatch.fnmatch(root, '*/static/src/js*'):
886 for fname in fnmatch.filter(files, '*.js'):
887 babel_extract_terms(fname, path, root, 'javascript',
888 extra_comments=[WEB_TRANSLATION_COMMENT],
889 extract_keywords={'_t': None, '_lt': None})
890 # QWeb template files
891 if fnmatch.fnmatch(root, '*/static/src/xml*'):
892 for fname in fnmatch.filter(files, '*.xml'):
893 babel_extract_terms(fname, path, root, 'openerp.tools.translate:babel_extract_qweb',
894 extra_comments=[WEB_TRANSLATION_COMMENT])
898 # translate strings marked as to be translated
899 for module, source, name, id, type, comments in _to_translate:
900 trans = '' if not lang else trans_obj._get_source(cr, uid, name, type, lang, source)
901 out.append([module, type, name, id, source, encode(trans) or '', comments])
904 def trans_load(cr, filename, lang, verbose=True, module_name=None, context=None):
906 fileobj = misc.file_open(filename)
907 _logger.info("loading %s", filename)
908 fileformat = os.path.splitext(filename)[-1][1:].lower()
909 result = trans_load_data(cr, fileobj, fileformat, lang, verbose=verbose, module_name=module_name, context=context)
914 _logger.error("couldn't read translation file %s", filename)
917 def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, module_name=None, context=None):
918 """Populates the ir_translation table."""
920 _logger.info('loading translation file for language %s', lang)
924 registry = openerp.registry(db_name)
925 lang_obj = registry.get('res.lang')
926 trans_obj = registry.get('ir.translation')
927 iso_lang = misc.get_iso_codes(lang)
929 ids = lang_obj.search(cr, SUPERUSER_ID, [('code','=', lang)])
932 # lets create the language with locale information
933 lang_obj.load_lang(cr, SUPERUSER_ID, lang=lang, lang_name=lang_name)
935 # Parse also the POT: it will possibly provide additional targets.
936 # (Because the POT comments are correct on Launchpad but not the
937 # PO comments due to a Launchpad limitation. See LP bug 933496.)
940 # now, the serious things: we read the language file
942 if fileformat == 'csv':
943 reader = csv.reader(fileobj, quotechar='"', delimiter=',')
944 # read the first line of the file (it contains columns titles)
948 elif fileformat == 'po':
949 reader = TinyPoFile(fileobj)
950 f = ['type', 'name', 'res_id', 'src', 'value', 'comments']
952 # Make a reader for the POT file and be somewhat defensive for the
954 if fileobj.name.endswith('.po'):
956 # Normally the path looks like /path/to/xxx/i18n/lang.po
957 # and we try to find the corresponding
958 # /path/to/xxx/i18n/xxx.pot file.
959 head, _ = os.path.split(fileobj.name)
960 head2, _ = os.path.split(head)
961 head3, tail3 = os.path.split(head2)
962 pot_handle = misc.file_open(os.path.join(head3, tail3, 'i18n', tail3 + '.pot'))
963 pot_reader = TinyPoFile(pot_handle)
968 _logger.error('Bad file format: %s', fileformat)
969 raise Exception(_('Bad file format'))
971 # Read the POT `reference` comments, and keep them indexed by source
974 for type, name, res_id, src, _, comments in pot_reader:
976 pot_targets.setdefault(src, {'value': None, 'targets': []})
977 pot_targets[src]['targets'].append((type, name, res_id))
979 # read the rest of the file
980 irt_cursor = trans_obj._get_import_cursor(cr, SUPERUSER_ID, context=context)
982 def process_row(row):
983 """Process a single PO (or POT) entry."""
984 # skip empty rows and rows where the translation field (=last fiefd) is empty
985 #if (not row) or (not row[-1]):
988 # dictionary which holds values for this line of the csv file
989 # {'lang': ..., 'type': ..., 'name': ..., 'res_id': ...,
990 # 'src': ..., 'value': ..., 'module':...}
991 dic = dict.fromkeys(('name', 'res_id', 'src', 'type', 'imd_model', 'imd_name', 'module', 'value', 'comments'))
993 for i, field in enumerate(f):
996 # Get the `reference` comments from the POT.
998 if pot_reader and src in pot_targets:
999 pot_targets[src]['targets'] = filter(lambda x: x != row[:3], pot_targets[src]['targets'])
1000 pot_targets[src]['value'] = row[4]
1001 if not pot_targets[src]['targets']:
1002 del pot_targets[src]
1004 # This would skip terms that fail to specify a res_id
1005 if not dic.get('res_id'):
1008 res_id = dic.pop('res_id')
1009 if res_id and isinstance(res_id, (int, long)) \
1010 or (isinstance(res_id, basestring) and res_id.isdigit()):
1011 dic['res_id'] = int(res_id)
1012 dic['module'] = module_name
1014 tmodel = dic['name'].split(',')[0]
1016 tmodule, tname = res_id.split('.', 1)
1020 dic['imd_model'] = tmodel
1021 dic['imd_name'] = tname
1022 dic['module'] = tmodule
1023 dic['res_id'] = None
1025 irt_cursor.push(dic)
1027 # First process the entries from the PO file (doing so also fills/removes
1028 # the entries from the POT file).
1032 # Then process the entries implied by the POT file (which is more
1033 # correct w.r.t. the targets) if some of them remain.
1035 for src in pot_targets:
1036 value = pot_targets[src]['value']
1037 for type, name, res_id in pot_targets[src]['targets']:
1038 pot_rows.append((type, name, res_id, src, value, comments))
1039 for row in pot_rows:
1043 trans_obj.clear_caches()
1045 _logger.info("translation file loaded succesfully")
1047 filename = '[lang: %s][format: %s]' % (iso_lang or 'new', fileformat)
1048 _logger.exception("couldn't read translation file %s", filename)
1050 def get_locales(lang=None):
1052 lang = locale.getdefaultlocale()[0]
1055 lang = _LOCALE2WIN32.get(lang, lang)
1058 ln = locale._build_localename((lang, enc))
1060 nln = locale.normalize(ln)
1064 for x in process('utf8'): yield x
1066 prefenc = locale.getpreferredencoding()
1068 for x in process(prefenc): yield x
1072 'iso-8859-1': 'iso8859-15',
1074 }.get(prefenc.lower())
1076 for x in process(prefenc): yield x
1083 # locale.resetlocale is bugged with some locales.
1084 for ln in get_locales():
1086 return locale.setlocale(locale.LC_ALL, ln)
1087 except locale.Error:
1090 def load_language(cr, lang):
1091 """Loads a translation terms for a language.
1092 Used mainly to automate language loading at db initialization.
1094 :param lang: language ISO code with optional _underscore_ and l10n flavor (ex: 'fr', 'fr_BE', but not 'fr-BE')
1097 registry = openerp.registry(cr.dbname)
1098 language_installer = registry['base.language.install']
1099 oid = language_installer.create(cr, SUPERUSER_ID, {'lang': lang})
1100 language_installer.lang_install(cr, SUPERUSER_ID, [oid], context=None)
1102 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: