[FIX]:loading translation of .zip module
[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: \\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                               'now': datetime.utcnow().strftime('%Y-%m-%d %H:%M')+"+0000",
381                             }
382                           )
383
384     def write(self, modules, tnrs, source, trad):
385
386         plurial = len(modules) > 1 and 's' or ''
387         self.buffer.write("#. module%s: %s\n" % (plurial, ', '.join(modules)))
388
389
390         code = False
391         for typy, name, res_id in tnrs:
392             self.buffer.write("#: %s:%s:%s\n" % (typy, name, res_id))
393             if typy == 'code':
394                 code = True
395
396         if code:
397             # only strings in python code are python formated
398             self.buffer.write("#, python-format\n")
399
400         if not isinstance(trad, unicode):
401             trad = unicode(trad, 'utf8')
402         if not isinstance(source, unicode):
403             source = unicode(source, 'utf8')
404
405         msg = "msgid %s\n"      \
406               "msgstr %s\n\n"   \
407                   % (quote(source), quote(trad))
408         self.buffer.write(msg.encode('utf8'))
409
410
411 # Methods to export the translation file
412
413 def trans_export(lang, modules, buffer, format, cr):
414
415     def _process(format, modules, rows, buffer, lang, newlang):
416         if format == 'csv':
417             writer=csv.writer(buffer, 'UNIX')
418             for row in rows:
419                 writer.writerow(row)
420         elif format == 'po':
421             rows.pop(0)
422             writer = TinyPoFile(buffer)
423             writer.write_infos(modules)
424
425             # we now group the translations by source. That means one translation per source.
426             grouped_rows = {}
427             for module, type, name, res_id, src, trad in rows:
428                 row = grouped_rows.setdefault(src, {})
429                 row.setdefault('modules', set()).add(module)
430                 if ('translation' not in row) or (not row['translation']):
431                     row['translation'] = trad
432                 row.setdefault('tnrs', []).append((type, name, res_id))
433
434             for src, row in grouped_rows.items():
435                 writer.write(row['modules'], row['tnrs'], src, row['translation'])
436
437         elif format == 'tgz':
438             rows.pop(0)
439             rows_by_module = {}
440             for row in rows:
441                 module = row[0]
442                 # first row is the "header", as in csv, it will be popped
443                 rows_by_module.setdefault(module, [['module', 'type', 'name', 'res_id', 'src', ''],])
444                 rows_by_module[module].append(row)
445
446             tmpdir = tempfile.mkdtemp()
447             for mod, modrows in rows_by_module.items():
448                 tmpmoddir = join(tmpdir, mod, 'i18n')
449                 os.makedirs(tmpmoddir)
450                 pofilename = (newlang and mod or lang) + ".po" + (newlang and 't' or '')
451                 buf = file(join(tmpmoddir, pofilename), 'w')
452                 _process('po', [mod], modrows, buf, lang, newlang)
453                 buf.close()
454
455             tar = tarfile.open(fileobj=buffer, mode='w|gz')
456             tar.add(tmpdir, '')
457             tar.close()
458
459         else:
460             raise Exception(_('Unrecognized extension: must be one of '
461                 '.csv, .po, or .tgz (received .%s).' % format))
462
463     newlang = not bool(lang)
464     if newlang:
465         lang = 'en_US'
466     trans = trans_generate(lang, modules, cr)
467     if newlang and format!='csv':
468         for trx in trans:
469             trx[-1] = ''
470     modules = set([t[0] for t in trans[1:]])
471     _process(format, modules, trans, buffer, lang, newlang)
472     del trans
473
474 def trans_parse_xsl(de):
475     res = []
476     for n in de:
477         if n.get("t"):
478             for m in n:
479                 if isinstance(m, SKIPPED_ELEMENT_TYPES) or not m.text:
480                     continue
481                 l = m.text.strip().replace('\n',' ')
482                 if len(l):
483                     res.append(l.encode("utf8"))
484         res.extend(trans_parse_xsl(n))
485     return res
486
487 def trans_parse_rml(de):
488     res = []
489     for n in de:
490         for m in n:
491             if isinstance(m, SKIPPED_ELEMENT_TYPES) or not m.text:
492                 continue
493             string_list = [s.replace('\n', ' ').strip() for s in re.split('\[\[.+?\]\]', m.text)]
494             for s in string_list:
495                 if s:
496                     res.append(s.encode("utf8"))
497         res.extend(trans_parse_rml(n))
498     return res
499
500 def trans_parse_view(de):
501     res = []
502     if de.tag == 'attribute' and de.get("name") == 'string':
503         if de.text:
504             res.append(de.text.encode("utf8"))
505     if de.get("string"):
506         res.append(de.get('string').encode("utf8"))
507     if de.get("help"):
508         res.append(de.get('help').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.models.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             import openerp.netsvc as netsvc
595             if netsvc.Service._services.get(service_name):
596                 obj2 = netsvc.Service._services[service_name]
597                 for state_name, state_def in obj2.states.iteritems():
598                     if 'result' in state_def:
599                         result = state_def['result']
600                         if result['type'] != 'form':
601                             continue
602                         name = "%s,%s" % (encode(obj.wiz_name), state_name)
603
604                         def_params = {
605                             'string': ('wizard_field', lambda s: [encode(s)]),
606                             'selection': ('selection', lambda s: [encode(e[1]) for e in ((not callable(s)) and s or [])]),
607                             'help': ('help', lambda s: [encode(s)]),
608                         }
609
610                         # export fields
611                         if not result.has_key('fields'):
612                             logger.warning("res has no fields: %r", result)
613                             continue
614                         for field_name, field_def in result['fields'].iteritems():
615                             res_name = name + ',' + field_name
616
617                             for fn in def_params:
618                                 if fn in field_def:
619                                     transtype, modifier = def_params[fn]
620                                     for val in modifier(field_def[fn]):
621                                         push_translation(module, transtype, res_name, 0, val)
622
623                         # export arch
624                         arch = result['arch']
625                         if arch and not isinstance(arch, UpdateableStr):
626                             d = etree.XML(arch)
627                             for t in trans_parse_view(d):
628                                 push_translation(module, 'wizard_view', name, 0, t)
629
630                         # export button labels
631                         for but_args in result['state']:
632                             button_name = but_args[0]
633                             button_label = but_args[1]
634                             res_name = name + ',' + button_name
635                             push_translation(module, 'wizard_button', res_name, 0, button_label)
636
637         elif model=='ir.model.fields':
638             try:
639                 field_name = encode(obj.name)
640             except AttributeError, exc:
641                 logger.error("name error in %s: %s", xml_name, str(exc))
642                 continue
643             objmodel = pool.get(obj.model)
644             if not objmodel or not field_name in objmodel._columns:
645                 continue
646             field_def = objmodel._columns[field_name]
647
648             name = "%s,%s" % (encode(obj.model), field_name)
649             push_translation(module, 'field', name, 0, encode(field_def.string))
650
651             if field_def.help:
652                 push_translation(module, 'help', name, 0, encode(field_def.help))
653
654             if field_def.translate:
655                 ids = objmodel.search(cr, uid, [])
656                 obj_values = objmodel.read(cr, uid, ids, [field_name])
657                 for obj_value in obj_values:
658                     res_id = obj_value['id']
659                     if obj.name in ('ir.model', 'ir.ui.menu'):
660                         res_id = 0
661                     model_data_ids = model_data_obj.search(cr, uid, [
662                         ('model', '=', model),
663                         ('res_id', '=', res_id),
664                         ])
665                     if not model_data_ids:
666                         push_translation(module, 'model', name, 0, encode(obj_value[field_name]))
667
668             if hasattr(field_def, 'selection') and isinstance(field_def.selection, (list, tuple)):
669                 for dummy, val in field_def.selection:
670                     push_translation(module, 'selection', name, 0, encode(val))
671
672         elif model=='ir.actions.report.xml':
673             name = encode(obj.report_name)
674             fname = ""
675             if obj.report_rml:
676                 fname = obj.report_rml
677                 parse_func = trans_parse_rml
678                 report_type = "report"
679             elif obj.report_xsl:
680                 fname = obj.report_xsl
681                 parse_func = trans_parse_xsl
682                 report_type = "xsl"
683             if fname and obj.report_type in ('pdf', 'xsl'):
684                 try:
685                     report_file = misc.file_open(fname)
686                     try:
687                         d = etree.parse(report_file)
688                         for t in parse_func(d.iter()):
689                             push_translation(module, report_type, name, 0, t)
690                     finally:
691                         report_file.close()
692                 except (IOError, etree.XMLSyntaxError):
693                     logger.exception("couldn't export translation for report %s %s %s", name, report_type, fname)
694
695         for field_name,field_def in obj._table._columns.items():
696             if field_def.translate:
697                 name = model + "," + field_name
698                 try:
699                     trad = getattr(obj, field_name) or ''
700                 except:
701                     trad = ''
702                 push_translation(module, 'model', name, xml_name, encode(trad))
703
704         # End of data for ir.model.data query results
705
706     cr.execute(query_models, query_param)
707
708     def push_constraint_msg(module, term_type, model, msg):
709         # Check presence of __call__ directly instead of using
710         # callable() because it will be deprecated as of Python 3.0
711         if not hasattr(msg, '__call__'):
712             push_translation(module, term_type, model, 0, encode(msg))
713
714     for (model_id, model, module) in cr.fetchall():
715         module = encode(module)
716         model = encode(model)
717
718         model_obj = pool.get(model)
719
720         if not model_obj:
721             logging.getLogger("i18n").error("Unable to find object %r", model)
722             continue
723
724         for constraint in getattr(model_obj, '_constraints', []):
725             push_constraint_msg(module, 'constraint', model, constraint[1])
726
727         for constraint in getattr(model_obj, '_sql_constraints', []):
728             push_constraint_msg(module, 'sql_constraint', model, constraint[2])
729
730     # parse source code for _() calls
731     def get_module_from_path(path, mod_paths=None):
732         if not mod_paths:
733             # First, construct a list of possible paths
734             def_path = os.path.abspath(os.path.join(config.config['root_path'], 'addons'))     # default addons path (base)
735             ad_paths= map(lambda m: os.path.abspath(m.strip()),config.config['addons_path'].split(','))
736             mod_paths=[def_path]
737             for adp in ad_paths:
738                 mod_paths.append(adp)
739                 if not os.path.isabs(adp):
740                     mod_paths.append(adp)
741                 elif adp.startswith(def_path):
742                     mod_paths.append(adp[len(def_path)+1:])
743         for mp in mod_paths:
744             if path.startswith(mp) and (os.path.dirname(path) != mp):
745                 path = path[len(mp)+1:]
746                 return path.split(os.path.sep)[0]
747         return 'base'   # files that are not in a module are considered as being in 'base' module
748
749     modobj = pool.get('ir.module.module')
750     installed_modids = modobj.search(cr, uid, [('state', '=', 'installed')])
751     installed_modules = map(lambda m: m['name'], modobj.read(cr, uid, installed_modids, ['name']))
752
753     root_path = os.path.join(config.config['root_path'], 'addons')
754
755     apaths = map(os.path.abspath, map(str.strip, config.config['addons_path'].split(',')))
756     if root_path in apaths:
757         path_list = apaths
758     else :
759         path_list = [root_path,] + apaths
760
761     # Also scan these non-addon paths
762     for bin_path in ['osv', 'report' ]:
763         path_list.append(os.path.join(config.config['root_path'], bin_path))
764
765     logger.debug("Scanning modules at paths: ", path_list)
766
767     mod_paths = []
768     join_dquotes = re.compile(r'([^\\])"[\s\\]*"', re.DOTALL)
769     join_quotes = re.compile(r'([^\\])\'[\s\\]*\'', re.DOTALL)
770     re_dquotes = re.compile(r'[^a-zA-Z0-9_]_\([\s]*"(.+?)"[\s]*?\)', re.DOTALL)
771     re_quotes = re.compile(r'[^a-zA-Z0-9_]_\([\s]*\'(.+?)\'[\s]*?\)', re.DOTALL)
772
773     def export_code_terms_from_file(fname, path, root, terms_type):
774         fabsolutepath = join(root, fname)
775         frelativepath = fabsolutepath[len(path):]
776         module = get_module_from_path(fabsolutepath, mod_paths=mod_paths)
777         is_mod_installed = module in installed_modules
778         if (('all' in modules) or (module in modules)) and is_mod_installed:
779             logger.debug("Scanning code of %s at module: %s", frelativepath, module)
780             src_file = misc.file_open(fabsolutepath, subdir='')
781             try:
782                 code_string = src_file.read()
783             finally:
784                 src_file.close()
785             if module in installed_modules:
786                 frelativepath = str("addons" + frelativepath)
787             ite = re_dquotes.finditer(code_string)
788             code_offset = 0
789             code_line = 1
790             for i in ite:
791                 src = i.group(1)
792                 if src.startswith('""'):
793                     assert src.endswith('""'), "Incorrect usage of _(..) function (should contain only literal strings!) in file %s near: %s" % (frelativepath, src[:30])
794                     src = src[2:-2]
795                 else:
796                     src = join_dquotes.sub(r'\1', src)
797                 # try to count the lines from the last pos to our place:
798                 code_line += code_string[code_offset:i.start(1)].count('\n')
799                 # now, since we did a binary read of a python source file, we
800                 # have to expand pythonic escapes like the interpreter does.
801                 src = src.decode('string_escape')
802                 push_translation(module, terms_type, frelativepath, code_line, encode(src))
803                 code_line += i.group(1).count('\n')
804                 code_offset = i.end() # we have counted newlines up to the match end
805
806             ite = re_quotes.finditer(code_string)
807             code_offset = 0 #reset counters
808             code_line = 1
809             for i in ite:
810                 src = i.group(1)
811                 if src.startswith("''"):
812                     assert src.endswith("''"), "Incorrect usage of _(..) function (should contain only literal strings!) in file %s near: %s" % (frelativepath, src[:30])
813                     src = src[2:-2]
814                 else:
815                     src = join_quotes.sub(r'\1', src)
816                 code_line += code_string[code_offset:i.start(1)].count('\n')
817                 src = src.decode('string_escape')
818                 push_translation(module, terms_type, frelativepath, code_line, encode(src))
819                 code_line += i.group(1).count('\n')
820                 code_offset = i.end() # we have counted newlines up to the match end
821
822     for path in path_list:
823         logger.debug("Scanning files of modules at %s", path)
824         for root, dummy, files in osutil.walksymlinks(path):
825             for fname in itertools.chain(fnmatch.filter(files, '*.py')):
826                 export_code_terms_from_file(fname, path, root, 'code')
827             for fname in itertools.chain(fnmatch.filter(files, '*.mako')):
828                 export_code_terms_from_file(fname, path, root, 'report')
829
830
831     out = [["module","type","name","res_id","src","value"]] # header
832     _to_translate.sort()
833     # translate strings marked as to be translated
834     for module, source, name, id, type in _to_translate:
835         trans = trans_obj._get_source(cr, uid, name, type, lang, source)
836         out.append([module, type, name, id, source, encode(trans) or ''])
837
838     return out
839
840 def trans_load(cr, filename, lang, verbose=True, context=None):
841     logger = logging.getLogger('i18n')
842     try:
843         fileobj = misc.file_open(filename)
844         logger.info("loading %s", filename)
845         fileformat = os.path.splitext(filename)[-1][1:].lower()
846         r = trans_load_data(cr, fileobj, fileformat, lang, verbose=verbose, context=context)
847         fileobj.close()
848         return r
849     except IOError:
850         if verbose:
851             logger.error("couldn't read translation file %s", filename)
852         return None
853
854 def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, context=None):
855     """Populates the ir_translation table. Fixing the res_ids so that they point
856     correctly to ir_model_data is done in a separate step, using the
857     'trans_update_res_ids' function below."""
858     logger = logging.getLogger('i18n')
859     if verbose:
860         logger.info('loading translation file for language %s', lang)
861     if context is None:
862         context = {}
863     db_name = cr.dbname
864     pool = pooler.get_pool(db_name)
865     lang_obj = pool.get('res.lang')
866     trans_obj = pool.get('ir.translation')
867     iso_lang = misc.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             reader = csv.reader(fileobj, quotechar='"', delimiter=',')
881             # read the first line of the file (it contains columns titles)
882             for row in reader:
883                 f = row
884                 break
885         elif fileformat == 'po':
886             reader = TinyPoFile(fileobj)
887             f = ['type', 'name', 'res_id', 'src', 'value']
888         else:
889             logger.error('Bad file format: %s', fileformat)
890             raise Exception(_('Bad file format'))
891
892         # read the rest of the file
893         line = 1
894         for row in reader:
895             line += 1
896             # skip empty rows and rows where the translation field (=last fiefd) is empty
897             #if (not row) or (not row[-1]):
898             #    continue
899
900             # dictionary which holds values for this line of the csv file
901             # {'lang': ..., 'type': ..., 'name': ..., 'res_id': ...,
902             #  'src': ..., 'value': ...}
903             dic = {'lang': lang}
904             for i in range(len(f)):
905                 if f[i] in ('module',):
906                     continue
907                 dic[f[i]] = row[i]
908
909             try:
910                 dic['res_id'] = dic['res_id'] and int(dic['res_id']) or 0
911                 dic['module'] = False
912                 dic['xml_id'] = False
913             except:
914                 split_id = dic['res_id'].split('.', 1)
915                 dic['module'] = split_id[0]
916                 dic['xml_id'] = split_id[1]
917                 dic['res_id'] = False
918
919             args = [
920                 ('lang', '=', lang),
921                 ('type', '=', dic['type']),
922                 ('name', '=', dic['name']),
923                 ('src', '=', dic['src']),
924             ]
925             if dic['type'] == 'model':
926                 if dic['res_id'] is False:
927                     args.append(('module', '=', dic['module']))
928                     args.append(('xml_id', '=', dic['xml_id']))
929                 else:
930                     args.append(('res_id', '=', dic['res_id']))
931             ids = trans_obj.search(cr, uid, args)
932             if ids:
933                 if context.get('overwrite') and dic['value']:
934                     trans_obj.write(cr, uid, ids, {'value': dic['value']})
935             else:
936                 trans_obj.create(cr, uid, dic)
937         if verbose:
938             logger.info("translation file loaded succesfully")
939     except IOError:
940         filename = '[lang: %s][format: %s]' % (iso_lang or 'new', fileformat)
941         logger.exception("couldn't read translation file %s", filename)
942
943 def trans_update_res_ids(cr):
944     cr.execute("""
945             UPDATE ir_translation
946             SET res_id = COALESCE ((SELECT ir_model_data.res_id
947                           FROM ir_model_data
948                           WHERE ir_translation.module = ir_model_data.module
949                               AND ir_translation.xml_id = ir_model_data.name), 0)
950             WHERE ir_translation.module is not null
951                 AND ir_translation.xml_id is not null
952                 AND ir_translation.res_id = 0;
953     """)
954
955 def get_locales(lang=None):
956     if lang is None:
957         lang = locale.getdefaultlocale()[0]
958
959     if os.name == 'nt':
960         lang = _LOCALE2WIN32.get(lang, lang)
961
962     def process(enc):
963         ln = locale._build_localename((lang, enc))
964         yield ln
965         nln = locale.normalize(ln)
966         if nln != ln:
967             yield nln
968
969     for x in process('utf8'): yield x
970
971     prefenc = locale.getpreferredencoding()
972     if prefenc:
973         for x in process(prefenc): yield x
974
975         prefenc = {
976             'latin1': 'latin9',
977             'iso-8859-1': 'iso8859-15',
978             'cp1252': '1252',
979         }.get(prefenc.lower())
980         if prefenc:
981             for x in process(prefenc): yield x
982
983     yield lang
984
985
986
987 def resetlocale():
988     # locale.resetlocale is bugged with some locales.
989     for ln in get_locales():
990         try:
991             return locale.setlocale(locale.LC_ALL, ln)
992         except locale.Error:
993             continue
994
995 def load_language(cr, lang):
996     """Loads a translation terms for a language.
997     Used mainly to automate language loading at db initialization.
998
999     :param lang: language ISO code with optional _underscore_ and l10n flavor (ex: 'fr', 'fr_BE', but not 'fr-BE')
1000     :type lang: str
1001     """
1002     pool = pooler.get_pool(cr.dbname)
1003     language_installer = pool.get('base.language.install')
1004     uid = 1
1005     oid = language_installer.create(cr, uid, {'lang': lang})
1006     language_installer.lang_install(cr, uid, [oid], context=None)
1007
1008 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
1009