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