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