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