[IMP] translations: parse views iteratively instead of recursively
[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 not ("t-translation" in el.attrib and
612                          el.attrib["t-translation"].strip() == "off")):
613             _push(callback, el.text, el.sourceline)
614             for att in ('title', 'alt', 'label', 'placeholder'):
615                 if att in el.attrib:
616                     _push(callback, el.attrib[att], el.sourceline)
617             _extract_translatable_qweb_terms(el, callback)
618         _push(callback, el.tail, el.sourceline)
619
620 def babel_extract_qweb(fileobj, keywords, comment_tags, options):
621     """Babel message extractor for qweb template files.
622
623     :param fileobj: the file-like object the messages should be extracted from
624     :param keywords: a list of keywords (i.e. function names) that should
625                      be recognized as translation functions
626     :param comment_tags: a list of translator tags to search for and
627                          include in the results
628     :param options: a dictionary of additional options (optional)
629     :return: an iterator over ``(lineno, funcname, message, comments)``
630              tuples
631     :rtype: Iterable
632     """
633     result = []
634     def handle_text(text, lineno):
635         result.append((lineno, None, text, []))
636     tree = etree.parse(fileobj)
637     _extract_translatable_qweb_terms(tree.getroot(), handle_text)
638     return result
639
640 def trans_generate(lang, modules, cr):
641     dbname = cr.dbname
642
643     registry = openerp.registry(dbname)
644     trans_obj = registry.get('ir.translation')
645     model_data_obj = registry.get('ir.model.data')
646     uid = 1
647     l = registry.models.items()
648     l.sort()
649
650     query = 'SELECT name, model, res_id, module'    \
651             '  FROM ir_model_data'
652
653     query_models = """SELECT m.id, m.model, imd.module
654             FROM ir_model AS m, ir_model_data AS imd
655             WHERE m.id = imd.res_id AND imd.model = 'ir.model' """
656
657     if 'all_installed' in modules:
658         query += ' WHERE module IN ( SELECT name FROM ir_module_module WHERE state = \'installed\') '
659         query_models += " AND imd.module in ( SELECT name FROM ir_module_module WHERE state = 'installed') "
660     query_param = None
661     if 'all' not in modules:
662         query += ' WHERE module IN %s'
663         query_models += ' AND imd.module in %s'
664         query_param = (tuple(modules),)
665     query += ' ORDER BY module, model, name'
666     query_models += ' ORDER BY module, model'
667
668     cr.execute(query, query_param)
669
670     _to_translate = []
671     def push_translation(module, type, name, id, source, comments=None):
672         tuple = (module, source, name, id, type, comments or [])
673         # empty and one-letter terms are ignored, they probably are not meant to be
674         # translated, and would be very hard to translate anyway.
675         if not source or len(source.strip()) <= 1:
676             return
677         if tuple not in _to_translate:
678             _to_translate.append(tuple)
679
680     def encode(s):
681         if isinstance(s, unicode):
682             return s.encode('utf8')
683         return s
684
685     def push(mod, type, name, res_id, term):
686         term = (term or '').strip()
687         if len(term) > 2:
688             push_translation(mod, type, name, res_id, term)
689
690     def get_root_view(xml_id):
691         view = model_data_obj.xmlid_to_object(cr, uid, xml_id)
692         if view:
693             while view.mode != 'primary':
694                 view = view.inherit_id
695         xml_id = view.get_external_id(cr, uid).get(view.id, xml_id)
696         return xml_id
697
698     for (xml_name,model,res_id,module) in cr.fetchall():
699         module = encode(module)
700         model = encode(model)
701         xml_name = "%s.%s" % (module, encode(xml_name))
702
703         if model not in registry:
704             _logger.error("Unable to find object %r", model)
705             continue
706
707         if not registry[model]._translate:
708             # explicitly disabled
709             continue
710
711         exists = registry[model].exists(cr, uid, res_id)
712         if not exists:
713             _logger.warning("Unable to find object %r with id %d", model, res_id)
714             continue
715         obj = registry[model].browse(cr, uid, res_id)
716
717         if model=='ir.ui.view':
718             d = etree.XML(encode(obj.arch))
719             if obj.type == 'qweb':
720                 view_id = get_root_view(xml_name)
721                 push_qweb = lambda t,l: push(module, 'view', 'website', view_id, t)
722                 _extract_translatable_qweb_terms(d, push_qweb)
723             else:
724                 push_view = lambda t,l: push(module, 'view', obj.model, xml_name, t)
725                 trans_parse_view(d, push_view)
726         elif model=='ir.actions.wizard':
727             pass # TODO Can model really be 'ir.actions.wizard' ?
728
729         elif model=='ir.model.fields':
730             try:
731                 field_name = encode(obj.name)
732             except AttributeError, exc:
733                 _logger.error("name error in %s: %s", xml_name, str(exc))
734                 continue
735             objmodel = registry.get(obj.model)
736             if (objmodel is None or field_name not in objmodel._columns
737                     or not objmodel._translate):
738                 continue
739             field_def = objmodel._columns[field_name]
740
741             name = "%s,%s" % (encode(obj.model), field_name)
742             push_translation(module, 'field', name, 0, encode(field_def.string))
743
744             if field_def.help:
745                 push_translation(module, 'help', name, 0, encode(field_def.help))
746
747             if field_def.translate:
748                 ids = objmodel.search(cr, uid, [])
749                 obj_values = objmodel.read(cr, uid, ids, [field_name])
750                 for obj_value in obj_values:
751                     res_id = obj_value['id']
752                     if obj.name in ('ir.model', 'ir.ui.menu'):
753                         res_id = 0
754                     model_data_ids = model_data_obj.search(cr, uid, [
755                         ('model', '=', model),
756                         ('res_id', '=', res_id),
757                         ])
758                     if not model_data_ids:
759                         push_translation(module, 'model', name, 0, encode(obj_value[field_name]))
760
761             if hasattr(field_def, 'selection') and isinstance(field_def.selection, (list, tuple)):
762                 for dummy, val in field_def.selection:
763                     push_translation(module, 'selection', name, 0, encode(val))
764
765         elif model=='ir.actions.report.xml':
766             name = encode(obj.report_name)
767             fname = ""
768             if obj.report_rml:
769                 fname = obj.report_rml
770                 parse_func = trans_parse_rml
771                 report_type = "report"
772             elif obj.report_xsl:
773                 fname = obj.report_xsl
774                 parse_func = trans_parse_xsl
775                 report_type = "xsl"
776             if fname and obj.report_type in ('pdf', 'xsl'):
777                 try:
778                     report_file = misc.file_open(fname)
779                     try:
780                         d = etree.parse(report_file)
781                         for t in parse_func(d.iter()):
782                             push_translation(module, report_type, name, 0, t)
783                     finally:
784                         report_file.close()
785                 except (IOError, etree.XMLSyntaxError):
786                     _logger.exception("couldn't export translation for report %s %s %s", name, report_type, fname)
787
788         for field_name, field_def in obj._columns.items():
789             if model == 'ir.model' and field_name == 'name' and obj.name == obj.model:
790                 # ignore model name if it is the technical one, nothing to translate
791                 continue
792             if field_def.translate:
793                 name = model + "," + field_name
794                 try:
795                     term = obj[field_name] or ''
796                 except:
797                     term = ''
798                 push_translation(module, 'model', name, xml_name, encode(term))
799
800         # End of data for ir.model.data query results
801
802     cr.execute(query_models, query_param)
803
804     def push_constraint_msg(module, term_type, model, msg):
805         if not hasattr(msg, '__call__'):
806             push_translation(encode(module), term_type, encode(model), 0, encode(msg))
807
808     def push_local_constraints(module, model, cons_type='sql_constraints'):
809         """Climb up the class hierarchy and ignore inherited constraints
810            from other modules"""
811         term_type = 'sql_constraint' if cons_type == 'sql_constraints' else 'constraint'
812         msg_pos = 2 if cons_type == 'sql_constraints' else 1
813         for cls in model.__class__.__mro__:
814             if getattr(cls, '_module', None) != module:
815                 continue
816             constraints = getattr(cls, '_local_' + cons_type, [])
817             for constraint in constraints:
818                 push_constraint_msg(module, term_type, model._name, constraint[msg_pos])
819             
820     for (_, model, module) in cr.fetchall():
821         if model not in registry:
822             _logger.error("Unable to find object %r", model)
823             continue
824
825         model_obj = registry[model]
826
827         if model_obj._constraints:
828             push_local_constraints(module, model_obj, 'constraints')
829
830         if model_obj._sql_constraints:
831             push_local_constraints(module, model_obj, 'sql_constraints')
832
833     modobj = registry['ir.module.module']
834     installed_modids = modobj.search(cr, uid, [('state', '=', 'installed')])
835     installed_modules = map(lambda m: m['name'], modobj.read(cr, uid, installed_modids, ['name']))
836
837     path_list = list(openerp.modules.module.ad_paths)
838     # Also scan these non-addon paths
839     for bin_path in ['osv', 'report' ]:
840         path_list.append(os.path.join(config.config['root_path'], bin_path))
841
842     _logger.debug("Scanning modules at paths: %s", path_list)
843
844     mod_paths = list(path_list)
845
846     def get_module_from_path(path):
847         for mp in mod_paths:
848             if path.startswith(mp) and (os.path.dirname(path) != mp):
849                 path = path[len(mp)+1:]
850                 return path.split(os.path.sep)[0]
851         return 'base'   # files that are not in a module are considered as being in 'base' module
852
853     def verified_module_filepaths(fname, path, root):
854         fabsolutepath = join(root, fname)
855         frelativepath = fabsolutepath[len(path):]
856         display_path = "addons%s" % frelativepath
857         module = get_module_from_path(fabsolutepath)
858         if ('all' in modules or module in modules) and module in installed_modules:
859             return module, fabsolutepath, frelativepath, display_path
860         return None, None, None, None
861
862     def babel_extract_terms(fname, path, root, extract_method="python", trans_type='code',
863                                extra_comments=None, extract_keywords={'_': None}):
864         module, fabsolutepath, _, display_path = verified_module_filepaths(fname, path, root)
865         extra_comments = extra_comments or []
866         if module:
867             src_file = open(fabsolutepath, 'r')
868             try:
869                 for extracted in extract.extract(extract_method, src_file,
870                                                  keywords=extract_keywords):
871                     # Babel 0.9.6 yields lineno, message, comments
872                     # Babel 1.3 yields lineno, message, comments, context
873                     lineno, message, comments = extracted[:3] 
874                     push_translation(module, trans_type, display_path, lineno,
875                                      encode(message), comments + extra_comments)
876             except Exception:
877                 _logger.exception("Failed to extract terms from %s", fabsolutepath)
878             finally:
879                 src_file.close()
880
881     for path in path_list:
882         _logger.debug("Scanning files of modules at %s", path)
883         for root, dummy, files in osutil.walksymlinks(path):
884             for fname in fnmatch.filter(files, '*.py'):
885                 babel_extract_terms(fname, path, root)
886             # mako provides a babel extractor: http://docs.makotemplates.org/en/latest/usage.html#babel
887             for fname in fnmatch.filter(files, '*.mako'):
888                 babel_extract_terms(fname, path, root, 'mako', trans_type='report')
889             # Javascript source files in the static/src/js directory, rest is ignored (libs)
890             if fnmatch.fnmatch(root, '*/static/src/js*'):
891                 for fname in fnmatch.filter(files, '*.js'):
892                     babel_extract_terms(fname, path, root, 'javascript',
893                                         extra_comments=[WEB_TRANSLATION_COMMENT],
894                                         extract_keywords={'_t': None, '_lt': None})
895             # QWeb template files
896             if fnmatch.fnmatch(root, '*/static/src/xml*'):
897                 for fname in fnmatch.filter(files, '*.xml'):
898                     babel_extract_terms(fname, path, root, 'openerp.tools.translate:babel_extract_qweb',
899                                         extra_comments=[WEB_TRANSLATION_COMMENT])
900
901     out = []
902     _to_translate.sort()
903     # translate strings marked as to be translated
904     for module, source, name, id, type, comments in _to_translate:
905         trans = '' if not lang else trans_obj._get_source(cr, uid, name, type, lang, source)
906         out.append([module, type, name, id, source, encode(trans) or '', comments])
907     return out
908
909 def trans_load(cr, filename, lang, verbose=True, module_name=None, context=None):
910     try:
911         fileobj = misc.file_open(filename)
912         _logger.info("loading %s", filename)
913         fileformat = os.path.splitext(filename)[-1][1:].lower()
914         result = trans_load_data(cr, fileobj, fileformat, lang, verbose=verbose, module_name=module_name, context=context)
915         fileobj.close()
916         return result
917     except IOError:
918         if verbose:
919             _logger.error("couldn't read translation file %s", filename)
920         return None
921
922 def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, module_name=None, context=None):
923     """Populates the ir_translation table."""
924     if verbose:
925         _logger.info('loading translation file for language %s', lang)
926     if context is None:
927         context = {}
928     db_name = cr.dbname
929     registry = openerp.registry(db_name)
930     lang_obj = registry.get('res.lang')
931     trans_obj = registry.get('ir.translation')
932     iso_lang = misc.get_iso_codes(lang)
933     try:
934         ids = lang_obj.search(cr, SUPERUSER_ID, [('code','=', lang)])
935
936         if not ids:
937             # lets create the language with locale information
938             lang_obj.load_lang(cr, SUPERUSER_ID, lang=lang, lang_name=lang_name)
939
940         # Parse also the POT: it will possibly provide additional targets.
941         # (Because the POT comments are correct on Launchpad but not the
942         # PO comments due to a Launchpad limitation. See LP bug 933496.)
943         pot_reader = []
944
945         # now, the serious things: we read the language file
946         fileobj.seek(0)
947         if fileformat == 'csv':
948             reader = csv.reader(fileobj, quotechar='"', delimiter=',')
949             # read the first line of the file (it contains columns titles)
950             for row in reader:
951                 f = row
952                 break
953         elif fileformat == 'po':
954             reader = TinyPoFile(fileobj)
955             f = ['type', 'name', 'res_id', 'src', 'value', 'comments']
956
957             # Make a reader for the POT file and be somewhat defensive for the
958             # stable branch.
959             if fileobj.name.endswith('.po'):
960                 try:
961                     # Normally the path looks like /path/to/xxx/i18n/lang.po
962                     # and we try to find the corresponding
963                     # /path/to/xxx/i18n/xxx.pot file.
964                     head, _ = os.path.split(fileobj.name)
965                     head2, _ = os.path.split(head)
966                     head3, tail3 = os.path.split(head2)
967                     pot_handle = misc.file_open(os.path.join(head3, tail3, 'i18n', tail3 + '.pot'))
968                     pot_reader = TinyPoFile(pot_handle)
969                 except:
970                     pass
971
972         else:
973             _logger.error('Bad file format: %s', fileformat)
974             raise Exception(_('Bad file format'))
975
976         # Read the POT `reference` comments, and keep them indexed by source
977         # string.
978         pot_targets = {}
979         for type, name, res_id, src, _, comments in pot_reader:
980             if type is not None:
981                 pot_targets.setdefault(src, {'value': None, 'targets': []})
982                 pot_targets[src]['targets'].append((type, name, res_id))
983
984         # read the rest of the file
985         irt_cursor = trans_obj._get_import_cursor(cr, SUPERUSER_ID, context=context)
986
987         def process_row(row):
988             """Process a single PO (or POT) entry."""
989             # skip empty rows and rows where the translation field (=last fiefd) is empty
990             #if (not row) or (not row[-1]):
991             #    return
992
993             # dictionary which holds values for this line of the csv file
994             # {'lang': ..., 'type': ..., 'name': ..., 'res_id': ...,
995             #  'src': ..., 'value': ..., 'module':...}
996             dic = dict.fromkeys(('name', 'res_id', 'src', 'type', 'imd_model', 'imd_name', 'module', 'value', 'comments'))
997             dic['lang'] = lang
998             for i, field in enumerate(f):
999                 dic[field] = row[i]
1000
1001             # Get the `reference` comments from the POT.
1002             src = row[3]
1003             if pot_reader and src in pot_targets:
1004                 pot_targets[src]['targets'] = filter(lambda x: x != row[:3], pot_targets[src]['targets'])
1005                 pot_targets[src]['value'] = row[4]
1006                 if not pot_targets[src]['targets']:
1007                     del pot_targets[src]
1008
1009             # This would skip terms that fail to specify a res_id
1010             if not dic.get('res_id'):
1011                 return
1012
1013             res_id = dic.pop('res_id')
1014             if res_id and isinstance(res_id, (int, long)) \
1015                 or (isinstance(res_id, basestring) and res_id.isdigit()):
1016                     dic['res_id'] = int(res_id)
1017                     dic['module'] = module_name
1018             else:
1019                 tmodel = dic['name'].split(',')[0]
1020                 if '.' in res_id:
1021                     tmodule, tname = res_id.split('.', 1)
1022                 else:
1023                     tmodule = False
1024                     tname = res_id
1025                 dic['imd_model'] = tmodel
1026                 dic['imd_name'] =  tname
1027                 dic['module'] = tmodule
1028                 dic['res_id'] = None
1029
1030             irt_cursor.push(dic)
1031
1032         # First process the entries from the PO file (doing so also fills/removes
1033         # the entries from the POT file).
1034         for row in reader:
1035             process_row(row)
1036
1037         # Then process the entries implied by the POT file (which is more
1038         # correct w.r.t. the targets) if some of them remain.
1039         pot_rows = []
1040         for src in pot_targets:
1041             value = pot_targets[src]['value']
1042             for type, name, res_id in pot_targets[src]['targets']:
1043                 pot_rows.append((type, name, res_id, src, value, comments))
1044         for row in pot_rows:
1045             process_row(row)
1046
1047         irt_cursor.finish()
1048         trans_obj.clear_caches()
1049         if verbose:
1050             _logger.info("translation file loaded succesfully")
1051     except IOError:
1052         filename = '[lang: %s][format: %s]' % (iso_lang or 'new', fileformat)
1053         _logger.exception("couldn't read translation file %s", filename)
1054
1055 def get_locales(lang=None):
1056     if lang is None:
1057         lang = locale.getdefaultlocale()[0]
1058
1059     if os.name == 'nt':
1060         lang = _LOCALE2WIN32.get(lang, lang)
1061
1062     def process(enc):
1063         ln = locale._build_localename((lang, enc))
1064         yield ln
1065         nln = locale.normalize(ln)
1066         if nln != ln:
1067             yield nln
1068
1069     for x in process('utf8'): yield x
1070
1071     prefenc = locale.getpreferredencoding()
1072     if prefenc:
1073         for x in process(prefenc): yield x
1074
1075         prefenc = {
1076             'latin1': 'latin9',
1077             'iso-8859-1': 'iso8859-15',
1078             'cp1252': '1252',
1079         }.get(prefenc.lower())
1080         if prefenc:
1081             for x in process(prefenc): yield x
1082
1083     yield lang
1084
1085
1086
1087 def resetlocale():
1088     # locale.resetlocale is bugged with some locales.
1089     for ln in get_locales():
1090         try:
1091             return locale.setlocale(locale.LC_ALL, ln)
1092         except locale.Error:
1093             continue
1094
1095 def load_language(cr, lang):
1096     """Loads a translation terms for a language.
1097     Used mainly to automate language loading at db initialization.
1098
1099     :param lang: language ISO code with optional _underscore_ and l10n flavor (ex: 'fr', 'fr_BE', but not 'fr-BE')
1100     :type lang: str
1101     """
1102     registry = openerp.registry(cr.dbname)
1103     language_installer = registry['base.language.install']
1104     oid = language_installer.create(cr, SUPERUSER_ID, {'lang': lang})
1105     language_installer.lang_install(cr, SUPERUSER_ID, [oid], context=None)
1106
1107 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
1108