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