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