Merge commit 'origin/master' into mdv-gpl3
[odoo/odoo.git] / bin / tools / translate.py
1 # -*- encoding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution   
5 #    Copyright (C) 2004-2008 Tiny SPRL (<http://tiny.be>). All Rights Reserved
6 #    $Id$
7 #
8 #    This program is free software: you can redistribute it and/or modify
9 #    it under the terms of the GNU General Public License as published by
10 #    the Free Software Foundation, either version 3 of the License, or
11 #    (at your option) any later version.
12 #
13 #    This program is distributed in the hope that it will be useful,
14 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
15 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 #    GNU General Public License for more details.
17 #
18 #    You should have received a copy of the GNU General Public License
19 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
20 #
21 ##############################################################################
22
23 import os
24 from os.path import join
25 import fnmatch
26 import csv, xml.dom, re
27 import osv, tools, pooler
28 import ir
29 import netsvc
30 from tools.misc import UpdateableStr
31 import inspect
32 import mx.DateTime as mxdt
33 import tempfile
34 import tarfile
35 import codecs
36
37
38 class UNIX_LINE_TERMINATOR(csv.excel):
39     lineterminator = '\n'
40
41 csv.register_dialect("UNIX", UNIX_LINE_TERMINATOR)
42
43 #
44 # TODO: a caching method
45 #
46 def translate(cr, name, source_type, lang, source=None):
47     if source and name:
48         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))
49     elif name:
50         cr.execute('select value from ir_translation where lang=%s and type=%s and name=%s', (lang, source_type, str(name)))
51     elif source:
52         cr.execute('select value from ir_translation where lang=%s and type=%s and src=%s', (lang, source_type, source))
53     res_trans = cr.fetchone()
54     res = res_trans and res_trans[0] or False
55     return res
56
57 class GettextAlias(object):
58     def __call__(self, source):
59         frame = inspect.stack()[1][0]
60         cr = frame.f_locals.get('cr')
61         try:
62                 lang = frame.f_locals.get('context', {}).get('lang', False)
63                 if not (lang and cr):
64                         return source
65         except:
66                 return source
67         return translate(cr, None, 'code', lang, source) or source
68
69 _ = GettextAlias()
70
71
72 # class to handle po files
73 class TinyPoFile(object):
74     def __init__(self, buffer):
75         self.buffer = buffer
76
77     def __iter__(self):
78         self.buffer.seek(0)
79         self.lines = self._get_lines()
80
81         self.first = True
82         self.tnrs= []
83         return self
84
85     def _get_lines(self):
86         lines = self.buffer.readlines()
87         # remove the BOM (Byte Order Mark):
88         if len(lines):
89             lines[0] = unicode(lines[0], 'utf8').lstrip(unicode( codecs.BOM_UTF8, "utf8"))
90
91         lines.append('') # ensure that the file ends with at least an empty line
92         return lines
93
94     def next(self):
95         def unquote(str):
96             return str[1:-1].replace("\\n", "\n")   \
97                             .replace('\\"', '"')
98
99         type = name = res_id = source = trad = None
100
101         if self.tnrs:
102             type, name, res_id, source, trad = self.tnrs.pop(0)
103         else:
104             tmp_tnrs = []
105             line = None
106             fuzzy = False
107             while (not line):
108                 if 0 == len(self.lines):
109                     raise StopIteration()
110                 line = self.lines.pop(0).strip()
111
112             while line.startswith('#'):
113                 if line.startswith('#:'):
114                     tmp_tnrs.append( line[2:].strip().split(':') )
115                 elif line.startswith('#,') and (line[2:].strip() == 'fuzzy'):
116                         fuzzy = True
117                 line = self.lines.pop(0).strip()
118             while not line:
119                 # allow empty lines between comments and msgid
120                 line = self.lines.pop(0).strip()
121             if not line.startswith('msgid'):
122                 raise Exception("malformed file: bad line: %s" % line)
123             source = unquote(line[6:])
124             line = self.lines.pop(0).strip()
125             if not source and self.first:
126                 # if the source is "" and it's the first msgid, it's the special
127                 # msgstr with the informations about the traduction and the
128                 # traductor; we skip it
129                 self.tnrs = []
130                 while line:
131                     line = self.lines.pop(0).strip()
132                 return self.next()
133
134             while not line.startswith('msgstr'):
135                 if not line:
136                     raise Exception('malformed file')
137                 source += unquote(line)
138                 line = self.lines.pop(0).strip()
139
140             trad = unquote(line[7:])
141             line = self.lines.pop(0).strip()
142             while line:
143                 trad += unquote(line)
144                 line = self.lines.pop(0).strip()
145
146             if tmp_tnrs and not fuzzy:
147                 type, name, res_id = tmp_tnrs.pop(0)
148                 for t, n, r in tmp_tnrs:
149                     self.tnrs.append((t, n, r, source, trad))
150
151         self.first = False
152         
153         if name == None:
154                 return self.next()
155         return type, name, res_id, source, trad
156
157     def write_infos(self, modules):
158         import release
159         self.buffer.write("# Translation of %(project)s.\n" \
160                           "# This file containt the translation of the following modules:\n" \
161                           "%(modules)s" \
162                           "#\n" \
163                           "msgid \"\"\n" \
164                           "msgstr \"\"\n" \
165                           '''"Project-Id-Version: %(project)s %(version)s\\n"\n''' \
166                           '''"Report-Msgid-Bugs-To: %(bugmail)s\\n"\n''' \
167                           '''"POT-Creation-Date: %(now)s\\n"\n'''        \
168                           '''"PO-Revision-Date: %(now)s\\n"\n'''         \
169                           '''"Last-Translator: <>\\n"\n''' \
170                           '''"Language-Team: \\n"\n'''   \
171                           '''"MIME-Version: 1.0\\n"\n''' \
172                           '''"Content-Type: text/plain; charset=UTF-8\\n"\n'''   \
173                           '''"Content-Transfer-Encoding: \\n"\n'''       \
174                           '''"Plural-Forms: \\n"\n'''    \
175                           "\n"
176
177                           % { 'project': release.description,
178                               'version': release.version,
179                               'modules': reduce(lambda s, m: s + "#\t* %s\n" % m, modules, ""),
180                               'bugmail': release.support_email,
181                               'now': mxdt.ISO.strUTC(mxdt.ISO.DateTime.utc()),
182                             }
183                           )
184
185     def write(self, modules, tnrs, source, trad):
186         def quote(s):
187             return '"%s"' % s.replace('"','\\"') \
188                              .replace('\n', '\\n"\n"')
189
190         plurial = len(modules) > 1 and 's' or ''
191         self.buffer.write("#. module%s: %s\n" % (plurial, ', '.join(modules)))
192
193
194         code = False
195         for typy, name, res_id in tnrs:
196             self.buffer.write("#: %s:%s:%s\n" % (typy, name, res_id))
197             if typy == 'code':
198                 code = True
199
200         if code:
201             # only strings in python code are python formated
202             self.buffer.write("#, python-format\n")
203
204         if not isinstance(trad, unicode):
205             trad = unicode(trad, 'utf8')
206         if not isinstance(source, unicode):
207             source = unicode(source, 'utf8')
208
209         msg = "msgid %s\n"      \
210               "msgstr %s\n\n"   \
211                   % (quote(source), quote(trad))
212         self.buffer.write(msg.encode('utf8'))
213
214
215 # Methods to export the translation file
216
217 def trans_export(lang, modules, buffer, format, dbname=None):
218
219     def _process(format, modules, rows, buffer, lang, newlang):
220         if format == 'csv':
221             writer=csv.writer(buffer, 'UNIX')
222             for row in rows:
223                 writer.writerow(row)
224         elif format == 'po':
225             rows.pop(0)
226             writer = tools.TinyPoFile(buffer)
227             writer.write_infos(modules)
228
229             # we now group the translations by source. That means one translation per source.
230             grouped_rows = {}
231             for module, type, name, res_id, src, trad in rows:
232                 row = grouped_rows.setdefault(src, {})
233                 row.setdefault('modules', set()).add(module)
234                 if ('translation' not in row) or (not row['translation']):
235                     row['translation'] = trad
236                 row.setdefault('tnrs', []).append((type, name, res_id))
237
238             for src, row in grouped_rows.items():
239                 writer.write(row['modules'], row['tnrs'], src, row['translation'])
240
241         elif format == 'tgz':
242             rows.pop(0)
243             rows_by_module = {}
244             for row in rows:
245                 module = row[0]
246                 rows_by_module.setdefault(module, []).append(row)
247
248             tmpdir = tempfile.mkdtemp()
249             for mod, modrows in rows_by_module.items():
250                 tmpmoddir = join(tmpdir, mod, 'i18n')
251                 os.makedirs(tmpmoddir)
252                 pofilename = (newlang and mod or lang) + ".po" + (newlang and 't' or '')
253                 buf = open(join(tmpmoddir, pofilename), 'w')
254                 _process('po', [mod], modrows, buf, lang, newlang)
255
256             tar = tarfile.open(fileobj=buffer, mode='w|gz')
257             tar.add(tmpdir, '')
258             tar.close()
259
260         else:
261             raise Exception(_('Bad file format'))
262
263     newlang = not bool(lang)
264     if newlang:
265         lang = 'en_US'
266     trans = trans_generate(lang, modules, dbname)
267     modules = set([t[0] for t in trans[1:]])
268     _process(format, modules, trans, buffer, lang, newlang)
269     del trans
270
271
272 def trans_parse_xsl(de):
273     res = []
274     for n in [i for i in de.childNodes if (i.nodeType == i.ELEMENT_NODE)]:
275         if n.hasAttribute("t"):
276             for m in [j for j in n.childNodes if (j.nodeType == j.TEXT_NODE)]:
277                 l = m.data.strip().replace('\n',' ')
278                 if len(l):
279                     res.append(l.encode("utf8"))
280         res.extend(trans_parse_xsl(n))
281     return res
282
283 def trans_parse_rml(de):
284     res = []
285     for n in [i for i in de.childNodes if (i.nodeType == i.ELEMENT_NODE)]:
286         for m in [j for j in n.childNodes if (j.nodeType == j.TEXT_NODE)]:
287             string_list = [s.replace('\n', ' ').strip() for s in re.split('\[\[.+?\]\]', m.data)]
288             for s in string_list:
289                 if s:
290                     res.append(s.encode("utf8"))
291         res.extend(trans_parse_rml(n))
292     return res
293
294 def trans_parse_view(de):
295     res = []
296     if de.hasAttribute("string"):
297         s = de.getAttribute('string')
298         if s:
299             res.append(s.encode("utf8"))
300     if de.hasAttribute("sum"):
301         s = de.getAttribute('sum')
302         if s:
303             res.append(s.encode("utf8"))
304     for n in [i for i in de.childNodes if (i.nodeType == i.ELEMENT_NODE)]:
305         res.extend(trans_parse_view(n))
306     return res
307
308 # tests whether an object is in a list of modules
309 def in_modules(object_name, modules):
310     if 'all' in modules:
311         return True
312
313     module_dict = {
314         'ir': 'base',
315         'res': 'base',
316         'workflow': 'base',
317     }
318     module = object_name.split('.')[0]
319     module = module_dict.get(module, module)
320     return module in modules
321
322 def trans_generate(lang, modules, dbname=None):
323     logger = netsvc.Logger()
324     if not dbname:
325         dbname=tools.config['db_name']
326         if not modules:
327             modules = ['all']
328
329     pool = pooler.get_pool(dbname)
330     trans_obj = pool.get('ir.translation')
331     model_data_obj = pool.get('ir.model.data')
332     cr = pooler.get_db(dbname).cursor()
333     uid = 1
334     l = pool.obj_pool.items()
335     l.sort()
336
337     query = 'SELECT name, model, res_id, module'    \
338             '  FROM ir_model_data'
339     if not 'all' in modules:
340         query += ' WHERE module IN (%s)' % ','.join(['%s']*len(modules))
341     query += ' ORDER BY module, model, name'
342
343     query_param = not 'all' in modules and modules or None
344     cr.execute(query, query_param)
345
346     _to_translate = []
347     def push_translation(module, type, name, id, source):
348         tuple = (module, type, name, id, source)
349         if source and tuple not in _to_translate:
350             _to_translate.append(tuple)
351
352
353     for (xml_name,model,res_id,module) in cr.fetchall():
354         xml_name = module+'.'+xml_name
355         if not pool.get(model):
356             logger.notifyChannel("db", netsvc.LOG_ERROR, "unable to find object %r" % (model,))
357             continue
358         obj = pool.get(model).browse(cr, uid, res_id)
359         if model=='ir.ui.view':
360             d = xml.dom.minidom.parseString(obj.arch)
361             for t in trans_parse_view(d.documentElement):
362                 push_translation(module, 'view', obj.model, 0, t)
363         elif model=='ir.actions.wizard':
364             service_name = 'wizard.'+obj.wiz_name
365             try:
366                 obj2 = netsvc._service[service_name]
367             except KeyError, exc:
368                 logger.notifyChannel("db", netsvc.LOG_ERROR, "key error in %s: %s" % (xml_name,str(exc)))
369                 continue
370             for state_name, state_def in obj2.states.iteritems():
371                 if 'result' in state_def:
372                     result = state_def['result']
373                     if result['type'] != 'form':
374                         continue
375                     name = obj.wiz_name + ',' + state_name
376
377                     # export fields
378                     for field_name, field_def in result['fields'].iteritems():
379                         res_name = name + ',' + field_name
380                         if 'string' in field_def:
381                             source = field_def['string']
382                             push_translation(module, 'wizard_field', res_name, 0, source.encode('utf8'))
383                             
384                         if 'selection' in field_def:
385                             for key, val in field_def['selection']:
386                                 push_translation(module, 'selection', res_name, 0, val.encode('utf8'))
387
388                     # export arch
389                     arch = result['arch']
390                     if arch and not isinstance(arch, UpdateableStr):
391                         d = xml.dom.minidom.parseString(arch)
392                         for t in trans_parse_view(d.documentElement):
393                             push_translation(module, 'wizard_view', name, 0, t)
394
395                     # export button labels
396                     for but_args in result['state']:
397                         button_name = but_args[0]
398                         button_label = but_args[1]
399                         res_name = name + ',' + button_name
400                         push_translation(module, 'wizard_button', res_name, 0, button_label)
401
402         elif model=='ir.model.fields':
403             try:
404                 field_name = obj.name
405             except AttributeError, exc:
406                 logger.notifyChannel("db", netsvc.LOG_ERROR, "name error in %s: %s" % (xml_name,str(exc)))
407                 continue
408             objmodel = pool.get(obj.model)
409             if not objmodel or not field_name in objmodel._columns:
410                 continue
411             field_def = objmodel._columns[field_name]
412
413             name = obj.model + "," + field_name
414             push_translation(module, 'field', name, 0, field_def.string.encode('utf8'))
415
416             if field_def.help:
417                 push_translation(module, 'help', name, 0, field_def.help.encode('utf8'))
418
419             if field_def.translate:
420                 ids = objmodel.search(cr, uid, [])
421                 obj_values = objmodel.read(cr, uid, ids, [field_name])
422                 for obj_value in obj_values:
423                     res_id = obj_value['id']
424                     if obj.name in ('ir.model', 'ir.ui.menu'):
425                         res_id = 0
426                     model_data_ids = model_data_obj.search(cr, uid, [
427                         ('model', '=', model),
428                         ('res_id', '=', res_id),
429                         ])
430                     if not model_data_ids:
431                         push_translation(module, 'model', name, 0, obj_value[field_name])
432
433             if hasattr(field_def, 'selection') and isinstance(field_def.selection, (list, tuple)):
434                 for key, val in field_def.selection:
435                     push_translation(module, 'selection', name, 0, val.encode('utf8'))
436
437         elif model=='ir.actions.report.xml':
438             name = obj.report_name
439             fname = ""
440             if obj.report_rml:
441                 fname = obj.report_rml
442                 parse_func = trans_parse_rml
443                 report_type = "rml"
444             elif obj.report_xsl:
445                 fname = obj.report_xsl
446                 parse_func = trans_parse_xsl
447                 report_type = "xsl"
448             try:
449                 xmlstr = tools.file_open(fname).read()
450                 d = xml.dom.minidom.parseString(xmlstr)
451                 for t in parse_func(d.documentElement):
452                     push_translation(module, report_type, name, 0, t)
453             except IOError:
454                 if fname:
455                     logger.notifyChannel("init", netsvc.LOG_WARNING, "couldn't export translation for report %s %s %s" % (name, report_type, fname))
456
457         for constraint in pool.get(model)._constraints:
458             msg = constraint[1]
459             push_translation(module, 'constraint', model, 0, msg.encode('utf8'))
460
461         for field_name,field_def in pool.get(model)._columns.items():
462             if field_def.translate:
463                 name = model + "," + field_name
464                 try:
465                     trad = getattr(obj, field_name) or ''
466                 except:
467                     trad = ''
468                 push_translation(module, 'model', name, xml_name, trad)
469
470     # parse source code for _() calls
471     def get_module_from_path(path):
472         relative_addons_path = tools.config['addons_path'][len(tools.config['root_path'])+1:]
473         if path.startswith(relative_addons_path) and (os.path.dirname(path) != relative_addons_path):
474             path = path[len(relative_addons_path)+1:]
475             return path.split(os.path.sep)[0]
476         return 'base'   # files that are not in a module are considered as being in 'base' module
477
478     modobj = pool.get('ir.module.module')
479     installed_modids = modobj.search(cr, uid, [('state', '=', 'installed')])
480     installed_modules = map(lambda m: m['name'], modobj.read(cr, uid, installed_modids, ['name']))
481
482     for root, dirs, files in tools.osutil.walksymlinks(tools.config['root_path']):
483         for fname in fnmatch.filter(files, '*.py'):
484             fabsolutepath = join(root, fname)
485             frelativepath = fabsolutepath[len(tools.config['root_path'])+1:]
486             module = get_module_from_path(frelativepath)
487             is_mod_installed = module in installed_modules
488             if (('all' in modules) or (module in modules)) and is_mod_installed:
489                 code_string = tools.file_open(fabsolutepath, subdir='').read()
490                 iter = re.finditer(
491                     '[^a-zA-Z0-9_]_\([\s]*["\'](.+?)["\'][\s]*\)',
492                     code_string, re.M)
493                 for i in iter:
494                     push_translation(module, 'code', frelativepath, 0, i.group(1).encode('utf8'))
495
496
497     out = [["module","type","name","res_id","src","value"]] # header
498     # translate strings marked as to be translated
499     for module, type, name, id, source in _to_translate:
500         trans = trans_obj._get_source(cr, uid, name, type, lang, source)
501         out.append([module, type, name, id, source, trans or ''])
502
503     cr.close()
504     return out
505
506 def trans_load(db_name, filename, lang, strict=False, verbose=True):
507     logger = netsvc.Logger()
508     try:
509         fileobj = open(filename,'r')
510         fileformat = os.path.splitext(filename)[-1][1:].lower()
511         r = trans_load_data(db_name, fileobj, fileformat, lang, strict=strict, verbose=verbose)
512         fileobj.close()
513         return r
514     except IOError:
515         if verbose:
516             logger.notifyChannel("init", netsvc.LOG_ERROR, "couldn't read translation file %s" % (filename,)) # FIXME translate message
517         return None
518
519 def trans_load_data(db_name, fileobj, fileformat, lang, strict=False, lang_name=None, verbose=True):
520     logger = netsvc.Logger()
521     if verbose:
522         logger.notifyChannel("init", netsvc.LOG_INFO,
523                 'loading translation file for language %s' % (lang))
524     pool = pooler.get_pool(db_name)
525     lang_obj = pool.get('res.lang')
526     trans_obj = pool.get('ir.translation')
527     model_data_obj = pool.get('ir.model.data')
528     try:
529         uid = 1
530         cr = pooler.get_db(db_name).cursor()
531
532         ids = lang_obj.search(cr, uid, [('code','=',lang)])
533         if not ids:
534             if not lang_name:
535                 lang_name=lang
536                 languages=tools.get_languages()
537                 lang_name = languages.get(lang, lang)
538             ids = lang_obj.create(cr, uid, {
539                 'code': lang,
540                 'name': lang_name,
541                 'translatable': 1,
542                 })
543         else:
544             lang_obj.write(cr, uid, ids, {'translatable':1})
545         lang_ids = lang_obj.search(cr, uid, [])
546         langs = lang_obj.read(cr, uid, lang_ids)
547         ls = map(lambda x: (x['code'],x['name']), langs)
548
549         fileobj.seek(0)
550
551         if fileformat == 'csv':
552             reader = csv.reader(fileobj, quotechar='"', delimiter=',')
553             # read the first line of the file (it contains columns titles)
554             for row in reader:
555                 f = row
556                 break
557         elif fileformat == 'po':
558             reader = TinyPoFile(fileobj)
559             f = ['type', 'name', 'res_id', 'src', 'value']
560         else:
561             raise Exception(_('Bad file format'))
562
563         # read the rest of the file
564         line = 1
565         for row in reader:
566             line += 1
567             # skip empty rows and rows where the translation field (=last fiefd) is empty
568             if (not row) or (not row[-1]):
569                 #print "translate: skip %s" % repr(row)
570                 continue
571
572             # dictionary which holds values for this line of the csv file
573             # {'lang': ..., 'type': ..., 'name': ..., 'res_id': ...,
574             #  'src': ..., 'value': ...}
575             dic = {'lang': lang}
576             for i in range(len(f)):
577                 if f[i] in ('module',):
578                     continue
579                 dic[f[i]] = row[i]
580
581             try:
582                 dic['res_id'] = int(dic['res_id'])
583             except:
584                 model_data_ids = model_data_obj.search(cr, uid, [
585                     ('model', '=', dic['name'].split(',')[0]),
586                     ('module', '=', dic['res_id'].split('.', 1)[0]),
587                     ('name', '=', dic['res_id'].split('.', 1)[1]),
588                     ])
589                 if model_data_ids:
590                     dic['res_id'] = model_data_obj.browse(cr, uid,
591                             model_data_ids[0]).res_id
592                 else:
593                     dic['res_id'] = False
594
595             if dic['type'] == 'model' and not strict:
596                 (model, field) = dic['name'].split(',')
597
598                 # get the ids of the resources of this model which share
599                 # the same source
600                 obj = pool.get(model)
601                 if obj:
602                     ids = obj.search(cr, uid, [(field, '=', dic['src'])])
603
604                     # if the resource id (res_id) is in that list, use it,
605                     # otherwise use the whole list
606                     ids = (dic['res_id'] in ids) and [dic['res_id']] or ids
607                     for id in ids:
608                         dic['res_id'] = id
609                         ids = trans_obj.search(cr, uid, [
610                             ('lang', '=', lang),
611                             ('type', '=', dic['type']),
612                             ('name', '=', dic['name']),
613                             ('src', '=', dic['src']),
614                             ('res_id', '=', dic['res_id'])
615                         ])
616                         if ids:
617                             trans_obj.write(cr, uid, ids, {'value': dic['value']})
618                         else:
619                             trans_obj.create(cr, uid, dic)
620             else:
621                 ids = trans_obj.search(cr, uid, [
622                     ('lang', '=', lang),
623                     ('type', '=', dic['type']),
624                     ('name', '=', dic['name']),
625                     ('src', '=', dic['src'])
626                 ])
627                 if ids:
628                     trans_obj.write(cr, uid, ids, {'value': dic['value']})
629                 else:
630                     trans_obj.create(cr, uid, dic)
631             cr.commit()
632         cr.close()
633         if verbose:
634             logger.notifyChannel("init", netsvc.LOG_INFO,
635                     "translation file loaded succesfully")
636     except IOError:
637         filename = '[lang: %s][format: %s]' % (lang or 'new', fileformat)
638         logger.notifyChannel("init", netsvc.LOG_ERROR, "couldn't read translation file %s" % (filename,))
639
640
641 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
642