[IMP] translations: simplify condition in qweb terms extraction
[odoo/odoo.git] / openerp / tools / translate.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>).
6 #
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.
11 #
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.
16 #
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/>.
19 #
20 ##############################################################################
21
22 import codecs
23 import csv
24 import fnmatch
25 import inspect
26 import locale
27 import os
28 import openerp.sql_db as sql_db
29 import re
30 import logging
31 import tarfile
32 import tempfile
33 import threading
34 from babel.messages import extract
35 from os.path import join
36
37 from datetime import datetime
38 from lxml import etree
39
40 import config
41 import misc
42 from misc import SKIPPED_ELEMENT_TYPES
43 import osutil
44 import openerp
45 from openerp import SUPERUSER_ID
46
47 _logger = logging.getLogger(__name__)
48
49 # used to notify web client that these translations should be loaded in the UI
50 WEB_TRANSLATION_COMMENT = "openerp-web"
51
52 SKIPPED_ELEMENTS = ('script', 'style')
53
54 _LOCALE2WIN32 = {
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': 'Bosnian_Bosnia and Herzegovina',
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',
84     'hi_IN': 'Hindi',
85     'hu': 'Hungarian_Hungary',
86     'is_IS': 'Icelandic_Iceland',
87     'id_ID': 'Indonesian_indonesia',
88     'it_IT': 'Italian_Italy',
89     'ja_JP': 'Japanese_Japan',
90     'kn_IN': 'Kannada',
91     'km_KH': 'Khmer',
92     'ko_KR': 'Korean_Korea',
93     'lo_LA': 'Lao_Laos',
94     'lt_LT': 'Lithuanian_Lithuania',
95     'lat': 'Latvian_Latvia',
96     'ml_IN': 'Malayalam_India',
97     'mi_NZ': 'Maori',
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',
137
138 }
139
140
141 class UNIX_LINE_TERMINATOR(csv.excel):
142     lineterminator = '\n'
143
144 csv.register_dialect("UNIX", UNIX_LINE_TERMINATOR)
145
146 #
147 # Warning: better use self.pool.get('ir.translation')._get_source if you can
148 #
149 def translate(cr, name, source_type, lang, source=None):
150     if source and name:
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))
152     elif name:
153         cr.execute('select value from ir_translation where lang=%s and type=%s and name=%s', (lang, source_type, str(name)))
154     elif source:
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
158     return res
159
160 class GettextAlias(object):
161
162     def _get_db(self):
163         # find current DB based on thread/worker db name (see netsvc)
164         db_name = getattr(threading.currentThread(), 'dbname', None)
165         if db_name:
166             return sql_db.db_connect(db_name)
167
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
177         if hasattr(s, 'cr'):
178             return s.cr, False
179         if allow_create:
180             # create a new cursor
181             db = self._get_db()
182             if db is not None:
183                 return db.cursor(), True
184         return None, False
185
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')
193         return s.env.uid
194
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         lang = None
199         if frame.f_locals.get('context'):
200             lang = frame.f_locals['context'].get('lang')
201         if not lang:
202             kwargs = frame.f_locals.get('kwargs', {})
203             if kwargs.get('context'):
204                 lang = kwargs['context'].get('lang')
205         if not lang:
206             s = frame.f_locals.get('self')
207             if hasattr(s, 'env'):
208                 lang = s.env.lang
209             if not lang:
210                 if hasattr(s, 'localcontext'):
211                     lang = s.localcontext.get('lang')
212             if not lang:
213                 # Last resort: attempt to guess the language of the user
214                 # Pitfall: some operations are performed in sudo mode, and we
215                 #          don't know the originial uid, so the language may
216                 #          be wrong when the admin language differs.
217                 pool = getattr(s, 'pool', None)
218                 (cr, dummy) = self._get_cr(frame, allow_create=False)
219                 uid = self._get_uid(frame)
220                 if pool and cr and uid:
221                     lang = pool['res.users'].context_get(cr, uid)['lang']
222         return lang
223
224     def __call__(self, source):
225         res = source
226         cr = None
227         is_new_cr = False
228         try:
229             frame = inspect.currentframe()
230             if frame is None:
231                 return source
232             frame = frame.f_back
233             if not frame:
234                 return source
235             lang = self._get_lang(frame)
236             if lang:
237                 cr, is_new_cr = self._get_cr(frame)
238                 if cr:
239                     # Try to use ir.translation to benefit from global cache if possible
240                     registry = openerp.registry(cr.dbname)
241                     res = registry['ir.translation']._get_source(cr, SUPERUSER_ID, None, ('code','sql_constraint'), lang, source)
242                 else:
243                     _logger.debug('no context cursor detected, skipping translation for "%r"', source)
244             else:
245                 _logger.debug('no translation language detected, skipping translation for "%r" ', source)
246         except Exception:
247             _logger.debug('translation went wrong for "%r", skipped', source)
248                 # if so, double-check the root/base translations filenames
249         finally:
250             if cr and is_new_cr:
251                 cr.close()
252         return res
253
254 _ = GettextAlias()
255
256
257 def quote(s):
258     """Returns quoted PO term string, with special PO characters escaped"""
259     assert r"\n" not in s, "Translation terms may not include escaped newlines ('\\n'), please use only literal newlines! (in '%s')" % s
260     return '"%s"' % s.replace('\\','\\\\') \
261                      .replace('"','\\"') \
262                      .replace('\n', '\\n"\n"')
263
264 re_escaped_char = re.compile(r"(\\.)")
265 re_escaped_replacements = {'n': '\n', }
266
267 def _sub_replacement(match_obj):
268     return re_escaped_replacements.get(match_obj.group(1)[1], match_obj.group(1)[1])
269
270 def unquote(str):
271     """Returns unquoted PO term string, with special PO characters unescaped"""
272     return re_escaped_char.sub(_sub_replacement, str[1:-1])
273
274 # class to handle po files
275 class TinyPoFile(object):
276     def __init__(self, buffer):
277         self.buffer = buffer
278
279     def warn(self, msg, *args):
280         _logger.warning(msg, *args)
281
282     def __iter__(self):
283         self.buffer.seek(0)
284         self.lines = self._get_lines()
285         self.lines_count = len(self.lines)
286
287         self.first = True
288         self.extra_lines= []
289         return self
290
291     def _get_lines(self):
292         lines = self.buffer.readlines()
293         # remove the BOM (Byte Order Mark):
294         if len(lines):
295             lines[0] = unicode(lines[0], 'utf8').lstrip(unicode( codecs.BOM_UTF8, "utf8"))
296
297         lines.append('') # ensure that the file ends with at least an empty line
298         return lines
299
300     def cur_line(self):
301         return self.lines_count - len(self.lines)
302
303     def next(self):
304         trans_type = name = res_id = source = trad = None
305         if self.extra_lines:
306             trans_type, name, res_id, source, trad, comments = self.extra_lines.pop(0)
307             if not res_id:
308                 res_id = '0'
309         else:
310             comments = []
311             targets = []
312             line = None
313             fuzzy = False
314             while not line:
315                 if 0 == len(self.lines):
316                     raise StopIteration()
317                 line = self.lines.pop(0).strip()
318             while line.startswith('#'):
319                 if line.startswith('#~ '):
320                     break
321                 if line.startswith('#.'):
322                     line = line[2:].strip()
323                     if not line.startswith('module:'):
324                         comments.append(line)
325                 elif line.startswith('#:'):
326                     # Process the `reference` comments. Each line can specify
327                     # multiple targets (e.g. model, view, code, selection,
328                     # ...). For each target, we will return an additional
329                     # entry.
330                     for lpart in line[2:].strip().split(' '):
331                         trans_info = lpart.strip().split(':',2)
332                         if trans_info and len(trans_info) == 2:
333                             # looks like the translation trans_type is missing, which is not
334                             # unexpected because it is not a GetText standard. Default: 'code'
335                             trans_info[:0] = ['code']
336                         if trans_info and len(trans_info) == 3:
337                             # this is a ref line holding the destination info (model, field, record)
338                             targets.append(trans_info)
339                 elif line.startswith('#,') and (line[2:].strip() == 'fuzzy'):
340                     fuzzy = True
341                 line = self.lines.pop(0).strip()
342             while not line:
343                 # allow empty lines between comments and msgid
344                 line = self.lines.pop(0).strip()
345             if line.startswith('#~ '):
346                 while line.startswith('#~ ') or not line.strip():
347                     if 0 == len(self.lines):
348                         raise StopIteration()
349                     line = self.lines.pop(0)
350                 # This has been a deprecated entry, don't return anything
351                 return self.next()
352
353             if not line.startswith('msgid'):
354                 raise Exception("malformed file: bad line: %s" % line)
355             source = unquote(line[6:])
356             line = self.lines.pop(0).strip()
357             if not source and self.first:
358                 # if the source is "" and it's the first msgid, it's the special
359                 # msgstr with the informations about the traduction and the
360                 # traductor; we skip it
361                 self.extra_lines = []
362                 while line:
363                     line = self.lines.pop(0).strip()
364                 return self.next()
365
366             while not line.startswith('msgstr'):
367                 if not line:
368                     raise Exception('malformed file at %d'% self.cur_line())
369                 source += unquote(line)
370                 line = self.lines.pop(0).strip()
371
372             trad = unquote(line[7:])
373             line = self.lines.pop(0).strip()
374             while line:
375                 trad += unquote(line)
376                 line = self.lines.pop(0).strip()
377
378             if targets and not fuzzy:
379                 # Use the first target for the current entry (returned at the
380                 # end of this next() call), and keep the others to generate
381                 # additional entries (returned the next next() calls).
382                 trans_type, name, res_id = targets.pop(0)
383                 for t, n, r in targets:
384                     if t == trans_type == 'code': continue
385                     self.extra_lines.append((t, n, r, source, trad, comments))
386
387         self.first = False
388
389         if name is None:
390             if not fuzzy:
391                 self.warn('Missing "#:" formated comment at line %d for the following source:\n\t%s',
392                         self.cur_line(), source[:30])
393             return self.next()
394         return trans_type, name, res_id, source, trad, '\n'.join(comments)
395
396     def write_infos(self, modules):
397         import openerp.release as release
398         self.buffer.write("# Translation of %(project)s.\n" \
399                           "# This file contains the translation of the following modules:\n" \
400                           "%(modules)s" \
401                           "#\n" \
402                           "msgid \"\"\n" \
403                           "msgstr \"\"\n" \
404                           '''"Project-Id-Version: %(project)s %(version)s\\n"\n''' \
405                           '''"Report-Msgid-Bugs-To: \\n"\n''' \
406                           '''"POT-Creation-Date: %(now)s\\n"\n'''        \
407                           '''"PO-Revision-Date: %(now)s\\n"\n'''         \
408                           '''"Last-Translator: <>\\n"\n''' \
409                           '''"Language-Team: \\n"\n'''   \
410                           '''"MIME-Version: 1.0\\n"\n''' \
411                           '''"Content-Type: text/plain; charset=UTF-8\\n"\n'''   \
412                           '''"Content-Transfer-Encoding: \\n"\n'''       \
413                           '''"Plural-Forms: \\n"\n'''    \
414                           "\n"
415
416                           % { 'project': release.description,
417                               'version': release.version,
418                               'modules': reduce(lambda s, m: s + "#\t* %s\n" % m, modules, ""),
419                               'now': datetime.utcnow().strftime('%Y-%m-%d %H:%M')+"+0000",
420                             }
421                           )
422
423     def write(self, modules, tnrs, source, trad, comments=None):
424
425         plurial = len(modules) > 1 and 's' or ''
426         self.buffer.write("#. module%s: %s\n" % (plurial, ', '.join(modules)))
427
428         if comments:
429             self.buffer.write(''.join(('#. %s\n' % c for c in comments)))
430
431         code = False
432         for typy, name, res_id in tnrs:
433             self.buffer.write("#: %s:%s:%s\n" % (typy, name, res_id))
434             if typy == 'code':
435                 code = True
436
437         if code:
438             # only strings in python code are python formated
439             self.buffer.write("#, python-format\n")
440
441         if not isinstance(trad, unicode):
442             trad = unicode(trad, 'utf8')
443         if not isinstance(source, unicode):
444             source = unicode(source, 'utf8')
445
446         msg = "msgid %s\n"      \
447               "msgstr %s\n\n"   \
448                   % (quote(source), quote(trad))
449         self.buffer.write(msg.encode('utf8'))
450
451
452 # Methods to export the translation file
453
454 def trans_export(lang, modules, buffer, format, cr):
455
456     def _process(format, modules, rows, buffer, lang):
457         if format == 'csv':
458             writer = csv.writer(buffer, 'UNIX')
459             # write header first
460             writer.writerow(("module","type","name","res_id","src","value"))
461             for module, type, name, res_id, src, trad, comments in rows:
462                 # Comments are ignored by the CSV writer
463                 writer.writerow((module, type, name, res_id, src, trad))
464         elif format == 'po':
465             writer = TinyPoFile(buffer)
466             writer.write_infos(modules)
467
468             # we now group the translations by source. That means one translation per source.
469             grouped_rows = {}
470             for module, type, name, res_id, src, trad, comments in rows:
471                 row = grouped_rows.setdefault(src, {})
472                 row.setdefault('modules', set()).add(module)
473                 if not row.get('translation') and trad != src:
474                     row['translation'] = trad
475                 row.setdefault('tnrs', []).append((type, name, res_id))
476                 row.setdefault('comments', set()).update(comments)
477
478             for src, row in sorted(grouped_rows.items()):
479                 if not lang:
480                     # translation template, so no translation value
481                     row['translation'] = ''
482                 elif not row.get('translation'):
483                     row['translation'] = src
484                 writer.write(row['modules'], row['tnrs'], src, row['translation'], row['comments'])
485
486         elif format == 'tgz':
487             rows_by_module = {}
488             for row in rows:
489                 module = row[0]
490                 rows_by_module.setdefault(module, []).append(row)
491             tmpdir = tempfile.mkdtemp()
492             for mod, modrows in rows_by_module.items():
493                 tmpmoddir = join(tmpdir, mod, 'i18n')
494                 os.makedirs(tmpmoddir)
495                 pofilename = (lang if lang else mod) + ".po" + ('t' if not lang else '')
496                 buf = file(join(tmpmoddir, pofilename), 'w')
497                 _process('po', [mod], modrows, buf, lang)
498                 buf.close()
499
500             tar = tarfile.open(fileobj=buffer, mode='w|gz')
501             tar.add(tmpdir, '')
502             tar.close()
503
504         else:
505             raise Exception(_('Unrecognized extension: must be one of '
506                 '.csv, .po, or .tgz (received .%s).' % format))
507
508     trans_lang = lang
509     if not trans_lang and format == 'csv':
510         # CSV files are meant for translators and they need a starting point,
511         # so we at least put the original term in the translation column
512         trans_lang = 'en_US'
513     translations = trans_generate(lang, modules, cr)
514     modules = set([t[0] for t in translations[1:]])
515     _process(format, modules, translations, buffer, lang)
516     del translations
517
518 def trans_parse_xsl(de):
519     return list(set(trans_parse_xsl_aux(de, False)))
520
521 def trans_parse_xsl_aux(de, t):
522     res = []
523
524     for n in de:
525         t = t or n.get("t")
526         if t:
527                 if isinstance(n, SKIPPED_ELEMENT_TYPES) or n.tag.startswith('{http://www.w3.org/1999/XSL/Transform}'):
528                     continue
529                 if n.text:
530                     l = n.text.strip().replace('\n',' ')
531                     if len(l):
532                         res.append(l.encode("utf8"))
533                 if n.tail:
534                     l = n.tail.strip().replace('\n',' ')
535                     if len(l):
536                         res.append(l.encode("utf8"))
537         res.extend(trans_parse_xsl_aux(n, t))
538     return res
539
540 def trans_parse_rml(de):
541     res = []
542     for n in de:
543         for m in n:
544             if isinstance(m, SKIPPED_ELEMENT_TYPES) or not m.text:
545                 continue
546             string_list = [s.replace('\n', ' ').strip() for s in re.split('\[\[.+?\]\]', m.text)]
547             for s in string_list:
548                 if s:
549                     res.append(s.encode("utf8"))
550         res.extend(trans_parse_rml(n))
551     return res
552
553 def _push(callback, term, source_line):
554     """ Sanity check before pushing translation terms """
555     term = (term or "").strip().encode('utf8')
556     # Avoid non-char tokens like ':' '...' '.00' etc.
557     if len(term) > 8 or any(x.isalpha() for x in term):
558         callback(term, source_line)
559
560 def trans_parse_view(element, callback):
561     """ Helper method to recursively walk an etree document representing a
562         regular view and call ``callback(term)`` for each translatable term
563         that is found in the document.
564
565         :param ElementTree element: root of etree document to extract terms from
566         :param callable callback: a callable in the form ``f(term, source_line)``,
567             that will be called for each extracted term.
568     """
569     for el in element.iter():
570         if (not isinstance(el, SKIPPED_ELEMENT_TYPES)
571                 and el.tag.lower() not in SKIPPED_ELEMENTS
572                 and el.text):
573             _push(callback, el.text, el.sourceline)
574         if el.tail:
575             _push(callback, el.tail, el.sourceline)
576         for attr in ('string', 'help', 'sum', 'confirm', 'placeholder'):
577             value = el.get(attr)
578             if value:
579                 _push(callback, value, el.sourceline)
580
581 # tests whether an object is in a list of modules
582 def in_modules(object_name, modules):
583     if 'all' in modules:
584         return True
585
586     module_dict = {
587         'ir': 'base',
588         'res': 'base',
589         'workflow': 'base',
590     }
591     module = object_name.split('.')[0]
592     module = module_dict.get(module, module)
593     return module in modules
594
595 def _extract_translatable_qweb_terms(element, callback):
596     """ Helper method to walk an etree document representing
597         a QWeb template, and call ``callback(term)`` for each
598         translatable term that is found in the document.
599
600         :param etree._Element element: root of etree document to extract terms from
601         :param Callable callback: a callable in the form ``f(term, source_line)``,
602                                   that will be called for each extracted term.
603     """
604     # not using elementTree.iterparse because we need to skip sub-trees in case
605     # the ancestor element had a reason to be skipped
606     for el in element:
607         if isinstance(el, SKIPPED_ELEMENT_TYPES): continue
608         if (el.tag.lower() not in SKIPPED_ELEMENTS
609                 and "t-js" not in el.attrib
610                 and not ("t-jquery" in el.attrib and "t-operation" not in el.attrib)
611                 and el.get("t-translation", '').strip() != "off"):
612             _push(callback, el.text, el.sourceline)
613             for att in ('title', 'alt', 'label', 'placeholder'):
614                 if att in el.attrib:
615                     _push(callback, el.attrib[att], el.sourceline)
616             _extract_translatable_qweb_terms(el, callback)
617         _push(callback, el.tail, el.sourceline)
618
619 def babel_extract_qweb(fileobj, keywords, comment_tags, options):
620     """Babel message extractor for qweb template files.
621
622     :param fileobj: the file-like object the messages should be extracted from
623     :param keywords: a list of keywords (i.e. function names) that should
624                      be recognized as translation functions
625     :param comment_tags: a list of translator tags to search for and
626                          include in the results
627     :param options: a dictionary of additional options (optional)
628     :return: an iterator over ``(lineno, funcname, message, comments)``
629              tuples
630     :rtype: Iterable
631     """
632     result = []
633     def handle_text(text, lineno):
634         result.append((lineno, None, text, []))
635     tree = etree.parse(fileobj)
636     _extract_translatable_qweb_terms(tree.getroot(), handle_text)
637     return result
638
639 def trans_generate(lang, modules, cr):
640     dbname = cr.dbname
641
642     registry = openerp.registry(dbname)
643     trans_obj = registry.get('ir.translation')
644     model_data_obj = registry.get('ir.model.data')
645     uid = 1
646     l = registry.models.items()
647     l.sort()
648
649     query = 'SELECT name, model, res_id, module'    \
650             '  FROM ir_model_data'
651
652     query_models = """SELECT m.id, m.model, imd.module
653             FROM ir_model AS m, ir_model_data AS imd
654             WHERE m.id = imd.res_id AND imd.model = 'ir.model' """
655
656     if 'all_installed' in modules:
657         query += ' WHERE module IN ( SELECT name FROM ir_module_module WHERE state = \'installed\') '
658         query_models += " AND imd.module in ( SELECT name FROM ir_module_module WHERE state = 'installed') "
659     query_param = None
660     if 'all' not in modules:
661         query += ' WHERE module IN %s'
662         query_models += ' AND imd.module in %s'
663         query_param = (tuple(modules),)
664     query += ' ORDER BY module, model, name'
665     query_models += ' ORDER BY module, model'
666
667     cr.execute(query, query_param)
668
669     _to_translate = []
670     def push_translation(module, type, name, id, source, comments=None):
671         tuple = (module, source, name, id, type, comments or [])
672         # empty and one-letter terms are ignored, they probably are not meant to be
673         # translated, and would be very hard to translate anyway.
674         if not source or len(source.strip()) <= 1:
675             return
676         if tuple not in _to_translate:
677             _to_translate.append(tuple)
678
679     def encode(s):
680         if isinstance(s, unicode):
681             return s.encode('utf8')
682         return s
683
684     def push(mod, type, name, res_id, term):
685         term = (term or '').strip()
686         if len(term) > 2:
687             push_translation(mod, type, name, res_id, term)
688
689     def get_root_view(xml_id):
690         view = model_data_obj.xmlid_to_object(cr, uid, xml_id)
691         if view:
692             while view.mode != 'primary':
693                 view = view.inherit_id
694         xml_id = view.get_external_id(cr, uid).get(view.id, xml_id)
695         return xml_id
696
697     for (xml_name,model,res_id,module) in cr.fetchall():
698         module = encode(module)
699         model = encode(model)
700         xml_name = "%s.%s" % (module, encode(xml_name))
701
702         if model not in registry:
703             _logger.error("Unable to find object %r", model)
704             continue
705
706         if not registry[model]._translate:
707             # explicitly disabled
708             continue
709
710         exists = registry[model].exists(cr, uid, res_id)
711         if not exists:
712             _logger.warning("Unable to find object %r with id %d", model, res_id)
713             continue
714         obj = registry[model].browse(cr, uid, res_id)
715
716         if model=='ir.ui.view':
717             d = etree.XML(encode(obj.arch))
718             if obj.type == 'qweb':
719                 view_id = get_root_view(xml_name)
720                 push_qweb = lambda t,l: push(module, 'view', 'website', view_id, t)
721                 _extract_translatable_qweb_terms(d, push_qweb)
722             else:
723                 push_view = lambda t,l: push(module, 'view', obj.model, xml_name, t)
724                 trans_parse_view(d, push_view)
725         elif model=='ir.actions.wizard':
726             pass # TODO Can model really be 'ir.actions.wizard' ?
727
728         elif model=='ir.model.fields':
729             try:
730                 field_name = encode(obj.name)
731             except AttributeError, exc:
732                 _logger.error("name error in %s: %s", xml_name, str(exc))
733                 continue
734             objmodel = registry.get(obj.model)
735             if (objmodel is None or field_name not in objmodel._columns
736                     or not objmodel._translate):
737                 continue
738             field_def = objmodel._columns[field_name]
739
740             name = "%s,%s" % (encode(obj.model), field_name)
741             push_translation(module, 'field', name, 0, encode(field_def.string))
742
743             if field_def.help:
744                 push_translation(module, 'help', name, 0, encode(field_def.help))
745
746             if field_def.translate:
747                 ids = objmodel.search(cr, uid, [])
748                 obj_values = objmodel.read(cr, uid, ids, [field_name])
749                 for obj_value in obj_values:
750                     res_id = obj_value['id']
751                     if obj.name in ('ir.model', 'ir.ui.menu'):
752                         res_id = 0
753                     model_data_ids = model_data_obj.search(cr, uid, [
754                         ('model', '=', model),
755                         ('res_id', '=', res_id),
756                         ])
757                     if not model_data_ids:
758                         push_translation(module, 'model', name, 0, encode(obj_value[field_name]))
759
760             if hasattr(field_def, 'selection') and isinstance(field_def.selection, (list, tuple)):
761                 for dummy, val in field_def.selection:
762                     push_translation(module, 'selection', name, 0, encode(val))
763
764         elif model=='ir.actions.report.xml':
765             name = encode(obj.report_name)
766             fname = ""
767             if obj.report_rml:
768                 fname = obj.report_rml
769                 parse_func = trans_parse_rml
770                 report_type = "report"
771             elif obj.report_xsl:
772                 fname = obj.report_xsl
773                 parse_func = trans_parse_xsl
774                 report_type = "xsl"
775             if fname and obj.report_type in ('pdf', 'xsl'):
776                 try:
777                     report_file = misc.file_open(fname)
778                     try:
779                         d = etree.parse(report_file)
780                         for t in parse_func(d.iter()):
781                             push_translation(module, report_type, name, 0, t)
782                     finally:
783                         report_file.close()
784                 except (IOError, etree.XMLSyntaxError):
785                     _logger.exception("couldn't export translation for report %s %s %s", name, report_type, fname)
786
787         for field_name, field_def in obj._columns.items():
788             if model == 'ir.model' and field_name == 'name' and obj.name == obj.model:
789                 # ignore model name if it is the technical one, nothing to translate
790                 continue
791             if field_def.translate:
792                 name = model + "," + field_name
793                 try:
794                     term = obj[field_name] or ''
795                 except:
796                     term = ''
797                 push_translation(module, 'model', name, xml_name, encode(term))
798
799         # End of data for ir.model.data query results
800
801     cr.execute(query_models, query_param)
802
803     def push_constraint_msg(module, term_type, model, msg):
804         if not hasattr(msg, '__call__'):
805             push_translation(encode(module), term_type, encode(model), 0, encode(msg))
806
807     def push_local_constraints(module, model, cons_type='sql_constraints'):
808         """Climb up the class hierarchy and ignore inherited constraints
809            from other modules"""
810         term_type = 'sql_constraint' if cons_type == 'sql_constraints' else 'constraint'
811         msg_pos = 2 if cons_type == 'sql_constraints' else 1
812         for cls in model.__class__.__mro__:
813             if getattr(cls, '_module', None) != module:
814                 continue
815             constraints = getattr(cls, '_local_' + cons_type, [])
816             for constraint in constraints:
817                 push_constraint_msg(module, term_type, model._name, constraint[msg_pos])
818             
819     for (_, model, module) in cr.fetchall():
820         if model not in registry:
821             _logger.error("Unable to find object %r", model)
822             continue
823
824         model_obj = registry[model]
825
826         if model_obj._constraints:
827             push_local_constraints(module, model_obj, 'constraints')
828
829         if model_obj._sql_constraints:
830             push_local_constraints(module, model_obj, 'sql_constraints')
831
832     modobj = registry['ir.module.module']
833     installed_modids = modobj.search(cr, uid, [('state', '=', 'installed')])
834     installed_modules = map(lambda m: m['name'], modobj.read(cr, uid, installed_modids, ['name']))
835
836     path_list = list(openerp.modules.module.ad_paths)
837     # Also scan these non-addon paths
838     for bin_path in ['osv', 'report' ]:
839         path_list.append(os.path.join(config.config['root_path'], bin_path))
840
841     _logger.debug("Scanning modules at paths: %s", path_list)
842
843     mod_paths = list(path_list)
844
845     def get_module_from_path(path):
846         for mp in mod_paths:
847             if path.startswith(mp) and (os.path.dirname(path) != mp):
848                 path = path[len(mp)+1:]
849                 return path.split(os.path.sep)[0]
850         return 'base'   # files that are not in a module are considered as being in 'base' module
851
852     def verified_module_filepaths(fname, path, root):
853         fabsolutepath = join(root, fname)
854         frelativepath = fabsolutepath[len(path):]
855         display_path = "addons%s" % frelativepath
856         module = get_module_from_path(fabsolutepath)
857         if ('all' in modules or module in modules) and module in installed_modules:
858             return module, fabsolutepath, frelativepath, display_path
859         return None, None, None, None
860
861     def babel_extract_terms(fname, path, root, extract_method="python", trans_type='code',
862                                extra_comments=None, extract_keywords={'_': None}):
863         module, fabsolutepath, _, display_path = verified_module_filepaths(fname, path, root)
864         extra_comments = extra_comments or []
865         if module:
866             src_file = open(fabsolutepath, 'r')
867             try:
868                 for extracted in extract.extract(extract_method, src_file,
869                                                  keywords=extract_keywords):
870                     # Babel 0.9.6 yields lineno, message, comments
871                     # Babel 1.3 yields lineno, message, comments, context
872                     lineno, message, comments = extracted[:3] 
873                     push_translation(module, trans_type, display_path, lineno,
874                                      encode(message), comments + extra_comments)
875             except Exception:
876                 _logger.exception("Failed to extract terms from %s", fabsolutepath)
877             finally:
878                 src_file.close()
879
880     for path in path_list:
881         _logger.debug("Scanning files of modules at %s", path)
882         for root, dummy, files in osutil.walksymlinks(path):
883             for fname in fnmatch.filter(files, '*.py'):
884                 babel_extract_terms(fname, path, root)
885             # mako provides a babel extractor: http://docs.makotemplates.org/en/latest/usage.html#babel
886             for fname in fnmatch.filter(files, '*.mako'):
887                 babel_extract_terms(fname, path, root, 'mako', trans_type='report')
888             # Javascript source files in the static/src/js directory, rest is ignored (libs)
889             if fnmatch.fnmatch(root, '*/static/src/js*'):
890                 for fname in fnmatch.filter(files, '*.js'):
891                     babel_extract_terms(fname, path, root, 'javascript',
892                                         extra_comments=[WEB_TRANSLATION_COMMENT],
893                                         extract_keywords={'_t': None, '_lt': None})
894             # QWeb template files
895             if fnmatch.fnmatch(root, '*/static/src/xml*'):
896                 for fname in fnmatch.filter(files, '*.xml'):
897                     babel_extract_terms(fname, path, root, 'openerp.tools.translate:babel_extract_qweb',
898                                         extra_comments=[WEB_TRANSLATION_COMMENT])
899
900     out = []
901     _to_translate.sort()
902     # translate strings marked as to be translated
903     for module, source, name, id, type, comments in _to_translate:
904         trans = '' if not lang else trans_obj._get_source(cr, uid, name, type, lang, source)
905         out.append([module, type, name, id, source, encode(trans) or '', comments])
906     return out
907
908 def trans_load(cr, filename, lang, verbose=True, module_name=None, context=None):
909     try:
910         fileobj = misc.file_open(filename)
911         _logger.info("loading %s", filename)
912         fileformat = os.path.splitext(filename)[-1][1:].lower()
913         result = trans_load_data(cr, fileobj, fileformat, lang, verbose=verbose, module_name=module_name, context=context)
914         fileobj.close()
915         return result
916     except IOError:
917         if verbose:
918             _logger.error("couldn't read translation file %s", filename)
919         return None
920
921 def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, module_name=None, context=None):
922     """Populates the ir_translation table."""
923     if verbose:
924         _logger.info('loading translation file for language %s', lang)
925     if context is None:
926         context = {}
927     db_name = cr.dbname
928     registry = openerp.registry(db_name)
929     lang_obj = registry.get('res.lang')
930     trans_obj = registry.get('ir.translation')
931     iso_lang = misc.get_iso_codes(lang)
932     try:
933         ids = lang_obj.search(cr, SUPERUSER_ID, [('code','=', lang)])
934
935         if not ids:
936             # lets create the language with locale information
937             lang_obj.load_lang(cr, SUPERUSER_ID, lang=lang, lang_name=lang_name)
938
939         # Parse also the POT: it will possibly provide additional targets.
940         # (Because the POT comments are correct on Launchpad but not the
941         # PO comments due to a Launchpad limitation. See LP bug 933496.)
942         pot_reader = []
943
944         # now, the serious things: we read the language file
945         fileobj.seek(0)
946         if fileformat == 'csv':
947             reader = csv.reader(fileobj, quotechar='"', delimiter=',')
948             # read the first line of the file (it contains columns titles)
949             for row in reader:
950                 f = row
951                 break
952         elif fileformat == 'po':
953             reader = TinyPoFile(fileobj)
954             f = ['type', 'name', 'res_id', 'src', 'value', 'comments']
955
956             # Make a reader for the POT file and be somewhat defensive for the
957             # stable branch.
958             if fileobj.name.endswith('.po'):
959                 try:
960                     # Normally the path looks like /path/to/xxx/i18n/lang.po
961                     # and we try to find the corresponding
962                     # /path/to/xxx/i18n/xxx.pot file.
963                     head, _ = os.path.split(fileobj.name)
964                     head2, _ = os.path.split(head)
965                     head3, tail3 = os.path.split(head2)
966                     pot_handle = misc.file_open(os.path.join(head3, tail3, 'i18n', tail3 + '.pot'))
967                     pot_reader = TinyPoFile(pot_handle)
968                 except:
969                     pass
970
971         else:
972             _logger.error('Bad file format: %s', fileformat)
973             raise Exception(_('Bad file format'))
974
975         # Read the POT `reference` comments, and keep them indexed by source
976         # string.
977         pot_targets = {}
978         for type, name, res_id, src, _, comments in pot_reader:
979             if type is not None:
980                 pot_targets.setdefault(src, {'value': None, 'targets': []})
981                 pot_targets[src]['targets'].append((type, name, res_id))
982
983         # read the rest of the file
984         irt_cursor = trans_obj._get_import_cursor(cr, SUPERUSER_ID, context=context)
985
986         def process_row(row):
987             """Process a single PO (or POT) entry."""
988             # skip empty rows and rows where the translation field (=last fiefd) is empty
989             #if (not row) or (not row[-1]):
990             #    return
991
992             # dictionary which holds values for this line of the csv file
993             # {'lang': ..., 'type': ..., 'name': ..., 'res_id': ...,
994             #  'src': ..., 'value': ..., 'module':...}
995             dic = dict.fromkeys(('name', 'res_id', 'src', 'type', 'imd_model', 'imd_name', 'module', 'value', 'comments'))
996             dic['lang'] = lang
997             for i, field in enumerate(f):
998                 dic[field] = row[i]
999
1000             # Get the `reference` comments from the POT.
1001             src = row[3]
1002             if pot_reader and src in pot_targets:
1003                 pot_targets[src]['targets'] = filter(lambda x: x != row[:3], pot_targets[src]['targets'])
1004                 pot_targets[src]['value'] = row[4]
1005                 if not pot_targets[src]['targets']:
1006                     del pot_targets[src]
1007
1008             # This would skip terms that fail to specify a res_id
1009             if not dic.get('res_id'):
1010                 return
1011
1012             res_id = dic.pop('res_id')
1013             if res_id and isinstance(res_id, (int, long)) \
1014                 or (isinstance(res_id, basestring) and res_id.isdigit()):
1015                     dic['res_id'] = int(res_id)
1016                     dic['module'] = module_name
1017             else:
1018                 tmodel = dic['name'].split(',')[0]
1019                 if '.' in res_id:
1020                     tmodule, tname = res_id.split('.', 1)
1021                 else:
1022                     tmodule = False
1023                     tname = res_id
1024                 dic['imd_model'] = tmodel
1025                 dic['imd_name'] =  tname
1026                 dic['module'] = tmodule
1027                 dic['res_id'] = None
1028
1029             irt_cursor.push(dic)
1030
1031         # First process the entries from the PO file (doing so also fills/removes
1032         # the entries from the POT file).
1033         for row in reader:
1034             process_row(row)
1035
1036         # Then process the entries implied by the POT file (which is more
1037         # correct w.r.t. the targets) if some of them remain.
1038         pot_rows = []
1039         for src in pot_targets:
1040             value = pot_targets[src]['value']
1041             for type, name, res_id in pot_targets[src]['targets']:
1042                 pot_rows.append((type, name, res_id, src, value, comments))
1043         for row in pot_rows:
1044             process_row(row)
1045
1046         irt_cursor.finish()
1047         trans_obj.clear_caches()
1048         if verbose:
1049             _logger.info("translation file loaded succesfully")
1050     except IOError:
1051         filename = '[lang: %s][format: %s]' % (iso_lang or 'new', fileformat)
1052         _logger.exception("couldn't read translation file %s", filename)
1053
1054 def get_locales(lang=None):
1055     if lang is None:
1056         lang = locale.getdefaultlocale()[0]
1057
1058     if os.name == 'nt':
1059         lang = _LOCALE2WIN32.get(lang, lang)
1060
1061     def process(enc):
1062         ln = locale._build_localename((lang, enc))
1063         yield ln
1064         nln = locale.normalize(ln)
1065         if nln != ln:
1066             yield nln
1067
1068     for x in process('utf8'): yield x
1069
1070     prefenc = locale.getpreferredencoding()
1071     if prefenc:
1072         for x in process(prefenc): yield x
1073
1074         prefenc = {
1075             'latin1': 'latin9',
1076             'iso-8859-1': 'iso8859-15',
1077             'cp1252': '1252',
1078         }.get(prefenc.lower())
1079         if prefenc:
1080             for x in process(prefenc): yield x
1081
1082     yield lang
1083
1084
1085
1086 def resetlocale():
1087     # locale.resetlocale is bugged with some locales.
1088     for ln in get_locales():
1089         try:
1090             return locale.setlocale(locale.LC_ALL, ln)
1091         except locale.Error:
1092             continue
1093
1094 def load_language(cr, lang):
1095     """Loads a translation terms for a language.
1096     Used mainly to automate language loading at db initialization.
1097
1098     :param lang: language ISO code with optional _underscore_ and l10n flavor (ex: 'fr', 'fr_BE', but not 'fr-BE')
1099     :type lang: str
1100     """
1101     registry = openerp.registry(cr.dbname)
1102     language_installer = registry['base.language.install']
1103     oid = language_installer.create(cr, SUPERUSER_ID, {'lang': lang})
1104     language_installer.lang_install(cr, SUPERUSER_ID, [oid], context=None)
1105
1106 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
1107