[IMP] various tentative improvements to translation code
[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 locale
27 import os
28 import openerp.sql_db as sql_db
29 import re
30 import logging
31 import tarfile
32 import tempfile
33 import threading
34 from babel.messages import extract
35 from os.path import join
36
37 from datetime import datetime
38 from lxml import etree
39
40 import config
41 import misc
42 from misc import SKIPPED_ELEMENT_TYPES
43 import osutil
44 import openerp
45 from openerp import SUPERUSER_ID
46
47 _logger = logging.getLogger(__name__)
48
49 # used to notify web client that these translations should be loaded in the UI
50 WEB_TRANSLATION_COMMENT = "openerp-web"
51
52 SKIPPED_ELEMENTS = ('script', 'style')
53
54 _LOCALE2WIN32 = {
55     'af_ZA': 'Afrikaans_South Africa',
56     'sq_AL': 'Albanian_Albania',
57     'ar_SA': 'Arabic_Saudi Arabia',
58     'eu_ES': 'Basque_Spain',
59     'be_BY': 'Belarusian_Belarus',
60     'bs_BA': 'Bosnian_Bosnia and Herzegovina',
61     'bg_BG': 'Bulgarian_Bulgaria',
62     'ca_ES': 'Catalan_Spain',
63     'hr_HR': 'Croatian_Croatia',
64     'zh_CN': 'Chinese_China',
65     'zh_TW': 'Chinese_Taiwan',
66     'cs_CZ': 'Czech_Czech Republic',
67     'da_DK': 'Danish_Denmark',
68     'nl_NL': 'Dutch_Netherlands',
69     'et_EE': 'Estonian_Estonia',
70     'fa_IR': 'Farsi_Iran',
71     'ph_PH': 'Filipino_Philippines',
72     'fi_FI': 'Finnish_Finland',
73     'fr_FR': 'French_France',
74     'fr_BE': 'French_France',
75     'fr_CH': 'French_France',
76     'fr_CA': 'French_France',
77     'ga': 'Scottish Gaelic',
78     'gl_ES': 'Galician_Spain',
79     'ka_GE': 'Georgian_Georgia',
80     'de_DE': 'German_Germany',
81     'el_GR': 'Greek_Greece',
82     'gu': 'Gujarati_India',
83     'he_IL': 'Hebrew_Israel',
84     'hi_IN': 'Hindi',
85     'hu': 'Hungarian_Hungary',
86     'is_IS': 'Icelandic_Iceland',
87     'id_ID': 'Indonesian_indonesia',
88     'it_IT': 'Italian_Italy',
89     'ja_JP': 'Japanese_Japan',
90     'kn_IN': 'Kannada',
91     'km_KH': 'Khmer',
92     'ko_KR': 'Korean_Korea',
93     'lo_LA': 'Lao_Laos',
94     'lt_LT': 'Lithuanian_Lithuania',
95     'lat': 'Latvian_Latvia',
96     'ml_IN': 'Malayalam_India',
97     'mi_NZ': 'Maori',
98     'mn': 'Cyrillic_Mongolian',
99     'no_NO': 'Norwegian_Norway',
100     'nn_NO': 'Norwegian-Nynorsk_Norway',
101     'pl': 'Polish_Poland',
102     'pt_PT': 'Portuguese_Portugal',
103     'pt_BR': 'Portuguese_Brazil',
104     'ro_RO': 'Romanian_Romania',
105     'ru_RU': 'Russian_Russia',
106     'sr_CS': 'Serbian (Cyrillic)_Serbia and Montenegro',
107     'sk_SK': 'Slovak_Slovakia',
108     'sl_SI': 'Slovenian_Slovenia',
109     #should find more specific locales for spanish countries,
110     #but better than nothing
111     'es_AR': 'Spanish_Spain',
112     'es_BO': 'Spanish_Spain',
113     'es_CL': 'Spanish_Spain',
114     'es_CO': 'Spanish_Spain',
115     'es_CR': 'Spanish_Spain',
116     'es_DO': 'Spanish_Spain',
117     'es_EC': 'Spanish_Spain',
118     'es_ES': 'Spanish_Spain',
119     'es_GT': 'Spanish_Spain',
120     'es_HN': 'Spanish_Spain',
121     'es_MX': 'Spanish_Spain',
122     'es_NI': 'Spanish_Spain',
123     'es_PA': 'Spanish_Spain',
124     'es_PE': 'Spanish_Spain',
125     'es_PR': 'Spanish_Spain',
126     'es_PY': 'Spanish_Spain',
127     'es_SV': 'Spanish_Spain',
128     'es_UY': 'Spanish_Spain',
129     'es_VE': 'Spanish_Spain',
130     'sv_SE': 'Swedish_Sweden',
131     'ta_IN': 'English_Australia',
132     'th_TH': 'Thai_Thailand',
133     'tr_TR': 'Turkish_Turkey',
134     'uk_UA': 'Ukrainian_Ukraine',
135     'vi_VN': 'Vietnamese_Viet Nam',
136     'tlh_TLH': 'Klingon',
137
138 }
139
140
141 class UNIX_LINE_TERMINATOR(csv.excel):
142     lineterminator = '\n'
143
144 csv.register_dialect("UNIX", UNIX_LINE_TERMINATOR)
145
146 #
147 # Warning: better use self.pool.get('ir.translation')._get_source if you can
148 #
149 def translate(cr, name, source_type, lang, source=None):
150     if source and name:
151         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))
152     elif name:
153         cr.execute('select value from ir_translation where lang=%s and type=%s and name=%s', (lang, source_type, str(name)))
154     elif source:
155         cr.execute('select value from ir_translation where lang=%s and type=%s and src=%s', (lang, source_type, source))
156     res_trans = cr.fetchone()
157     res = res_trans and res_trans[0] or False
158     return res
159
160 class GettextAlias(object):
161
162     def _get_db(self):
163         # find current DB based on thread/worker db name (see netsvc)
164         db_name = getattr(threading.currentThread(), 'dbname', None)
165         if db_name:
166             return sql_db.db_connect(db_name)
167
168     def _get_cr(self, frame, allow_create=True):
169         # try, in order: cr, cursor, self.env.cr, self.cr
170         if 'cr' in frame.f_locals:
171             return frame.f_locals['cr'], False
172         if 'cursor' in frame.f_locals:
173             return frame.f_locals['cursor'], False
174         s = frame.f_locals.get('self')
175         if hasattr(s, 'env'):
176             return s.env.cr, False
177         if hasattr(s, 'cr'):
178             return s.cr, False
179         if allow_create:
180             # create a new cursor
181             db = self._get_db()
182             if db is not None:
183                 return db.cursor(), True
184         return None, False
185
186     def _get_uid(self, frame):
187         # try, in order: uid, user, self.env.uid
188         if 'uid' in frame.f_locals:
189             return frame.f_locals['uid']
190         if 'user' in frame.f_locals:
191             return int(frame.f_locals['user'])      # user may be a record
192         s = frame.f_locals.get('self')
193         return s.env.uid
194
195     def _get_lang(self, frame):
196         # try, in order: context.get('lang'), kwargs['context'].get('lang'),
197         # self.env.lang, self.localcontext.get('lang')
198         lang = None
199         if frame.f_locals.get('context'):
200             lang = frame.f_locals['context'].get('lang')
201         if not lang:
202             kwargs = frame.f_locals.get('kwargs', {})
203             if kwargs.get('context'):
204                 lang = kwargs['context'].get('lang')
205         if not lang:
206             s = frame.f_locals.get('self')
207             if hasattr(s, 'env'):
208                 lang = s.env.lang
209             if not lang:
210                 if hasattr(s, 'localcontext'):
211                     lang = s.localcontext.get('lang')
212             if not lang:
213                 # Last resort: attempt to guess the language of the user
214                 # Pitfall: some operations are performed in sudo mode, and we
215                 #          don't know the originial uid, so the language may
216                 #          be wrong when the admin language differs.
217                 pool = getattr(s, 'pool', None)
218                 (cr, dummy) = self._get_cr(frame, allow_create=False)
219                 uid = self._get_uid(frame)
220                 if pool and cr and uid:
221                     lang = pool['res.users'].context_get(cr, uid)['lang']
222         return lang
223
224     def __call__(self, source):
225         res = source
226         cr = None
227         is_new_cr = False
228         try:
229             frame = inspect.currentframe()
230             if frame is None:
231                 return source
232             frame = frame.f_back
233             if not frame:
234                 return source
235             lang = self._get_lang(frame)
236             if lang:
237                 cr, is_new_cr = self._get_cr(frame)
238                 if cr:
239                     # Try to use ir.translation to benefit from global cache if possible
240                     registry = openerp.registry(cr.dbname)
241                     res = registry['ir.translation']._get_source(cr, SUPERUSER_ID, None, ('code','sql_constraint'), lang, source)
242                 else:
243                     _logger.debug('no context cursor detected, skipping translation for "%r"', source)
244             else:
245                 _logger.debug('no translation language detected, skipping translation for "%r" ', source)
246         except Exception:
247             _logger.debug('translation went wrong for "%r", skipped', source)
248                 # if so, double-check the root/base translations filenames
249         finally:
250             if cr and is_new_cr:
251                 cr.close()
252         return res
253
254 _ = GettextAlias()
255
256
257 def quote(s):
258     """Returns quoted PO term string, with special PO characters escaped"""
259     assert r"\n" not in s, "Translation terms may not include escaped newlines ('\\n'), please use only literal newlines! (in '%s')" % s
260     return '"%s"' % s.replace('\\','\\\\') \
261                      .replace('"','\\"') \
262                      .replace('\n', '\\n"\n"')
263
264 re_escaped_char = re.compile(r"(\\.)")
265 re_escaped_replacements = {'n': '\n', }
266
267 def _sub_replacement(match_obj):
268     return re_escaped_replacements.get(match_obj.group(1)[1], match_obj.group(1)[1])
269
270 def unquote(str):
271     """Returns unquoted PO term string, with special PO characters unescaped"""
272     return re_escaped_char.sub(_sub_replacement, str[1:-1])
273
274 # class to handle po files
275 class TinyPoFile(object):
276     def __init__(self, buffer):
277         self.buffer = buffer
278
279     def warn(self, msg, *args):
280         _logger.warning(msg, *args)
281
282     def __iter__(self):
283         self.buffer.seek(0)
284         self.lines = self._get_lines()
285         self.lines_count = len(self.lines)
286
287         self.first = True
288         self.extra_lines= []
289         return self
290
291     def _get_lines(self):
292         lines = self.buffer.readlines()
293         # remove the BOM (Byte Order Mark):
294         if len(lines):
295             lines[0] = unicode(lines[0], 'utf8').lstrip(unicode( codecs.BOM_UTF8, "utf8"))
296
297         lines.append('') # ensure that the file ends with at least an empty line
298         return lines
299
300     def cur_line(self):
301         return self.lines_count - len(self.lines)
302
303     def next(self):
304         trans_type = name = res_id = source = trad = None
305         if self.extra_lines:
306             trans_type, name, res_id, source, trad, comments = self.extra_lines.pop(0)
307             if not res_id:
308                 res_id = '0'
309         else:
310             comments = []
311             targets = []
312             line = None
313             fuzzy = False
314             while not line:
315                 if 0 == len(self.lines):
316                     raise StopIteration()
317                 line = self.lines.pop(0).strip()
318             while line.startswith('#'):
319                 if line.startswith('#~ '):
320                     break
321                 if line.startswith('#.'):
322                     line = line[2:].strip()
323                     if not line.startswith('module:'):
324                         comments.append(line)
325                 elif line.startswith('#:'):
326                     # Process the `reference` comments. Each line can specify
327                     # multiple targets (e.g. model, view, code, selection,
328                     # ...). For each target, we will return an additional
329                     # entry.
330                     for lpart in line[2:].strip().split(' '):
331                         trans_info = lpart.strip().split(':',2)
332                         if trans_info and len(trans_info) == 2:
333                             # looks like the translation trans_type is missing, which is not
334                             # unexpected because it is not a GetText standard. Default: 'code'
335                             trans_info[:0] = ['code']
336                         if trans_info and len(trans_info) == 3:
337                             # this is a ref line holding the destination info (model, field, record)
338                             targets.append(trans_info)
339                 elif line.startswith('#,') and (line[2:].strip() == 'fuzzy'):
340                     fuzzy = True
341                 line = self.lines.pop(0).strip()
342             while not line:
343                 # allow empty lines between comments and msgid
344                 line = self.lines.pop(0).strip()
345             if line.startswith('#~ '):
346                 while line.startswith('#~ ') or not line.strip():
347                     if 0 == len(self.lines):
348                         raise StopIteration()
349                     line = self.lines.pop(0)
350                 # This has been a deprecated entry, don't return anything
351                 return self.next()
352
353             if not line.startswith('msgid'):
354                 raise Exception("malformed file: bad line: %s" % line)
355             source = unquote(line[6:])
356             line = self.lines.pop(0).strip()
357             if not source and self.first:
358                 # if the source is "" and it's the first msgid, it's the special
359                 # msgstr with the informations about the traduction and the
360                 # traductor; we skip it
361                 self.extra_lines = []
362                 while line:
363                     line = self.lines.pop(0).strip()
364                 return self.next()
365
366             while not line.startswith('msgstr'):
367                 if not line:
368                     raise Exception('malformed file at %d'% self.cur_line())
369                 source += unquote(line)
370                 line = self.lines.pop(0).strip()
371
372             trad = unquote(line[7:])
373             line = self.lines.pop(0).strip()
374             while line:
375                 trad += unquote(line)
376                 line = self.lines.pop(0).strip()
377
378             if targets and not fuzzy:
379                 # Use the first target for the current entry (returned at the
380                 # end of this next() call), and keep the others to generate
381                 # additional entries (returned the next next() calls).
382                 trans_type, name, res_id = targets.pop(0)
383                 for t, n, r in targets:
384                     if t == trans_type == 'code': continue
385                     self.extra_lines.append((t, n, r, source, trad, comments))
386
387         self.first = False
388
389         if name is None:
390             if not fuzzy:
391                 self.warn('Missing "#:" formated comment at line %d for the following source:\n\t%s',
392                         self.cur_line(), source[:30])
393             return self.next()
394         return trans_type, name, res_id, source, trad, '\n'.join(comments)
395
396     def write_infos(self, modules):
397         import openerp.release as release
398         self.buffer.write("# Translation of %(project)s.\n" \
399                           "# This file contains the translation of the following modules:\n" \
400                           "%(modules)s" \
401                           "#\n" \
402                           "msgid \"\"\n" \
403                           "msgstr \"\"\n" \
404                           '''"Project-Id-Version: %(project)s %(version)s\\n"\n''' \
405                           '''"Report-Msgid-Bugs-To: \\n"\n''' \
406                           '''"POT-Creation-Date: %(now)s\\n"\n'''        \
407                           '''"PO-Revision-Date: %(now)s\\n"\n'''         \
408                           '''"Last-Translator: <>\\n"\n''' \
409                           '''"Language-Team: \\n"\n'''   \
410                           '''"MIME-Version: 1.0\\n"\n''' \
411                           '''"Content-Type: text/plain; charset=UTF-8\\n"\n'''   \
412                           '''"Content-Transfer-Encoding: \\n"\n'''       \
413                           '''"Plural-Forms: \\n"\n'''    \
414                           "\n"
415
416                           % { 'project': release.description,
417                               'version': release.version,
418                               'modules': reduce(lambda s, m: s + "#\t* %s\n" % m, modules, ""),
419                               'now': datetime.utcnow().strftime('%Y-%m-%d %H:%M')+"+0000",
420                             }
421                           )
422
423     def write(self, modules, tnrs, source, trad, comments=None):
424
425         plurial = len(modules) > 1 and 's' or ''
426         self.buffer.write("#. module%s: %s\n" % (plurial, ', '.join(modules)))
427
428         if comments:
429             self.buffer.write(''.join(('#. %s\n' % c for c in comments)))
430
431         code = False
432         for typy, name, res_id in tnrs:
433             self.buffer.write("#: %s:%s:%s\n" % (typy, name, res_id))
434             if typy == 'code':
435                 code = True
436
437         if code:
438             # only strings in python code are python formated
439             self.buffer.write("#, python-format\n")
440
441         if not isinstance(trad, unicode):
442             trad = unicode(trad, 'utf8')
443         if not isinstance(source, unicode):
444             source = unicode(source, 'utf8')
445
446         msg = "msgid %s\n"      \
447               "msgstr %s\n\n"   \
448                   % (quote(source), quote(trad))
449         self.buffer.write(msg.encode('utf8'))
450
451
452 # Methods to export the translation file
453
454 def trans_export(lang, modules, buffer, format, cr):
455
456     def _process(format, modules, rows, buffer, lang):
457         if format == 'csv':
458             writer = csv.writer(buffer, 'UNIX')
459             # write header first
460             writer.writerow(("module","type","name","res_id","src","value"))
461             for module, type, name, res_id, src, trad, comments in rows:
462                 # Comments are ignored by the CSV writer
463                 writer.writerow((module, type, name, res_id, src, trad))
464         elif format == 'po':
465             writer = TinyPoFile(buffer)
466             writer.write_infos(modules)
467
468             # we now group the translations by source. That means one translation per source.
469             grouped_rows = {}
470             for module, type, name, res_id, src, trad, comments in rows:
471                 row = grouped_rows.setdefault(src, {})
472                 row.setdefault('modules', set()).add(module)
473                 if not row.get('translation') and trad != src:
474                     row['translation'] = trad
475                 row.setdefault('tnrs', []).append((type, name, res_id))
476                 row.setdefault('comments', set()).update(comments)
477
478             for src, row in sorted(grouped_rows.items()):
479                 if not lang:
480                     # translation template, so no translation value
481                     row['translation'] = ''
482                 elif not row.get('translation'):
483                     row['translation'] = src
484                 writer.write(row['modules'], row['tnrs'], src, row['translation'], row['comments'])
485
486         elif format == 'tgz':
487             rows_by_module = {}
488             for row in rows:
489                 module = row[0]
490                 rows_by_module.setdefault(module, []).append(row)
491             tmpdir = tempfile.mkdtemp()
492             for mod, modrows in rows_by_module.items():
493                 tmpmoddir = join(tmpdir, mod, 'i18n')
494                 os.makedirs(tmpmoddir)
495                 pofilename = (lang if lang else mod) + ".po" + ('t' if not lang else '')
496                 buf = file(join(tmpmoddir, pofilename), 'w')
497                 _process('po', [mod], modrows, buf, lang)
498                 buf.close()
499
500             tar = tarfile.open(fileobj=buffer, mode='w|gz')
501             tar.add(tmpdir, '')
502             tar.close()
503
504         else:
505             raise Exception(_('Unrecognized extension: must be one of '
506                 '.csv, .po, or .tgz (received .%s).' % format))
507
508     translations = trans_generate(lang, modules, cr)
509     modules = set(t[0] for t in translations)
510     _process(format, modules, translations, buffer, lang)
511     del translations
512
513 def trans_parse_xsl(de):
514     return list(set(trans_parse_xsl_aux(de, False)))
515
516 def trans_parse_xsl_aux(de, t):
517     res = []
518
519     for n in de:
520         t = t or n.get("t")
521         if t:
522                 if isinstance(n, SKIPPED_ELEMENT_TYPES) or n.tag.startswith('{http://www.w3.org/1999/XSL/Transform}'):
523                     continue
524                 if n.text:
525                     l = n.text.strip().replace('\n',' ')
526                     if len(l):
527                         res.append(l.encode("utf8"))
528                 if n.tail:
529                     l = n.tail.strip().replace('\n',' ')
530                     if len(l):
531                         res.append(l.encode("utf8"))
532         res.extend(trans_parse_xsl_aux(n, t))
533     return res
534
535 def trans_parse_rml(de):
536     res = []
537     for n in de:
538         for m in n:
539             if isinstance(m, SKIPPED_ELEMENT_TYPES) or not m.text:
540                 continue
541             string_list = [s.replace('\n', ' ').strip() for s in re.split('\[\[.+?\]\]', m.text)]
542             for s in string_list:
543                 if s:
544                     res.append(s.encode("utf8"))
545         res.extend(trans_parse_rml(n))
546     return res
547
548 def _push(callback, term, source_line):
549     """ Sanity check before pushing translation terms """
550     term = (term or "").strip().encode('utf8')
551     # Avoid non-char tokens like ':' '...' '.00' etc.
552     if len(term) > 8 or any(x.isalpha() for x in term):
553         callback(term, source_line)
554
555 def trans_parse_view(element, callback):
556     """ Helper method to recursively walk an etree document representing a
557         regular view and call ``callback(term)`` for each translatable term
558         that is found in the document.
559
560         :param ElementTree element: root of etree document to extract terms from
561         :param callable callback: a callable in the form ``f(term, source_line)``,
562             that will be called for each extracted term.
563     """
564     for el in element.iter():
565         if (not isinstance(el, SKIPPED_ELEMENT_TYPES)
566                 and el.tag.lower() not in SKIPPED_ELEMENTS
567                 and el.text):
568             _push(callback, el.text, el.sourceline)
569         if el.tail:
570             _push(callback, el.tail, el.sourceline)
571         for attr in ('string', 'help', 'sum', 'confirm', 'placeholder'):
572             value = el.get(attr)
573             if value:
574                 _push(callback, value, el.sourceline)
575
576 # tests whether an object is in a list of modules
577 def in_modules(object_name, modules):
578     if 'all' in modules:
579         return True
580
581     module_dict = {
582         'ir': 'base',
583         'res': 'base',
584         'workflow': 'base',
585     }
586     module = object_name.split('.')[0]
587     module = module_dict.get(module, module)
588     return module in modules
589
590 def _extract_translatable_qweb_terms(element, callback):
591     """ Helper method to walk an etree document representing
592         a QWeb template, and call ``callback(term)`` for each
593         translatable term that is found in the document.
594
595         :param etree._Element element: root of etree document to extract terms from
596         :param Callable callback: a callable in the form ``f(term, source_line)``,
597                                   that will be called for each extracted term.
598     """
599     # not using elementTree.iterparse because we need to skip sub-trees in case
600     # the ancestor element had a reason to be skipped
601     for el in element:
602         if isinstance(el, SKIPPED_ELEMENT_TYPES): continue
603         if (el.tag.lower() not in SKIPPED_ELEMENTS
604                 and "t-js" not in el.attrib
605                 and not ("t-jquery" in el.attrib and "t-operation" not in el.attrib)
606                 and el.get("t-translation", '').strip() != "off"):
607             _push(callback, el.text, el.sourceline)
608             for att in ('title', 'alt', 'label', 'placeholder'):
609                 if att in el.attrib:
610                     _push(callback, el.attrib[att], el.sourceline)
611             _extract_translatable_qweb_terms(el, callback)
612         _push(callback, el.tail, el.sourceline)
613
614 def babel_extract_qweb(fileobj, keywords, comment_tags, options):
615     """Babel message extractor for qweb template files.
616
617     :param fileobj: the file-like object the messages should be extracted from
618     :param keywords: a list of keywords (i.e. function names) that should
619                      be recognized as translation functions
620     :param comment_tags: a list of translator tags to search for and
621                          include in the results
622     :param options: a dictionary of additional options (optional)
623     :return: an iterator over ``(lineno, funcname, message, comments)``
624              tuples
625     :rtype: Iterable
626     """
627     result = []
628     def handle_text(text, lineno):
629         result.append((lineno, None, text, []))
630     tree = etree.parse(fileobj)
631     _extract_translatable_qweb_terms(tree.getroot(), handle_text)
632     return result
633
634 def trans_generate(lang, modules, cr):
635     dbname = cr.dbname
636
637     registry = openerp.registry(dbname)
638     trans_obj = registry['ir.translation']
639     model_data_obj = registry['ir.model.data']
640     uid = 1
641
642     query = 'SELECT name, model, res_id, module' \
643             '  FROM ir_model_data'
644
645     query_models = """SELECT m.id, m.model, imd.module
646             FROM ir_model AS m, ir_model_data AS imd
647             WHERE m.id = imd.res_id AND imd.model = 'ir.model' """
648
649     if 'all_installed' in modules:
650         query += ' WHERE module IN ( SELECT name FROM ir_module_module WHERE state = \'installed\') '
651         query_models += " AND imd.module in ( SELECT name FROM ir_module_module WHERE state = 'installed') "
652     query_param = None
653     if 'all' not in modules:
654         query += ' WHERE module IN %s'
655         query_models += ' AND imd.module in %s'
656         query_param = (tuple(modules),)
657     query += ' ORDER BY module, model, name'
658     query_models += ' ORDER BY module, model'
659
660     cr.execute(query, query_param)
661
662     _to_translate = set()
663     def push_translation(module, type, name, id, source, comments=None):
664         # empty and one-letter terms are ignored, they probably are not meant to be
665         # translated, and would be very hard to translate anyway.
666         if not source or len(source.strip()) <= 1:
667             return
668
669         tnx = (module, source, name, id, type, tuple(comments or ()))
670         if tnx not in _to_translate:
671             _to_translate.add(tnx)
672
673     def encode(s):
674         if isinstance(s, unicode):
675             return s.encode('utf8')
676         return s
677
678     def push(mod, type, name, res_id, term):
679         term = (term or '').strip()
680         if len(term) > 2:
681             push_translation(mod, type, name, res_id, term)
682
683     def get_root_view(xml_id):
684         view = model_data_obj.xmlid_to_object(cr, uid, xml_id)
685         if view:
686             while view.mode != 'primary':
687                 view = view.inherit_id
688         xml_id = view.get_external_id(cr, uid).get(view.id, xml_id)
689         return xml_id
690
691     for (xml_name,model,res_id,module) in cr.fetchall():
692         module = encode(module)
693         model = encode(model)
694         xml_name = "%s.%s" % (module, encode(xml_name))
695
696         if model not in registry:
697             _logger.error("Unable to find object %r", model)
698             continue
699
700         Model = registry[model]
701         if not Model._translate:
702             # explicitly disabled
703             continue
704
705         obj = Model.browse(cr, uid, res_id)
706         if not obj.exists():
707             _logger.warning("Unable to find object %r with id %d", model, res_id)
708             continue
709
710         if model=='ir.ui.view':
711             d = etree.XML(encode(obj.arch))
712             if obj.type == 'qweb':
713                 view_id = get_root_view(xml_name)
714                 push_qweb = lambda t,l: push(module, 'view', 'website', view_id, t)
715                 _extract_translatable_qweb_terms(d, push_qweb)
716             else:
717                 push_view = lambda t,l: push(module, 'view', obj.model, xml_name, t)
718                 trans_parse_view(d, push_view)
719         elif model=='ir.actions.wizard':
720             pass # TODO Can model really be 'ir.actions.wizard' ?
721
722         elif model=='ir.model.fields':
723             try:
724                 field_name = encode(obj.name)
725             except AttributeError, exc:
726                 _logger.error("name error in %s: %s", xml_name, str(exc))
727                 continue
728             objmodel = registry.get(obj.model)
729             if (objmodel is None or field_name not in objmodel._columns
730                     or not objmodel._translate):
731                 continue
732             field_def = objmodel._columns[field_name]
733
734             name = "%s,%s" % (encode(obj.model), field_name)
735             push_translation(module, 'field', name, 0, encode(field_def.string))
736
737             if field_def.help:
738                 push_translation(module, 'help', name, 0, encode(field_def.help))
739
740             if field_def.translate:
741                 ids = objmodel.search(cr, uid, [])
742                 obj_values = objmodel.read(cr, uid, ids, [field_name])
743                 for obj_value in obj_values:
744                     res_id = obj_value['id']
745                     if obj.name in ('ir.model', 'ir.ui.menu'):
746                         res_id = 0
747                     model_data_ids = model_data_obj.search(cr, uid, [
748                         ('model', '=', model),
749                         ('res_id', '=', res_id),
750                         ])
751                     if not model_data_ids:
752                         push_translation(module, 'model', name, 0, encode(obj_value[field_name]))
753
754             if hasattr(field_def, 'selection') and isinstance(field_def.selection, (list, tuple)):
755                 for dummy, val in field_def.selection:
756                     push_translation(module, 'selection', name, 0, encode(val))
757
758         elif model=='ir.actions.report.xml':
759             name = encode(obj.report_name)
760             fname = ""
761             if obj.report_rml:
762                 fname = obj.report_rml
763                 parse_func = trans_parse_rml
764                 report_type = "report"
765             elif obj.report_xsl:
766                 fname = obj.report_xsl
767                 parse_func = trans_parse_xsl
768                 report_type = "xsl"
769             if fname and obj.report_type in ('pdf', 'xsl'):
770                 try:
771                     report_file = misc.file_open(fname)
772                     try:
773                         d = etree.parse(report_file)
774                         for t in parse_func(d.iter()):
775                             push_translation(module, report_type, name, 0, t)
776                     finally:
777                         report_file.close()
778                 except (IOError, etree.XMLSyntaxError):
779                     _logger.exception("couldn't export translation for report %s %s %s", name, report_type, fname)
780
781         for field_name, field_def in obj._columns.items():
782             if model == 'ir.model' and field_name == 'name' and obj.name == obj.model:
783                 # ignore model name if it is the technical one, nothing to translate
784                 continue
785             if field_def.translate:
786                 name = model + "," + field_name
787                 try:
788                     term = obj[field_name] or ''
789                 except:
790                     term = ''
791                 push_translation(module, 'model', name, xml_name, encode(term))
792
793         # End of data for ir.model.data query results
794
795     cr.execute(query_models, query_param)
796
797     def push_constraint_msg(module, term_type, model, msg):
798         if not hasattr(msg, '__call__'):
799             push_translation(encode(module), term_type, encode(model), 0, encode(msg))
800
801     def push_local_constraints(module, model, cons_type='sql_constraints'):
802         """Climb up the class hierarchy and ignore inherited constraints
803            from other modules"""
804         term_type = 'sql_constraint' if cons_type == 'sql_constraints' else 'constraint'
805         msg_pos = 2 if cons_type == 'sql_constraints' else 1
806         for cls in model.__class__.__mro__:
807             if getattr(cls, '_module', None) != module:
808                 continue
809             constraints = getattr(cls, '_local_' + cons_type, [])
810             for constraint in constraints:
811                 push_constraint_msg(module, term_type, model._name, constraint[msg_pos])
812             
813     for (_, model, module) in cr.fetchall():
814         if model not in registry:
815             _logger.error("Unable to find object %r", model)
816             continue
817
818         model_obj = registry[model]
819
820         if model_obj._constraints:
821             push_local_constraints(module, model_obj, 'constraints')
822
823         if model_obj._sql_constraints:
824             push_local_constraints(module, model_obj, 'sql_constraints')
825
826     installed_modules = map(
827         lambda m: m['name'],
828         registry['ir.module.module'].search_read(cr, uid, [('state', '=', 'installed')], fields=['name']))
829
830     path_list = list(openerp.modules.module.ad_paths)
831     # Also scan these non-addon paths
832     for bin_path in ['osv', 'report' ]:
833         path_list.append(os.path.join(config.config['root_path'], bin_path))
834
835     _logger.debug("Scanning modules at paths: %s", path_list)
836
837     def get_module_from_path(path):
838         for mp in path_list:
839             if path.startswith(mp) and os.path.dirname(path) != mp:
840                 path = path[len(mp)+1:]
841                 return path.split(os.path.sep)[0]
842         return 'base' # files that are not in a module are considered as being in 'base' module
843
844     def verified_module_filepaths(fname, path, root):
845         fabsolutepath = join(root, fname)
846         frelativepath = fabsolutepath[len(path):]
847         display_path = "addons%s" % frelativepath
848         module = get_module_from_path(fabsolutepath)
849         if ('all' in modules or module in modules) and module in installed_modules:
850             return module, fabsolutepath, frelativepath, display_path
851         return None, None, None, None
852
853     def babel_extract_terms(fname, path, root, extract_method="python", trans_type='code',
854                                extra_comments=None, extract_keywords={'_': None}):
855         module, fabsolutepath, _, display_path = verified_module_filepaths(fname, path, root)
856         extra_comments = extra_comments or []
857         if not module: return
858         src_file = open(fabsolutepath, 'r')
859         try:
860             for extracted in extract.extract(extract_method, src_file,
861                                              keywords=extract_keywords):
862                 # Babel 0.9.6 yields lineno, message, comments
863                 # Babel 1.3 yields lineno, message, comments, context
864                 lineno, message, comments = extracted[:3]
865                 push_translation(module, trans_type, display_path, lineno,
866                                  encode(message), comments + extra_comments)
867         except Exception:
868             _logger.exception("Failed to extract terms from %s", fabsolutepath)
869         finally:
870             src_file.close()
871
872     for path in path_list:
873         _logger.debug("Scanning files of modules at %s", path)
874         for root, dummy, files in osutil.walksymlinks(path):
875             for fname in fnmatch.filter(files, '*.py'):
876                 babel_extract_terms(fname, path, root)
877             # mako provides a babel extractor: http://docs.makotemplates.org/en/latest/usage.html#babel
878             for fname in fnmatch.filter(files, '*.mako'):
879                 babel_extract_terms(fname, path, root, 'mako', trans_type='report')
880             # Javascript source files in the static/src/js directory, rest is ignored (libs)
881             if fnmatch.fnmatch(root, '*/static/src/js*'):
882                 for fname in fnmatch.filter(files, '*.js'):
883                     babel_extract_terms(fname, path, root, 'javascript',
884                                         extra_comments=[WEB_TRANSLATION_COMMENT],
885                                         extract_keywords={'_t': None, '_lt': None})
886             # QWeb template files
887             if fnmatch.fnmatch(root, '*/static/src/xml*'):
888                 for fname in fnmatch.filter(files, '*.xml'):
889                     babel_extract_terms(fname, path, root, 'openerp.tools.translate:babel_extract_qweb',
890                                         extra_comments=[WEB_TRANSLATION_COMMENT])
891
892     out = []
893     # translate strings marked as to be translated
894     for module, source, name, id, type, comments in sorted(_to_translate):
895         trans = '' if not lang else trans_obj._get_source(cr, uid, name, type, lang, source)
896         out.append((module, type, name, id, source, encode(trans) or '', comments))
897     return out
898
899 def trans_load(cr, filename, lang, verbose=True, module_name=None, context=None):
900     try:
901         fileobj = misc.file_open(filename)
902         _logger.info("loading %s", filename)
903         fileformat = os.path.splitext(filename)[-1][1:].lower()
904         result = trans_load_data(cr, fileobj, fileformat, lang, verbose=verbose, module_name=module_name, context=context)
905         fileobj.close()
906         return result
907     except IOError:
908         if verbose:
909             _logger.error("couldn't read translation file %s", filename)
910         return None
911
912 def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, module_name=None, context=None):
913     """Populates the ir_translation table."""
914     if verbose:
915         _logger.info('loading translation file for language %s', lang)
916     if context is None:
917         context = {}
918     db_name = cr.dbname
919     registry = openerp.registry(db_name)
920     lang_obj = registry.get('res.lang')
921     trans_obj = registry.get('ir.translation')
922     iso_lang = misc.get_iso_codes(lang)
923     try:
924         ids = lang_obj.search(cr, SUPERUSER_ID, [('code','=', lang)])
925
926         if not ids:
927             # lets create the language with locale information
928             lang_obj.load_lang(cr, SUPERUSER_ID, lang=lang, lang_name=lang_name)
929
930         # Parse also the POT: it will possibly provide additional targets.
931         # (Because the POT comments are correct on Launchpad but not the
932         # PO comments due to a Launchpad limitation. See LP bug 933496.)
933         pot_reader = []
934
935         # now, the serious things: we read the language file
936         fileobj.seek(0)
937         if fileformat == 'csv':
938             reader = csv.reader(fileobj, quotechar='"', delimiter=',')
939             # read the first line of the file (it contains columns titles)
940             for row in reader:
941                 f = row
942                 break
943         elif fileformat == 'po':
944             reader = TinyPoFile(fileobj)
945             f = ['type', 'name', 'res_id', 'src', 'value', 'comments']
946
947             # Make a reader for the POT file and be somewhat defensive for the
948             # stable branch.
949             if fileobj.name.endswith('.po'):
950                 try:
951                     # Normally the path looks like /path/to/xxx/i18n/lang.po
952                     # and we try to find the corresponding
953                     # /path/to/xxx/i18n/xxx.pot file.
954                     head, _ = os.path.split(fileobj.name)
955                     head2, _ = os.path.split(head)
956                     head3, tail3 = os.path.split(head2)
957                     pot_handle = misc.file_open(os.path.join(head3, tail3, 'i18n', tail3 + '.pot'))
958                     pot_reader = TinyPoFile(pot_handle)
959                 except:
960                     pass
961
962         else:
963             _logger.error('Bad file format: %s', fileformat)
964             raise Exception(_('Bad file format'))
965
966         # Read the POT `reference` comments, and keep them indexed by source
967         # string.
968         pot_targets = {}
969         for type, name, res_id, src, _, comments in pot_reader:
970             if type is not None:
971                 pot_targets.setdefault(src, {'value': None, 'targets': []})
972                 pot_targets[src]['targets'].append((type, name, res_id))
973
974         # read the rest of the file
975         irt_cursor = trans_obj._get_import_cursor(cr, SUPERUSER_ID, context=context)
976
977         def process_row(row):
978             """Process a single PO (or POT) entry."""
979             # skip empty rows and rows where the translation field (=last fiefd) is empty
980             #if (not row) or (not row[-1]):
981             #    return
982
983             # dictionary which holds values for this line of the csv file
984             # {'lang': ..., 'type': ..., 'name': ..., 'res_id': ...,
985             #  'src': ..., 'value': ..., 'module':...}
986             dic = dict.fromkeys(('name', 'res_id', 'src', 'type', 'imd_model', 'imd_name', 'module', 'value', 'comments'))
987             dic['lang'] = lang
988             for i, field in enumerate(f):
989                 dic[field] = row[i]
990
991             # Get the `reference` comments from the POT.
992             src = row[3]
993             if pot_reader and src in pot_targets:
994                 pot_targets[src]['targets'] = filter(lambda x: x != row[:3], pot_targets[src]['targets'])
995                 pot_targets[src]['value'] = row[4]
996                 if not pot_targets[src]['targets']:
997                     del pot_targets[src]
998
999             # This would skip terms that fail to specify a res_id
1000             if not dic.get('res_id'):
1001                 return
1002
1003             res_id = dic.pop('res_id')
1004             if res_id and isinstance(res_id, (int, long)) \
1005                 or (isinstance(res_id, basestring) and res_id.isdigit()):
1006                     dic['res_id'] = int(res_id)
1007                     dic['module'] = module_name
1008             else:
1009                 tmodel = dic['name'].split(',')[0]
1010                 if '.' in res_id:
1011                     tmodule, tname = res_id.split('.', 1)
1012                 else:
1013                     tmodule = False
1014                     tname = res_id
1015                 dic['imd_model'] = tmodel
1016                 dic['imd_name'] =  tname
1017                 dic['module'] = tmodule
1018                 dic['res_id'] = None
1019
1020             irt_cursor.push(dic)
1021
1022         # First process the entries from the PO file (doing so also fills/removes
1023         # the entries from the POT file).
1024         for row in reader:
1025             process_row(row)
1026
1027         # Then process the entries implied by the POT file (which is more
1028         # correct w.r.t. the targets) if some of them remain.
1029         pot_rows = []
1030         for src in pot_targets:
1031             value = pot_targets[src]['value']
1032             for type, name, res_id in pot_targets[src]['targets']:
1033                 pot_rows.append((type, name, res_id, src, value, comments))
1034         for row in pot_rows:
1035             process_row(row)
1036
1037         irt_cursor.finish()
1038         trans_obj.clear_caches()
1039         if verbose:
1040             _logger.info("translation file loaded succesfully")
1041     except IOError:
1042         filename = '[lang: %s][format: %s]' % (iso_lang or 'new', fileformat)
1043         _logger.exception("couldn't read translation file %s", filename)
1044
1045 def get_locales(lang=None):
1046     if lang is None:
1047         lang = locale.getdefaultlocale()[0]
1048
1049     if os.name == 'nt':
1050         lang = _LOCALE2WIN32.get(lang, lang)
1051
1052     def process(enc):
1053         ln = locale._build_localename((lang, enc))
1054         yield ln
1055         nln = locale.normalize(ln)
1056         if nln != ln:
1057             yield nln
1058
1059     for x in process('utf8'): yield x
1060
1061     prefenc = locale.getpreferredencoding()
1062     if prefenc:
1063         for x in process(prefenc): yield x
1064
1065         prefenc = {
1066             'latin1': 'latin9',
1067             'iso-8859-1': 'iso8859-15',
1068             'cp1252': '1252',
1069         }.get(prefenc.lower())
1070         if prefenc:
1071             for x in process(prefenc): yield x
1072
1073     yield lang
1074
1075
1076
1077 def resetlocale():
1078     # locale.resetlocale is bugged with some locales.
1079     for ln in get_locales():
1080         try:
1081             return locale.setlocale(locale.LC_ALL, ln)
1082         except locale.Error:
1083             continue
1084
1085 def load_language(cr, lang):
1086     """Loads a translation terms for a language.
1087     Used mainly to automate language loading at db initialization.
1088
1089     :param lang: language ISO code with optional _underscore_ and l10n flavor (ex: 'fr', 'fr_BE', but not 'fr-BE')
1090     :type lang: str
1091     """
1092     registry = openerp.registry(cr.dbname)
1093     language_installer = registry['base.language.install']
1094     oid = language_installer.create(cr, SUPERUSER_ID, {'lang': lang})
1095     language_installer.lang_install(cr, SUPERUSER_ID, [oid], context=None)
1096
1097 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
1098