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