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