[MERGE] Forward-port 6.1 bugfixes up to rev. 4307
[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.pooler as pooler
29 import openerp.sql_db as sql_db
30 import re
31 import logging
32 import tarfile
33 import tempfile
34 import threading
35 from babel.messages import extract
36 from os.path import join
37
38 from datetime import datetime
39 from lxml import etree
40
41 import config
42 import misc
43 from misc import UpdateableStr
44 from misc import SKIPPED_ELEMENT_TYPES
45 import osutil
46 from openerp import SUPERUSER_ID
47
48 _logger = logging.getLogger(__name__)
49
50 # used to notify web client that these translations should be loaded in the UI
51 WEB_TRANSLATION_COMMENT = "openerp-web"
52
53 _LOCALE2WIN32 = {
54     'af_ZA': 'Afrikaans_South Africa',
55     'sq_AL': 'Albanian_Albania',
56     'ar_SA': 'Arabic_Saudi Arabia',
57     'eu_ES': 'Basque_Spain',
58     'be_BY': 'Belarusian_Belarus',
59     'bs_BA': 'Serbian (Latin)',
60     'bg_BG': 'Bulgarian_Bulgaria',
61     'ca_ES': 'Catalan_Spain',
62     'hr_HR': 'Croatian_Croatia',
63     'zh_CN': 'Chinese_China',
64     'zh_TW': 'Chinese_Taiwan',
65     'cs_CZ': 'Czech_Czech Republic',
66     'da_DK': 'Danish_Denmark',
67     'nl_NL': 'Dutch_Netherlands',
68     'et_EE': 'Estonian_Estonia',
69     'fa_IR': 'Farsi_Iran',
70     'ph_PH': 'Filipino_Philippines',
71     'fi_FI': 'Finnish_Finland',
72     'fr_FR': 'French_France',
73     'fr_BE': 'French_France',
74     'fr_CH': 'French_France',
75     'fr_CA': 'French_France',
76     'ga': 'Scottish Gaelic',
77     'gl_ES': 'Galician_Spain',
78     'ka_GE': 'Georgian_Georgia',
79     'de_DE': 'German_Germany',
80     'el_GR': 'Greek_Greece',
81     'gu': 'Gujarati_India',
82     'he_IL': 'Hebrew_Israel',
83     'hi_IN': 'Hindi',
84     'hu': 'Hungarian_Hungary',
85     'is_IS': 'Icelandic_Iceland',
86     'id_ID': 'Indonesian_indonesia',
87     'it_IT': 'Italian_Italy',
88     'ja_JP': 'Japanese_Japan',
89     'kn_IN': 'Kannada',
90     'km_KH': 'Khmer',
91     'ko_KR': 'Korean_Korea',
92     'lo_LA': 'Lao_Laos',
93     'lt_LT': 'Lithuanian_Lithuania',
94     'lat': 'Latvian_Latvia',
95     'ml_IN': 'Malayalam_India',
96     'id_ID': 'Indonesian_indonesia',
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     'mi_NZ': 'Maori',
107     'sr_CS': 'Serbian (Cyrillic)_Serbia and Montenegro',
108     'sk_SK': 'Slovak_Slovakia',
109     'sl_SI': 'Slovenian_Slovenia',
110     #should find more specific locales for spanish countries,
111     #but better than nothing
112     'es_AR': 'Spanish_Spain',
113     'es_BO': 'Spanish_Spain',
114     'es_CL': 'Spanish_Spain',
115     'es_CO': 'Spanish_Spain',
116     'es_CR': 'Spanish_Spain',
117     'es_DO': 'Spanish_Spain',
118     'es_EC': 'Spanish_Spain',
119     'es_ES': 'Spanish_Spain',
120     'es_GT': 'Spanish_Spain',
121     'es_HN': 'Spanish_Spain',
122     'es_MX': 'Spanish_Spain',
123     'es_NI': 'Spanish_Spain',
124     'es_PA': 'Spanish_Spain',
125     'es_PE': 'Spanish_Spain',
126     'es_PR': 'Spanish_Spain',
127     'es_PY': 'Spanish_Spain',
128     'es_SV': 'Spanish_Spain',
129     'es_UY': 'Spanish_Spain',
130     'es_VE': 'Spanish_Spain',
131     'sv_SE': 'Swedish_Sweden',
132     'ta_IN': 'English_Australia',
133     'th_TH': 'Thai_Thailand',
134     'mi_NZ': 'Maori',
135     'tr_TR': 'Turkish_Turkey',
136     'uk_UA': 'Ukrainian_Ukraine',
137     'vi_VN': 'Vietnamese_Viet Nam',
138     'tlh_TLH': 'Klingon',
139
140 }
141
142
143 class UNIX_LINE_TERMINATOR(csv.excel):
144     lineterminator = '\n'
145
146 csv.register_dialect("UNIX", UNIX_LINE_TERMINATOR)
147
148 #
149 # Warning: better use self.pool.get('ir.translation')._get_source if you can
150 #
151 def translate(cr, name, source_type, lang, source=None):
152     if source and name:
153         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))
154     elif name:
155         cr.execute('select value from ir_translation where lang=%s and type=%s and name=%s', (lang, source_type, str(name)))
156     elif source:
157         cr.execute('select value from ir_translation where lang=%s and type=%s and src=%s', (lang, source_type, source))
158     res_trans = cr.fetchone()
159     res = res_trans and res_trans[0] or False
160     return res
161
162 class GettextAlias(object):
163
164     def _get_db(self):
165         # find current DB based on thread/worker db name (see netsvc)
166         db_name = getattr(threading.currentThread(), 'dbname', None)
167         if db_name:
168             return sql_db.db_connect(db_name)
169
170     def _get_cr(self, frame, allow_create=True):
171         is_new_cr = False
172         cr = frame.f_locals.get('cr', frame.f_locals.get('cursor'))
173         if not cr:
174             s = frame.f_locals.get('self', {})
175             cr = getattr(s, 'cr', None)
176         if not cr and allow_create:
177             db = self._get_db()
178             if db:
179                 cr = db.cursor()
180                 is_new_cr = True
181         return cr, is_new_cr
182
183     def _get_uid(self, frame):
184         return frame.f_locals.get('uid') or frame.f_locals.get('user')
185
186     def _get_lang(self, frame):
187         lang = None
188         ctx = frame.f_locals.get('context')
189         if not ctx:
190             kwargs = frame.f_locals.get('kwargs')
191             if kwargs is None:
192                 args = frame.f_locals.get('args')
193                 if args and isinstance(args, (list, tuple)) \
194                         and isinstance(args[-1], dict):
195                     ctx = args[-1]
196             elif isinstance(kwargs, dict):
197                 ctx = kwargs.get('context')
198         if ctx:
199             lang = ctx.get('lang')
200         s = frame.f_locals.get('self', {})
201         if not lang:
202             c = getattr(s, 'localcontext', None)
203             if c:
204                 lang = c.get('lang')
205         if not lang:
206             # Last resort: attempt to guess the language of the user
207             # Pitfall: some operations are performed in sudo mode, and we 
208             #          don't know the originial uid, so the language may
209             #          be wrong when the admin language differs.
210             pool = getattr(s, 'pool', None)
211             (cr, dummy) = self._get_cr(frame, allow_create=False)
212             uid = self._get_uid(frame)
213             if pool and cr and uid:
214                 lang = pool.get('res.users').context_get(cr, uid)['lang']
215         return lang
216
217     def __call__(self, source):
218         res = source
219         cr = None
220         is_new_cr = False
221         try:
222             frame = inspect.currentframe()
223             if frame is None:
224                 return source
225             frame = frame.f_back
226             if not frame:
227                 return source
228             lang = self._get_lang(frame)
229             if lang:
230                 cr, is_new_cr = self._get_cr(frame)
231                 if cr:
232                     # Try to use ir.translation to benefit from global cache if possible
233                     pool = pooler.get_pool(cr.dbname)
234                     res = pool.get('ir.translation')._get_source(cr, SUPERUSER_ID, None, ('code','sql_constraint'), lang, source)
235                 else:
236                     _logger.debug('no context cursor detected, skipping translation for "%r"', source)
237             else:
238                 _logger.debug('no translation language detected, skipping translation for "%r" ', source)
239         except Exception:
240             _logger.debug('translation went wrong for "%r", skipped', source)
241                 # if so, double-check the root/base translations filenames
242         finally:
243             if cr and is_new_cr:
244                 cr.close()
245         return res
246
247 _ = GettextAlias()
248
249
250 def quote(s):
251     """Returns quoted PO term string, with special PO characters escaped"""
252     assert r"\n" not in s, "Translation terms may not include escaped newlines ('\\n'), please use only literal newlines! (in '%s')" % s
253     return '"%s"' % s.replace('\\','\\\\') \
254                      .replace('"','\\"') \
255                      .replace('\n', '\\n"\n"')
256
257 re_escaped_char = re.compile(r"(\\.)")
258 re_escaped_replacements = {'n': '\n', }
259
260 def _sub_replacement(match_obj):
261     return re_escaped_replacements.get(match_obj.group(1)[1], match_obj.group(1)[1])
262
263 def unquote(str):
264     """Returns unquoted PO term string, with special PO characters unescaped"""
265     return re_escaped_char.sub(_sub_replacement, str[1:-1])
266
267 # class to handle po files
268 class TinyPoFile(object):
269     def __init__(self, buffer):
270         self.buffer = buffer
271
272     def warn(self, msg, *args):
273         _logger.warning(msg, *args)
274
275     def __iter__(self):
276         self.buffer.seek(0)
277         self.lines = self._get_lines()
278         self.lines_count = len(self.lines);
279
280         self.first = True
281         self.extra_lines= []
282         return self
283
284     def _get_lines(self):
285         lines = self.buffer.readlines()
286         # remove the BOM (Byte Order Mark):
287         if len(lines):
288             lines[0] = unicode(lines[0], 'utf8').lstrip(unicode( codecs.BOM_UTF8, "utf8"))
289
290         lines.append('') # ensure that the file ends with at least an empty line
291         return lines
292
293     def cur_line(self):
294         return (self.lines_count - len(self.lines))
295
296     def next(self):
297         trans_type = name = res_id = source = trad = None
298         if self.extra_lines:
299             trans_type, name, res_id, source, trad, comments = self.extra_lines.pop(0)
300             if not res_id:
301                 res_id = '0'
302         else:
303             comments = []
304             targets = []
305             line = None
306             fuzzy = False
307             while (not line):
308                 if 0 == len(self.lines):
309                     raise StopIteration()
310                 line = self.lines.pop(0).strip()
311             while line.startswith('#'):
312                 if line.startswith('#~ '):
313                     break
314                 if line.startswith('#.'):
315                     line = line[2:].strip()
316                     if not line.startswith('module:'):
317                         comments.append(line)
318                 elif line.startswith('#:'):
319                     for lpart in line[2:].strip().split(' '):
320                         trans_info = lpart.strip().split(':',2)
321                         if trans_info and len(trans_info) == 2:
322                             # looks like the translation trans_type is missing, which is not
323                             # unexpected because it is not a GetText standard. Default: 'code'
324                             trans_info[:0] = ['code']
325                         if trans_info and len(trans_info) == 3:
326                             # this is a ref line holding the destination info (model, field, record)
327                             targets.append(trans_info)
328                 elif line.startswith('#,') and (line[2:].strip() == 'fuzzy'):
329                     fuzzy = True
330                 line = self.lines.pop(0).strip()
331             while not line:
332                 # allow empty lines between comments and msgid
333                 line = self.lines.pop(0).strip()
334             if line.startswith('#~ '):
335                 while line.startswith('#~ ') or not line.strip():
336                     if 0 == len(self.lines):
337                         raise StopIteration()
338                     line = self.lines.pop(0)
339                 # This has been a deprecated entry, don't return anything
340                 return self.next()
341
342             if not line.startswith('msgid'):
343                 raise Exception("malformed file: bad line: %s" % line)
344             source = unquote(line[6:])
345             line = self.lines.pop(0).strip()
346             if not source and self.first:
347                 # if the source is "" and it's the first msgid, it's the special
348                 # msgstr with the informations about the traduction and the
349                 # traductor; we skip it
350                 self.extra_lines = []
351                 while line:
352                     line = self.lines.pop(0).strip()
353                 return self.next()
354
355             while not line.startswith('msgstr'):
356                 if not line:
357                     raise Exception('malformed file at %d'% self.cur_line())
358                 source += unquote(line)
359                 line = self.lines.pop(0).strip()
360
361             trad = unquote(line[7:])
362             line = self.lines.pop(0).strip()
363             while line:
364                 trad += unquote(line)
365                 line = self.lines.pop(0).strip()
366
367             if targets and not fuzzy:
368                 trans_type, name, res_id = targets.pop(0)
369                 for t, n, r in targets:
370                     if t == trans_type == 'code': continue
371                     self.extra_lines.append((t, n, r, source, trad, comments))
372
373         self.first = False
374
375         if name is None:
376             if not fuzzy:
377                 self.warn('Missing "#:" formated comment at line %d for the following source:\n\t%s',
378                         self.cur_line(), source[:30])
379             return self.next()
380         return trans_type, name, res_id, source, trad, '\n'.join(comments)
381
382     def write_infos(self, modules):
383         import openerp.release as release
384         self.buffer.write("# Translation of %(project)s.\n" \
385                           "# This file contains the translation of the following modules:\n" \
386                           "%(modules)s" \
387                           "#\n" \
388                           "msgid \"\"\n" \
389                           "msgstr \"\"\n" \
390                           '''"Project-Id-Version: %(project)s %(version)s\\n"\n''' \
391                           '''"Report-Msgid-Bugs-To: \\n"\n''' \
392                           '''"POT-Creation-Date: %(now)s\\n"\n'''        \
393                           '''"PO-Revision-Date: %(now)s\\n"\n'''         \
394                           '''"Last-Translator: <>\\n"\n''' \
395                           '''"Language-Team: \\n"\n'''   \
396                           '''"MIME-Version: 1.0\\n"\n''' \
397                           '''"Content-Type: text/plain; charset=UTF-8\\n"\n'''   \
398                           '''"Content-Transfer-Encoding: \\n"\n'''       \
399                           '''"Plural-Forms: \\n"\n'''    \
400                           "\n"
401
402                           % { 'project': release.description,
403                               'version': release.version,
404                               'modules': reduce(lambda s, m: s + "#\t* %s\n" % m, modules, ""),
405                               'now': datetime.utcnow().strftime('%Y-%m-%d %H:%M')+"+0000",
406                             }
407                           )
408
409     def write(self, modules, tnrs, source, trad, comments=None):
410
411         plurial = len(modules) > 1 and 's' or ''
412         self.buffer.write("#. module%s: %s\n" % (plurial, ', '.join(modules)))
413
414         if comments:
415             self.buffer.write(''.join(('#. %s\n' % c for c in comments)))
416
417         code = False
418         for typy, name, res_id in tnrs:
419             self.buffer.write("#: %s:%s:%s\n" % (typy, name, res_id))
420             if typy == 'code':
421                 code = True
422
423         if code:
424             # only strings in python code are python formated
425             self.buffer.write("#, python-format\n")
426
427         if not isinstance(trad, unicode):
428             trad = unicode(trad, 'utf8')
429         if not isinstance(source, unicode):
430             source = unicode(source, 'utf8')
431
432         msg = "msgid %s\n"      \
433               "msgstr %s\n\n"   \
434                   % (quote(source), quote(trad))
435         self.buffer.write(msg.encode('utf8'))
436
437
438 # Methods to export the translation file
439
440 def trans_export(lang, modules, buffer, format, cr):
441
442     def _process(format, modules, rows, buffer, lang):
443         if format == 'csv':
444             writer = csv.writer(buffer, 'UNIX')
445             # write header first
446             writer.writerow(("module","type","name","res_id","src","value"))
447             for module, type, name, res_id, src, trad, comments in rows:
448                 # Comments are ignored by the CSV writer
449                 writer.writerow((module, type, name, res_id, src, trad))
450         elif format == 'po':
451             writer = TinyPoFile(buffer)
452             writer.write_infos(modules)
453
454             # we now group the translations by source. That means one translation per source.
455             grouped_rows = {}
456             for module, type, name, res_id, src, trad, comments in rows:
457                 row = grouped_rows.setdefault(src, {})
458                 row.setdefault('modules', set()).add(module)
459                 if not row.get('translation') and trad != src:
460                     row['translation'] = trad
461                 row.setdefault('tnrs', []).append((type, name, res_id))
462                 row.setdefault('comments', set()).update(comments)
463
464             for src, row in grouped_rows.items():
465                 if not lang:
466                     # translation template, so no translation value
467                     row['translation'] = ''
468                 elif not row.get('translation'):
469                     row['translation'] = src
470                 writer.write(row['modules'], row['tnrs'], src, row['translation'], row['comments'])
471
472         elif format == 'tgz':
473             rows_by_module = {}
474             for row in rows:
475                 module = row[0]
476                 rows_by_module.setdefault(module, []).append(row)
477             tmpdir = tempfile.mkdtemp()
478             for mod, modrows in rows_by_module.items():
479                 tmpmoddir = join(tmpdir, mod, 'i18n')
480                 os.makedirs(tmpmoddir)
481                 pofilename = (lang if lang else mod) + ".po" + ('t' if not lang else '')
482                 buf = file(join(tmpmoddir, pofilename), 'w')
483                 _process('po', [mod], modrows, buf, lang)
484                 buf.close()
485
486             tar = tarfile.open(fileobj=buffer, mode='w|gz')
487             tar.add(tmpdir, '')
488             tar.close()
489
490         else:
491             raise Exception(_('Unrecognized extension: must be one of '
492                 '.csv, .po, or .tgz (received .%s).' % format))
493
494     trans_lang = lang
495     if not trans_lang and format == 'csv':
496         # CSV files are meant for translators and they need a starting point,
497         # so we at least put the original term in the translation column
498         trans_lang = 'en_US'
499     translations = trans_generate(lang, modules, cr)
500     modules = set([t[0] for t in translations[1:]])
501     _process(format, modules, translations, buffer, lang)
502     del translations
503
504 def trans_parse_xsl(de):
505     return list(set(trans_parse_xsl_aux(de, False)))
506
507 def trans_parse_xsl_aux(de, t):
508     res = []
509
510     for n in de:
511         t = t or n.get("t")
512         if t:
513                 if isinstance(n, SKIPPED_ELEMENT_TYPES) or n.tag.startswith('{http://www.w3.org/1999/XSL/Transform}'):
514                     continue
515                 if n.text:
516                     l = n.text.strip().replace('\n',' ')
517                     if len(l):
518                         res.append(l.encode("utf8"))
519                 if n.tail:
520                     l = n.tail.strip().replace('\n',' ')
521                     if len(l):
522                         res.append(l.encode("utf8"))
523         res.extend(trans_parse_xsl_aux(n, t))
524     return res
525
526 def trans_parse_rml(de):
527     res = []
528     for n in de:
529         for m in n:
530             if isinstance(m, SKIPPED_ELEMENT_TYPES) or not m.text:
531                 continue
532             string_list = [s.replace('\n', ' ').strip() for s in re.split('\[\[.+?\]\]', m.text)]
533             for s in string_list:
534                 if s:
535                     res.append(s.encode("utf8"))
536         res.extend(trans_parse_rml(n))
537     return res
538
539 def trans_parse_view(de):
540     res = []
541     if not isinstance(de, SKIPPED_ELEMENT_TYPES) and de.text and de.text.strip():
542         res.append(de.text.strip().encode("utf8"))
543     if de.tail and de.tail.strip():
544         res.append(de.tail.strip().encode("utf8"))
545     if de.tag == 'attribute' and de.get("name") == 'string':
546         if de.text:
547             res.append(de.text.encode("utf8"))
548     if de.get("string"):
549         res.append(de.get('string').encode("utf8"))
550     if de.get("help"):
551         res.append(de.get('help').encode("utf8"))
552     if de.get("sum"):
553         res.append(de.get('sum').encode("utf8"))
554     if de.get("confirm"):
555         res.append(de.get('confirm').encode("utf8"))
556     for n in de:
557         res.extend(trans_parse_view(n))
558     return res
559
560 # tests whether an object is in a list of modules
561 def in_modules(object_name, modules):
562     if 'all' in modules:
563         return True
564
565     module_dict = {
566         'ir': 'base',
567         'res': 'base',
568         'workflow': 'base',
569     }
570     module = object_name.split('.')[0]
571     module = module_dict.get(module, module)
572     return module in modules
573
574
575 def babel_extract_qweb(fileobj, keywords, comment_tags, options):
576     """Babel message extractor for qweb template files.
577     :param fileobj: the file-like object the messages should be extracted from
578     :param keywords: a list of keywords (i.e. function names) that should
579                      be recognized as translation functions
580     :param comment_tags: a list of translator tags to search for and
581                          include in the results
582     :param options: a dictionary of additional options (optional)
583     :return: an iterator over ``(lineno, funcname, message, comments)``
584              tuples
585     :rtype: ``iterator``
586     """
587     result = []
588     def handle_text(text, lineno):
589         text = (text or "").strip()
590         if len(text) > 1: # Avoid mono-char tokens like ':' ',' etc.
591             result.append((lineno, None, text, []))
592
593     # not using elementTree.iterparse because we need to skip sub-trees in case
594     # the ancestor element had a reason to be skipped
595     def iter_elements(current_element):
596         for el in current_element:
597             if isinstance(el, SKIPPED_ELEMENT_TYPES): continue
598             if "t-js" not in el.attrib and \
599                     not ("t-jquery" in el.attrib and "t-operation" not in el.attrib) and \
600                     not ("t-translation" in el.attrib and el.attrib["t-translation"].strip() == "off"):
601                 handle_text(el.text, el.sourceline)
602                 for att in ('title', 'alt', 'label', 'placeholder'):
603                     if att in el.attrib:
604                         handle_text(el.attrib[att], el.sourceline)
605                 iter_elements(el)
606             handle_text(el.tail, el.sourceline)
607
608     tree = etree.parse(fileobj)
609     iter_elements(tree.getroot())
610
611     return result
612
613
614 def trans_generate(lang, modules, cr):
615     dbname = cr.dbname
616
617     pool = pooler.get_pool(dbname)
618     trans_obj = pool.get('ir.translation')
619     model_data_obj = pool.get('ir.model.data')
620     uid = 1
621     l = pool.models.items()
622     l.sort()
623
624     query = 'SELECT name, model, res_id, module'    \
625             '  FROM ir_model_data'
626
627     query_models = """SELECT m.id, m.model, imd.module
628             FROM ir_model AS m, ir_model_data AS imd
629             WHERE m.id = imd.res_id AND imd.model = 'ir.model' """
630
631     if 'all_installed' in modules:
632         query += ' WHERE module IN ( SELECT name FROM ir_module_module WHERE state = \'installed\') '
633         query_models += " AND imd.module in ( SELECT name FROM ir_module_module WHERE state = 'installed') "
634     query_param = None
635     if 'all' not in modules:
636         query += ' WHERE module IN %s'
637         query_models += ' AND imd.module in %s'
638         query_param = (tuple(modules),)
639     query += ' ORDER BY module, model, name'
640     query_models += ' ORDER BY module, model'
641
642     cr.execute(query, query_param)
643
644     _to_translate = []
645     def push_translation(module, type, name, id, source, comments=None):
646         tuple = (module, source, name, id, type, comments or [])
647         # empty and one-letter terms are ignored, they probably are not meant to be
648         # translated, and would be very hard to translate anyway.
649         if not source or len(source.strip()) <= 1:
650             _logger.debug("Ignoring empty or 1-letter source term: %r", tuple)
651             return
652         if tuple not in _to_translate:
653             _to_translate.append(tuple)
654
655     def encode(s):
656         if isinstance(s, unicode):
657             return s.encode('utf8')
658         return s
659
660     for (xml_name,model,res_id,module) in cr.fetchall():
661         module = encode(module)
662         model = encode(model)
663         xml_name = "%s.%s" % (module, encode(xml_name))
664
665         if not pool.get(model):
666             _logger.error("Unable to find object %r", model)
667             continue
668
669         exists = pool.get(model).exists(cr, uid, res_id)
670         if not exists:
671             _logger.warning("Unable to find object %r with id %d", model, res_id)
672             continue
673         obj = pool.get(model).browse(cr, uid, res_id)
674
675         if model=='ir.ui.view':
676             d = etree.XML(encode(obj.arch))
677             for t in trans_parse_view(d):
678                 push_translation(module, 'view', encode(obj.model), 0, t)
679         elif model=='ir.actions.wizard':
680             service_name = 'wizard.'+encode(obj.wiz_name)
681             import openerp.netsvc as netsvc
682             if netsvc.Service._services.get(service_name):
683                 obj2 = netsvc.Service._services[service_name]
684                 for state_name, state_def in obj2.states.iteritems():
685                     if 'result' in state_def:
686                         result = state_def['result']
687                         if result['type'] != 'form':
688                             continue
689                         name = "%s,%s" % (encode(obj.wiz_name), state_name)
690
691                         def_params = {
692                             'string': ('wizard_field', lambda s: [encode(s)]),
693                             'selection': ('selection', lambda s: [encode(e[1]) for e in ((not callable(s)) and s or [])]),
694                             'help': ('help', lambda s: [encode(s)]),
695                         }
696
697                         # export fields
698                         if not result.has_key('fields'):
699                             _logger.warning("res has no fields: %r", result)
700                             continue
701                         for field_name, field_def in result['fields'].iteritems():
702                             res_name = name + ',' + field_name
703
704                             for fn in def_params:
705                                 if fn in field_def:
706                                     transtype, modifier = def_params[fn]
707                                     for val in modifier(field_def[fn]):
708                                         push_translation(module, transtype, res_name, 0, val)
709
710                         # export arch
711                         arch = result['arch']
712                         if arch and not isinstance(arch, UpdateableStr):
713                             d = etree.XML(arch)
714                             for t in trans_parse_view(d):
715                                 push_translation(module, 'wizard_view', name, 0, t)
716
717                         # export button labels
718                         for but_args in result['state']:
719                             button_name = but_args[0]
720                             button_label = but_args[1]
721                             res_name = name + ',' + button_name
722                             push_translation(module, 'wizard_button', res_name, 0, button_label)
723
724         elif model=='ir.model.fields':
725             try:
726                 field_name = encode(obj.name)
727             except AttributeError, exc:
728                 _logger.error("name error in %s: %s", xml_name, str(exc))
729                 continue
730             objmodel = pool.get(obj.model)
731             if not objmodel or not field_name in objmodel._columns:
732                 continue
733             field_def = objmodel._columns[field_name]
734
735             name = "%s,%s" % (encode(obj.model), field_name)
736             push_translation(module, 'field', name, 0, encode(field_def.string))
737
738             if field_def.help:
739                 push_translation(module, 'help', name, 0, encode(field_def.help))
740
741             if field_def.translate:
742                 ids = objmodel.search(cr, uid, [])
743                 obj_values = objmodel.read(cr, uid, ids, [field_name])
744                 for obj_value in obj_values:
745                     res_id = obj_value['id']
746                     if obj.name in ('ir.model', 'ir.ui.menu'):
747                         res_id = 0
748                     model_data_ids = model_data_obj.search(cr, uid, [
749                         ('model', '=', model),
750                         ('res_id', '=', res_id),
751                         ])
752                     if not model_data_ids:
753                         push_translation(module, 'model', name, 0, encode(obj_value[field_name]))
754
755             if hasattr(field_def, 'selection') and isinstance(field_def.selection, (list, tuple)):
756                 for dummy, val in field_def.selection:
757                     push_translation(module, 'selection', name, 0, encode(val))
758
759         elif model=='ir.actions.report.xml':
760             name = encode(obj.report_name)
761             fname = ""
762             if obj.report_rml:
763                 fname = obj.report_rml
764                 parse_func = trans_parse_rml
765                 report_type = "report"
766             elif obj.report_xsl:
767                 fname = obj.report_xsl
768                 parse_func = trans_parse_xsl
769                 report_type = "xsl"
770             if fname and obj.report_type in ('pdf', 'xsl'):
771                 try:
772                     report_file = misc.file_open(fname)
773                     try:
774                         d = etree.parse(report_file)
775                         for t in parse_func(d.iter()):
776                             push_translation(module, report_type, name, 0, t)
777                     finally:
778                         report_file.close()
779                 except (IOError, etree.XMLSyntaxError):
780                     _logger.exception("couldn't export translation for report %s %s %s", name, report_type, fname)
781
782         for field_name,field_def in obj._table._columns.items():
783             if field_def.translate:
784                 name = model + "," + field_name
785                 try:
786                     trad = getattr(obj, field_name) or ''
787                 except:
788                     trad = ''
789                 push_translation(module, 'model', name, xml_name, encode(trad))
790
791         # End of data for ir.model.data query results
792
793     cr.execute(query_models, query_param)
794
795     def push_constraint_msg(module, term_type, model, msg):
796         # Check presence of __call__ directly instead of using
797         # callable() because it will be deprecated as of Python 3.0
798         if not hasattr(msg, '__call__'):
799             push_translation(module, term_type, model, 0, encode(msg))
800
801     for (_, model, module) in cr.fetchall():
802         module = encode(module)
803         model = encode(model)
804
805         model_obj = pool.get(model)
806
807         if not model_obj:
808             _logger.error("Unable to find object %r", model)
809             continue
810
811         for constraint in getattr(model_obj, '_constraints', []):
812             push_constraint_msg(module, 'constraint', model, constraint[1])
813
814         for constraint in getattr(model_obj, '_sql_constraints', []):
815             push_constraint_msg(module, 'sql_constraint', model, constraint[2])
816
817     def get_module_from_path(path, mod_paths=None):
818         if not mod_paths:
819             # First, construct a list of possible paths
820             def_path = os.path.abspath(os.path.join(config.config['root_path'], 'addons'))     # default addons path (base)
821             ad_paths= map(lambda m: os.path.abspath(m.strip()),config.config['addons_path'].split(','))
822             mod_paths=[def_path]
823             for adp in ad_paths:
824                 mod_paths.append(adp)
825                 if not os.path.isabs(adp):
826                     mod_paths.append(adp)
827                 elif adp.startswith(def_path):
828                     mod_paths.append(adp[len(def_path)+1:])
829         for mp in mod_paths:
830             if path.startswith(mp) and (os.path.dirname(path) != mp):
831                 path = path[len(mp)+1:]
832                 return path.split(os.path.sep)[0]
833         return 'base'   # files that are not in a module are considered as being in 'base' module
834
835     modobj = pool.get('ir.module.module')
836     installed_modids = modobj.search(cr, uid, [('state', '=', 'installed')])
837     installed_modules = map(lambda m: m['name'], modobj.read(cr, uid, installed_modids, ['name']))
838
839     root_path = os.path.join(config.config['root_path'], 'addons')
840
841     apaths = map(os.path.abspath, map(str.strip, config.config['addons_path'].split(',')))
842     if root_path in apaths:
843         path_list = apaths
844     else :
845         path_list = [root_path,] + apaths
846
847     # Also scan these non-addon paths
848     for bin_path in ['osv', 'report' ]:
849         path_list.append(os.path.join(config.config['root_path'], bin_path))
850
851     _logger.debug("Scanning modules at paths: ", path_list)
852
853     mod_paths = []
854
855     def verified_module_filepaths(fname, path, root):
856         fabsolutepath = join(root, fname)
857         frelativepath = fabsolutepath[len(path):]
858         display_path = "addons%s" % frelativepath
859         module = get_module_from_path(fabsolutepath, mod_paths=mod_paths)
860         if (('all' in modules) or (module in modules)) and module in installed_modules:
861             return module, fabsolutepath, frelativepath, display_path
862         return None, None, None, None
863
864     def babel_extract_terms(fname, path, root, extract_method="python", trans_type='code',
865                                extra_comments=None, extract_keywords={'_': None}):
866         module, fabsolutepath, _, display_path = verified_module_filepaths(fname, path, root)
867         extra_comments = extra_comments or []
868         if module:
869             src_file = open(fabsolutepath, 'r')
870             try:
871                 for lineno, message, comments in extract.extract(extract_method, src_file,
872                                                                  keywords=extract_keywords):
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             for fname in fnmatch.filter(files, '*.mako'):
886                 babel_extract_terms(fname, path, root, trans_type='report')
887             # Javascript source files in the static/src/js directory, rest is ignored (libs)
888             if fnmatch.fnmatch(root, '*/static/src/js*'):
889                 for fname in fnmatch.filter(files, '*.js'):
890                     babel_extract_terms(fname, path, root, 'javascript',
891                                         extra_comments=[WEB_TRANSLATION_COMMENT],
892                                         extract_keywords={'_t': None, '_lt': None})
893             # QWeb template files
894             if fnmatch.fnmatch(root, '*/static/src/xml*'):
895                 for fname in fnmatch.filter(files, '*.xml'):
896                     babel_extract_terms(fname, path, root, 'openerp.tools.translate:babel_extract_qweb',
897                                         extra_comments=[WEB_TRANSLATION_COMMENT])
898
899     out = []
900     _to_translate.sort()
901     # translate strings marked as to be translated
902     for module, source, name, id, type, comments in _to_translate:
903         trans = '' if not lang else trans_obj._get_source(cr, uid, name, type, lang, source)
904         out.append([module, type, name, id, source, encode(trans) or '', comments])
905     return out
906
907 def trans_load(cr, filename, lang, verbose=True, module_name=None, context=None):
908     try:
909         fileobj = misc.file_open(filename)
910         _logger.info("loading %s", filename)
911         fileformat = os.path.splitext(filename)[-1][1:].lower()
912         result = trans_load_data(cr, fileobj, fileformat, lang, verbose=verbose, module_name=module_name, context=context)
913         fileobj.close()
914         return result
915     except IOError:
916         if verbose:
917             _logger.error("couldn't read translation file %s", filename)
918         return None
919
920 def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, module_name=None, context=None):
921     """Populates the ir_translation table."""
922     if verbose:
923         _logger.info('loading translation file for language %s', lang)
924     if context is None:
925         context = {}
926     db_name = cr.dbname
927     pool = pooler.get_pool(db_name)
928     lang_obj = pool.get('res.lang')
929     trans_obj = pool.get('ir.translation')
930     iso_lang = misc.get_iso_codes(lang)
931     try:
932         ids = lang_obj.search(cr, SUPERUSER_ID, [('code','=', lang)])
933
934         if not ids:
935             # lets create the language with locale information
936             lang_obj.load_lang(cr, SUPERUSER_ID, lang=lang, lang_name=lang_name)
937
938
939         # now, the serious things: we read the language file
940         fileobj.seek(0)
941         if fileformat == 'csv':
942             reader = csv.reader(fileobj, quotechar='"', delimiter=',')
943             # read the first line of the file (it contains columns titles)
944             for row in reader:
945                 f = row
946                 break
947         elif fileformat == 'po':
948             reader = TinyPoFile(fileobj)
949             f = ['type', 'name', 'res_id', 'src', 'value', 'comments']
950         else:
951             _logger.error('Bad file format: %s', fileformat)
952             raise Exception(_('Bad file format'))
953
954         # read the rest of the file
955         line = 1
956         irt_cursor = trans_obj._get_import_cursor(cr, SUPERUSER_ID, context=context)
957
958         for row in reader:
959             line += 1
960             # skip empty rows and rows where the translation field (=last fiefd) is empty
961             #if (not row) or (not row[-1]):
962             #    continue
963
964             # dictionary which holds values for this line of the csv file
965             # {'lang': ..., 'type': ..., 'name': ..., 'res_id': ...,
966             #  'src': ..., 'value': ..., 'module':...}
967             dic = dict.fromkeys(('name', 'res_id', 'src', 'type', 'imd_model', 'imd_name', 'module', 'value', 'comments'))
968             dic['lang'] = lang
969             for i, field in enumerate(f):
970                 dic[field] = row[i]
971
972             # This would skip terms that fail to specify a res_id
973             if not dic.get('res_id'):
974                 continue
975
976             res_id = dic.pop('res_id')
977             if res_id and isinstance(res_id, (int, long)) \
978                 or (isinstance(res_id, basestring) and res_id.isdigit()):
979                     dic['res_id'] = int(res_id)
980                     dic['module'] = module_name
981             else:
982                 tmodel = dic['name'].split(',')[0]
983                 if '.' in res_id:
984                     tmodule, tname = res_id.split('.', 1)
985                 else:
986                     tmodule = False
987                     tname = res_id
988                 dic['imd_model'] = tmodel
989                 dic['imd_name'] =  tname
990                 dic['module'] = tmodule
991                 dic['res_id'] = None
992
993             irt_cursor.push(dic)
994
995         irt_cursor.finish()
996         if verbose:
997             _logger.info("translation file loaded succesfully")
998     except IOError:
999         filename = '[lang: %s][format: %s]' % (iso_lang or 'new', fileformat)
1000         _logger.exception("couldn't read translation file %s", filename)
1001
1002 def get_locales(lang=None):
1003     if lang is None:
1004         lang = locale.getdefaultlocale()[0]
1005
1006     if os.name == 'nt':
1007         lang = _LOCALE2WIN32.get(lang, lang)
1008
1009     def process(enc):
1010         ln = locale._build_localename((lang, enc))
1011         yield ln
1012         nln = locale.normalize(ln)
1013         if nln != ln:
1014             yield nln
1015
1016     for x in process('utf8'): yield x
1017
1018     prefenc = locale.getpreferredencoding()
1019     if prefenc:
1020         for x in process(prefenc): yield x
1021
1022         prefenc = {
1023             'latin1': 'latin9',
1024             'iso-8859-1': 'iso8859-15',
1025             'cp1252': '1252',
1026         }.get(prefenc.lower())
1027         if prefenc:
1028             for x in process(prefenc): yield x
1029
1030     yield lang
1031
1032
1033
1034 def resetlocale():
1035     # locale.resetlocale is bugged with some locales.
1036     for ln in get_locales():
1037         try:
1038             return locale.setlocale(locale.LC_ALL, ln)
1039         except locale.Error:
1040             continue
1041
1042 def load_language(cr, lang):
1043     """Loads a translation terms for a language.
1044     Used mainly to automate language loading at db initialization.
1045
1046     :param lang: language ISO code with optional _underscore_ and l10n flavor (ex: 'fr', 'fr_BE', but not 'fr-BE')
1047     :type lang: str
1048     """
1049     pool = pooler.get_pool(cr.dbname)
1050     language_installer = pool.get('base.language.install')
1051     uid = 1
1052     oid = language_installer.create(cr, uid, {'lang': lang})
1053     language_installer.lang_install(cr, uid, [oid], context=None)
1054
1055 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
1056