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