[MERGE] Forward-port saas-5 up to acbdac4
[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': 'Serbian (Latin)',
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         if 'context' in frame.f_locals:
199             return frame.f_locals['context'].get('lang')
200         kwargs = frame.f_locals.get('kwargs', {})
201         if 'context' in kwargs:
202             return kwargs['context'].get('lang')
203         s = frame.f_locals.get('self')
204         if hasattr(s, 'env'):
205             return s.env.lang
206         if hasattr(s, 'localcontext'):
207             return s.localcontext.get('lang')
208         # Last resort: attempt to guess the language of the user
209         # Pitfall: some operations are performed in sudo mode, and we
210         #          don't know the originial uid, so the language may
211         #          be wrong when the admin language differs.
212         pool = getattr(s, 'pool', None)
213         (cr, dummy) = self._get_cr(frame, allow_create=False)
214         uid = self._get_uid(frame)
215         if pool and cr and uid:
216             return pool['res.users'].context_get(cr, uid)['lang']
217         return None
218
219     def __call__(self, source):
220         res = source
221         cr = None
222         is_new_cr = False
223         try:
224             frame = inspect.currentframe()
225             if frame is None:
226                 return source
227             frame = frame.f_back
228             if not frame:
229                 return source
230             lang = self._get_lang(frame)
231             if lang:
232                 cr, is_new_cr = self._get_cr(frame)
233                 if cr:
234                     # Try to use ir.translation to benefit from global cache if possible
235                     registry = openerp.registry(cr.dbname)
236                     res = registry['ir.translation']._get_source(cr, SUPERUSER_ID, None, ('code','sql_constraint'), lang, source)
237                 else:
238                     _logger.debug('no context cursor detected, skipping translation for "%r"', source)
239             else:
240                 _logger.debug('no translation language detected, skipping translation for "%r" ', source)
241         except Exception:
242             _logger.debug('translation went wrong for "%r", skipped', source)
243                 # if so, double-check the root/base translations filenames
244         finally:
245             if cr and is_new_cr:
246                 cr.close()
247         return res
248
249 _ = GettextAlias()
250
251
252 def quote(s):
253     """Returns quoted PO term string, with special PO characters escaped"""
254     assert r"\n" not in s, "Translation terms may not include escaped newlines ('\\n'), please use only literal newlines! (in '%s')" % s
255     return '"%s"' % s.replace('\\','\\\\') \
256                      .replace('"','\\"') \
257                      .replace('\n', '\\n"\n"')
258
259 re_escaped_char = re.compile(r"(\\.)")
260 re_escaped_replacements = {'n': '\n', }
261
262 def _sub_replacement(match_obj):
263     return re_escaped_replacements.get(match_obj.group(1)[1], match_obj.group(1)[1])
264
265 def unquote(str):
266     """Returns unquoted PO term string, with special PO characters unescaped"""
267     return re_escaped_char.sub(_sub_replacement, str[1:-1])
268
269 # class to handle po files
270 class TinyPoFile(object):
271     def __init__(self, buffer):
272         self.buffer = buffer
273
274     def warn(self, msg, *args):
275         _logger.warning(msg, *args)
276
277     def __iter__(self):
278         self.buffer.seek(0)
279         self.lines = self._get_lines()
280         self.lines_count = len(self.lines)
281
282         self.first = True
283         self.extra_lines= []
284         return self
285
286     def _get_lines(self):
287         lines = self.buffer.readlines()
288         # remove the BOM (Byte Order Mark):
289         if len(lines):
290             lines[0] = unicode(lines[0], 'utf8').lstrip(unicode( codecs.BOM_UTF8, "utf8"))
291
292         lines.append('') # ensure that the file ends with at least an empty line
293         return lines
294
295     def cur_line(self):
296         return self.lines_count - len(self.lines)
297
298     def next(self):
299         trans_type = name = res_id = source = trad = None
300         if self.extra_lines:
301             trans_type, name, res_id, source, trad, comments = self.extra_lines.pop(0)
302             if not res_id:
303                 res_id = '0'
304         else:
305             comments = []
306             targets = []
307             line = None
308             fuzzy = False
309             while not line:
310                 if 0 == len(self.lines):
311                     raise StopIteration()
312                 line = self.lines.pop(0).strip()
313             while line.startswith('#'):
314                 if line.startswith('#~ '):
315                     break
316                 if line.startswith('#.'):
317                     line = line[2:].strip()
318                     if not line.startswith('module:'):
319                         comments.append(line)
320                 elif line.startswith('#:'):
321                     # Process the `reference` comments. Each line can specify
322                     # multiple targets (e.g. model, view, code, selection,
323                     # ...). For each target, we will return an additional
324                     # entry.
325                     for lpart in line[2:].strip().split(' '):
326                         trans_info = lpart.strip().split(':',2)
327                         if trans_info and len(trans_info) == 2:
328                             # looks like the translation trans_type is missing, which is not
329                             # unexpected because it is not a GetText standard. Default: 'code'
330                             trans_info[:0] = ['code']
331                         if trans_info and len(trans_info) == 3:
332                             # this is a ref line holding the destination info (model, field, record)
333                             targets.append(trans_info)
334                 elif line.startswith('#,') and (line[2:].strip() == 'fuzzy'):
335                     fuzzy = True
336                 line = self.lines.pop(0).strip()
337             while not line:
338                 # allow empty lines between comments and msgid
339                 line = self.lines.pop(0).strip()
340             if line.startswith('#~ '):
341                 while line.startswith('#~ ') or not line.strip():
342                     if 0 == len(self.lines):
343                         raise StopIteration()
344                     line = self.lines.pop(0)
345                 # This has been a deprecated entry, don't return anything
346                 return self.next()
347
348             if not line.startswith('msgid'):
349                 raise Exception("malformed file: bad line: %s" % line)
350             source = unquote(line[6:])
351             line = self.lines.pop(0).strip()
352             if not source and self.first:
353                 # if the source is "" and it's the first msgid, it's the special
354                 # msgstr with the informations about the traduction and the
355                 # traductor; we skip it
356                 self.extra_lines = []
357                 while line:
358                     line = self.lines.pop(0).strip()
359                 return self.next()
360
361             while not line.startswith('msgstr'):
362                 if not line:
363                     raise Exception('malformed file at %d'% self.cur_line())
364                 source += unquote(line)
365                 line = self.lines.pop(0).strip()
366
367             trad = unquote(line[7:])
368             line = self.lines.pop(0).strip()
369             while line:
370                 trad += unquote(line)
371                 line = self.lines.pop(0).strip()
372
373             if targets and not fuzzy:
374                 # Use the first target for the current entry (returned at the
375                 # end of this next() call), and keep the others to generate
376                 # additional entries (returned the next next() calls).
377                 trans_type, name, res_id = targets.pop(0)
378                 for t, n, r in targets:
379                     if t == trans_type == 'code': continue
380                     self.extra_lines.append((t, n, r, source, trad, comments))
381
382         self.first = False
383
384         if name is None:
385             if not fuzzy:
386                 self.warn('Missing "#:" formated comment at line %d for the following source:\n\t%s',
387                         self.cur_line(), source[:30])
388             return self.next()
389         return trans_type, name, res_id, source, trad, '\n'.join(comments)
390
391     def write_infos(self, modules):
392         import openerp.release as release
393         self.buffer.write("# Translation of %(project)s.\n" \
394                           "# This file contains the translation of the following modules:\n" \
395                           "%(modules)s" \
396                           "#\n" \
397                           "msgid \"\"\n" \
398                           "msgstr \"\"\n" \
399                           '''"Project-Id-Version: %(project)s %(version)s\\n"\n''' \
400                           '''"Report-Msgid-Bugs-To: \\n"\n''' \
401                           '''"POT-Creation-Date: %(now)s\\n"\n'''        \
402                           '''"PO-Revision-Date: %(now)s\\n"\n'''         \
403                           '''"Last-Translator: <>\\n"\n''' \
404                           '''"Language-Team: \\n"\n'''   \
405                           '''"MIME-Version: 1.0\\n"\n''' \
406                           '''"Content-Type: text/plain; charset=UTF-8\\n"\n'''   \
407                           '''"Content-Transfer-Encoding: \\n"\n'''       \
408                           '''"Plural-Forms: \\n"\n'''    \
409                           "\n"
410
411                           % { 'project': release.description,
412                               'version': release.version,
413                               'modules': reduce(lambda s, m: s + "#\t* %s\n" % m, modules, ""),
414                               'now': datetime.utcnow().strftime('%Y-%m-%d %H:%M')+"+0000",
415                             }
416                           )
417
418     def write(self, modules, tnrs, source, trad, comments=None):
419
420         plurial = len(modules) > 1 and 's' or ''
421         self.buffer.write("#. module%s: %s\n" % (plurial, ', '.join(modules)))
422
423         if comments:
424             self.buffer.write(''.join(('#. %s\n' % c for c in comments)))
425
426         code = False
427         for typy, name, res_id in tnrs:
428             self.buffer.write("#: %s:%s:%s\n" % (typy, name, res_id))
429             if typy == 'code':
430                 code = True
431
432         if code:
433             # only strings in python code are python formated
434             self.buffer.write("#, python-format\n")
435
436         if not isinstance(trad, unicode):
437             trad = unicode(trad, 'utf8')
438         if not isinstance(source, unicode):
439             source = unicode(source, 'utf8')
440
441         msg = "msgid %s\n"      \
442               "msgstr %s\n\n"   \
443                   % (quote(source), quote(trad))
444         self.buffer.write(msg.encode('utf8'))
445
446
447 # Methods to export the translation file
448
449 def trans_export(lang, modules, buffer, format, cr):
450
451     def _process(format, modules, rows, buffer, lang):
452         if format == 'csv':
453             writer = csv.writer(buffer, 'UNIX')
454             # write header first
455             writer.writerow(("module","type","name","res_id","src","value"))
456             for module, type, name, res_id, src, trad, comments in rows:
457                 # Comments are ignored by the CSV writer
458                 writer.writerow((module, type, name, res_id, src, trad))
459         elif format == 'po':
460             writer = TinyPoFile(buffer)
461             writer.write_infos(modules)
462
463             # we now group the translations by source. That means one translation per source.
464             grouped_rows = {}
465             for module, type, name, res_id, src, trad, comments in rows:
466                 row = grouped_rows.setdefault(src, {})
467                 row.setdefault('modules', set()).add(module)
468                 if not row.get('translation') and trad != src:
469                     row['translation'] = trad
470                 row.setdefault('tnrs', []).append((type, name, res_id))
471                 row.setdefault('comments', set()).update(comments)
472
473             for src, row in sorted(grouped_rows.items()):
474                 if not lang:
475                     # translation template, so no translation value
476                     row['translation'] = ''
477                 elif not row.get('translation'):
478                     row['translation'] = src
479                 writer.write(row['modules'], row['tnrs'], src, row['translation'], row['comments'])
480
481         elif format == 'tgz':
482             rows_by_module = {}
483             for row in rows:
484                 module = row[0]
485                 rows_by_module.setdefault(module, []).append(row)
486             tmpdir = tempfile.mkdtemp()
487             for mod, modrows in rows_by_module.items():
488                 tmpmoddir = join(tmpdir, mod, 'i18n')
489                 os.makedirs(tmpmoddir)
490                 pofilename = (lang if lang else mod) + ".po" + ('t' if not lang else '')
491                 buf = file(join(tmpmoddir, pofilename), 'w')
492                 _process('po', [mod], modrows, buf, lang)
493                 buf.close()
494
495             tar = tarfile.open(fileobj=buffer, mode='w|gz')
496             tar.add(tmpdir, '')
497             tar.close()
498
499         else:
500             raise Exception(_('Unrecognized extension: must be one of '
501                 '.csv, .po, or .tgz (received .%s).' % format))
502
503     trans_lang = lang
504     if not trans_lang and format == 'csv':
505         # CSV files are meant for translators and they need a starting point,
506         # so we at least put the original term in the translation column
507         trans_lang = 'en_US'
508     translations = trans_generate(lang, modules, cr)
509     modules = set([t[0] for t in translations[1:]])
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     if (not isinstance(element, SKIPPED_ELEMENT_TYPES)
565             and element.tag.lower() not in SKIPPED_ELEMENTS
566             and element.text):
567         _push(callback, element.text, element.sourceline)
568     if element.tail:
569         _push(callback, element.tail, element.sourceline)
570     for attr in ('string', 'help', 'sum', 'confirm', 'placeholder'):
571         value = element.get(attr)
572         if value:
573             _push(callback, value, element.sourceline)
574     for n in element:
575         trans_parse_view(n, callback)
576
577 # tests whether an object is in a list of modules
578 def in_modules(object_name, modules):
579     if 'all' in modules:
580         return True
581
582     module_dict = {
583         'ir': 'base',
584         'res': 'base',
585         'workflow': 'base',
586     }
587     module = object_name.split('.')[0]
588     module = module_dict.get(module, module)
589     return module in modules
590
591 def _extract_translatable_qweb_terms(element, callback):
592     """ Helper method to walk an etree document representing
593         a QWeb template, and call ``callback(term)`` for each
594         translatable term that is found in the document.
595
596         :param ElementTree element: root of etree document to extract terms from
597         :param callable callback: a callable in the form ``f(term, source_line)``,
598             that will be called for each extracted term.
599     """
600     # not using elementTree.iterparse because we need to skip sub-trees in case
601     # the ancestor element had a reason to be skipped
602     for el in element:
603         if isinstance(el, SKIPPED_ELEMENT_TYPES): continue
604         if (el.tag.lower() not in SKIPPED_ELEMENTS
605                 and "t-js" not in el.attrib
606                 and not ("t-jquery" in el.attrib and "t-operation" not in el.attrib)
607                 and not ("t-translation" in el.attrib and
608                          el.attrib["t-translation"].strip() == "off")):
609             _push(callback, el.text, el.sourceline)
610             for att in ('title', 'alt', 'label', 'placeholder'):
611                 if att in el.attrib:
612                     _push(callback, el.attrib[att], el.sourceline)
613             _extract_translatable_qweb_terms(el, callback)
614         _push(callback, el.tail, el.sourceline)
615
616 def babel_extract_qweb(fileobj, keywords, comment_tags, options):
617     """Babel message extractor for qweb template files.
618     :param fileobj: the file-like object the messages should be extracted from
619     :param keywords: a list of keywords (i.e. function names) that should
620                      be recognized as translation functions
621     :param comment_tags: a list of translator tags to search for and
622                          include in the results
623     :param options: a dictionary of additional options (optional)
624     :return: an iterator over ``(lineno, funcname, message, comments)``
625              tuples
626     :rtype: ``iterator``
627     """
628     result = []
629     def handle_text(text, lineno):
630         result.append((lineno, None, text, []))
631     tree = etree.parse(fileobj)
632     _extract_translatable_qweb_terms(tree.getroot(), handle_text)
633     return result
634
635 def trans_generate(lang, modules, cr):
636     dbname = cr.dbname
637
638     registry = openerp.registry(dbname)
639     trans_obj = registry.get('ir.translation')
640     model_data_obj = registry.get('ir.model.data')
641     uid = 1
642     l = registry.models.items()
643     l.sort()
644
645     query = 'SELECT name, model, res_id, module'    \
646             '  FROM ir_model_data'
647
648     query_models = """SELECT m.id, m.model, imd.module
649             FROM ir_model AS m, ir_model_data AS imd
650             WHERE m.id = imd.res_id AND imd.model = 'ir.model' """
651
652     if 'all_installed' in modules:
653         query += ' WHERE module IN ( SELECT name FROM ir_module_module WHERE state = \'installed\') '
654         query_models += " AND imd.module in ( SELECT name FROM ir_module_module WHERE state = 'installed') "
655     query_param = None
656     if 'all' not in modules:
657         query += ' WHERE module IN %s'
658         query_models += ' AND imd.module in %s'
659         query_param = (tuple(modules),)
660     query += ' ORDER BY module, model, name'
661     query_models += ' ORDER BY module, model'
662
663     cr.execute(query, query_param)
664
665     _to_translate = []
666     def push_translation(module, type, name, id, source, comments=None):
667         tuple = (module, source, name, id, type, comments or [])
668         # empty and one-letter terms are ignored, they probably are not meant to be
669         # translated, and would be very hard to translate anyway.
670         if not source or len(source.strip()) <= 1:
671             return
672         if tuple not in _to_translate:
673             _to_translate.append(tuple)
674
675     def encode(s):
676         if isinstance(s, unicode):
677             return s.encode('utf8')
678         return s
679
680     def push(mod, type, name, res_id, term):
681         term = (term or '').strip()
682         if len(term) > 2:
683             push_translation(mod, type, name, res_id, term)
684
685     def get_root_view(xml_id):
686         view = model_data_obj.xmlid_to_object(cr, uid, xml_id)
687         if view:
688             while view.mode != 'primary':
689                 view = view.inherit_id
690         xml_id = view.get_external_id(cr, uid).get(view.id, xml_id)
691         return xml_id
692
693     for (xml_name,model,res_id,module) in cr.fetchall():
694         module = encode(module)
695         model = encode(model)
696         xml_name = "%s.%s" % (module, encode(xml_name))
697
698         if model not in registry:
699             _logger.error("Unable to find object %r", model)
700             continue
701
702         if not registry[model]._translate:
703             # explicitly disabled
704             continue
705
706         exists = registry[model].exists(cr, uid, res_id)
707         if not exists:
708             _logger.warning("Unable to find object %r with id %d", model, res_id)
709             continue
710         obj = registry[model].browse(cr, uid, res_id)
711
712         if model=='ir.ui.view':
713             d = etree.XML(encode(obj.arch))
714             if obj.type == 'qweb':
715                 view_id = get_root_view(xml_name)
716                 push_qweb = lambda t,l: push(module, 'view', 'website', view_id, t)
717                 _extract_translatable_qweb_terms(d, push_qweb)
718             else:
719                 push_view = lambda t,l: push(module, 'view', obj.model, xml_name, t)
720                 trans_parse_view(d, push_view)
721         elif model=='ir.actions.wizard':
722             pass # TODO Can model really be 'ir.actions.wizard' ?
723
724         elif model=='ir.model.fields':
725             try:
726                 field_name = encode(obj.name)
727             except AttributeError, exc:
728                 _logger.error("name error in %s: %s", xml_name, str(exc))
729                 continue
730             objmodel = registry.get(obj.model)
731             if (objmodel is None or field_name not in objmodel._columns
732                     or not objmodel._translate):
733                 continue
734             field_def = objmodel._columns[field_name]
735
736             name = "%s,%s" % (encode(obj.model), field_name)
737             push_translation(module, 'field', name, 0, encode(field_def.string))
738
739             if field_def.help:
740                 push_translation(module, 'help', name, 0, encode(field_def.help))
741
742             if field_def.translate:
743                 ids = objmodel.search(cr, uid, [])
744                 obj_values = objmodel.read(cr, uid, ids, [field_name])
745                 for obj_value in obj_values:
746                     res_id = obj_value['id']
747                     if obj.name in ('ir.model', 'ir.ui.menu'):
748                         res_id = 0
749                     model_data_ids = model_data_obj.search(cr, uid, [
750                         ('model', '=', model),
751                         ('res_id', '=', res_id),
752                         ])
753                     if not model_data_ids:
754                         push_translation(module, 'model', name, 0, encode(obj_value[field_name]))
755
756             if hasattr(field_def, 'selection') and isinstance(field_def.selection, (list, tuple)):
757                 for dummy, val in field_def.selection:
758                     push_translation(module, 'selection', name, 0, encode(val))
759
760         elif model=='ir.actions.report.xml':
761             name = encode(obj.report_name)
762             fname = ""
763             if obj.report_rml:
764                 fname = obj.report_rml
765                 parse_func = trans_parse_rml
766                 report_type = "report"
767             elif obj.report_xsl:
768                 fname = obj.report_xsl
769                 parse_func = trans_parse_xsl
770                 report_type = "xsl"
771             if fname and obj.report_type in ('pdf', 'xsl'):
772                 try:
773                     report_file = misc.file_open(fname)
774                     try:
775                         d = etree.parse(report_file)
776                         for t in parse_func(d.iter()):
777                             push_translation(module, report_type, name, 0, t)
778                     finally:
779                         report_file.close()
780                 except (IOError, etree.XMLSyntaxError):
781                     _logger.exception("couldn't export translation for report %s %s %s", name, report_type, fname)
782
783         for field_name, field_def in obj._columns.items():
784             if model == 'ir.model' and field_name == 'name' and obj.name == obj.model:
785                 # ignore model name if it is the technical one, nothing to translate
786                 continue
787             if field_def.translate:
788                 name = model + "," + field_name
789                 try:
790                     term = obj[field_name] or ''
791                 except:
792                     term = ''
793                 push_translation(module, 'model', name, xml_name, encode(term))
794
795         # End of data for ir.model.data query results
796
797     cr.execute(query_models, query_param)
798
799     def push_constraint_msg(module, term_type, model, msg):
800         if not hasattr(msg, '__call__'):
801             push_translation(encode(module), term_type, encode(model), 0, encode(msg))
802
803     def push_local_constraints(module, model, cons_type='sql_constraints'):
804         """Climb up the class hierarchy and ignore inherited constraints
805            from other modules"""
806         term_type = 'sql_constraint' if cons_type == 'sql_constraints' else 'constraint'
807         msg_pos = 2 if cons_type == 'sql_constraints' else 1
808         for cls in model.__class__.__mro__:
809             if getattr(cls, '_module', None) != module:
810                 continue
811             constraints = getattr(cls, '_local_' + cons_type, [])
812             for constraint in constraints:
813                 push_constraint_msg(module, term_type, model._name, constraint[msg_pos])
814             
815     for (_, model, module) in cr.fetchall():
816         if model not in registry:
817             _logger.error("Unable to find object %r", model)
818             continue
819
820         model_obj = registry[model]
821
822         if model_obj._constraints:
823             push_local_constraints(module, model_obj, 'constraints')
824
825         if model_obj._sql_constraints:
826             push_local_constraints(module, model_obj, 'sql_constraints')
827
828     modobj = registry['ir.module.module']
829     installed_modids = modobj.search(cr, uid, [('state', '=', 'installed')])
830     installed_modules = map(lambda m: m['name'], modobj.read(cr, uid, installed_modids, ['name']))
831
832     path_list = list(openerp.modules.module.ad_paths)
833     # Also scan these non-addon paths
834     for bin_path in ['osv', 'report' ]:
835         path_list.append(os.path.join(config.config['root_path'], bin_path))
836
837     _logger.debug("Scanning modules at paths: %s", path_list)
838
839     mod_paths = list(path_list)
840
841     def get_module_from_path(path):
842         for mp in mod_paths:
843             if path.startswith(mp) and (os.path.dirname(path) != mp):
844                 path = path[len(mp)+1:]
845                 return path.split(os.path.sep)[0]
846         return 'base'   # files that are not in a module are considered as being in 'base' module
847
848     def verified_module_filepaths(fname, path, root):
849         fabsolutepath = join(root, fname)
850         frelativepath = fabsolutepath[len(path):]
851         display_path = "addons%s" % frelativepath
852         module = get_module_from_path(fabsolutepath)
853         if ('all' in modules or module in modules) and module in installed_modules:
854             return module, fabsolutepath, frelativepath, display_path
855         return None, None, None, None
856
857     def babel_extract_terms(fname, path, root, extract_method="python", trans_type='code',
858                                extra_comments=None, extract_keywords={'_': None}):
859         module, fabsolutepath, _, display_path = verified_module_filepaths(fname, path, root)
860         extra_comments = extra_comments or []
861         if module:
862             src_file = open(fabsolutepath, 'r')
863             try:
864                 for extracted in extract.extract(extract_method, src_file,
865                                                  keywords=extract_keywords):
866                     # Babel 0.9.6 yields lineno, message, comments
867                     # Babel 1.3 yields lineno, message, comments, context
868                     lineno, message, comments = extracted[:3] 
869                     push_translation(module, trans_type, display_path, lineno,
870                                      encode(message), comments + extra_comments)
871             except Exception:
872                 _logger.exception("Failed to extract terms from %s", fabsolutepath)
873             finally:
874                 src_file.close()
875
876     for path in path_list:
877         _logger.debug("Scanning files of modules at %s", path)
878         for root, dummy, files in osutil.walksymlinks(path):
879             for fname in fnmatch.filter(files, '*.py'):
880                 babel_extract_terms(fname, path, root)
881             # mako provides a babel extractor: http://docs.makotemplates.org/en/latest/usage.html#babel
882             for fname in fnmatch.filter(files, '*.mako'):
883                 babel_extract_terms(fname, path, root, 'mako', trans_type='report')
884             # Javascript source files in the static/src/js directory, rest is ignored (libs)
885             if fnmatch.fnmatch(root, '*/static/src/js*'):
886                 for fname in fnmatch.filter(files, '*.js'):
887                     babel_extract_terms(fname, path, root, 'javascript',
888                                         extra_comments=[WEB_TRANSLATION_COMMENT],
889                                         extract_keywords={'_t': None, '_lt': None})
890             # QWeb template files
891             if fnmatch.fnmatch(root, '*/static/src/xml*'):
892                 for fname in fnmatch.filter(files, '*.xml'):
893                     babel_extract_terms(fname, path, root, 'openerp.tools.translate:babel_extract_qweb',
894                                         extra_comments=[WEB_TRANSLATION_COMMENT])
895
896     out = []
897     _to_translate.sort()
898     # translate strings marked as to be translated
899     for module, source, name, id, type, comments in _to_translate:
900         trans = '' if not lang else trans_obj._get_source(cr, uid, name, type, lang, source)
901         out.append([module, type, name, id, source, encode(trans) or '', comments])
902     return out
903
904 def trans_load(cr, filename, lang, verbose=True, module_name=None, context=None):
905     try:
906         fileobj = misc.file_open(filename)
907         _logger.info("loading %s", filename)
908         fileformat = os.path.splitext(filename)[-1][1:].lower()
909         result = trans_load_data(cr, fileobj, fileformat, lang, verbose=verbose, module_name=module_name, context=context)
910         fileobj.close()
911         return result
912     except IOError:
913         if verbose:
914             _logger.error("couldn't read translation file %s", filename)
915         return None
916
917 def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, module_name=None, context=None):
918     """Populates the ir_translation table."""
919     if verbose:
920         _logger.info('loading translation file for language %s', lang)
921     if context is None:
922         context = {}
923     db_name = cr.dbname
924     registry = openerp.registry(db_name)
925     lang_obj = registry.get('res.lang')
926     trans_obj = registry.get('ir.translation')
927     iso_lang = misc.get_iso_codes(lang)
928     try:
929         ids = lang_obj.search(cr, SUPERUSER_ID, [('code','=', lang)])
930
931         if not ids:
932             # lets create the language with locale information
933             lang_obj.load_lang(cr, SUPERUSER_ID, lang=lang, lang_name=lang_name)
934
935         # Parse also the POT: it will possibly provide additional targets.
936         # (Because the POT comments are correct on Launchpad but not the
937         # PO comments due to a Launchpad limitation. See LP bug 933496.)
938         pot_reader = []
939
940         # now, the serious things: we read the language file
941         fileobj.seek(0)
942         if fileformat == 'csv':
943             reader = csv.reader(fileobj, quotechar='"', delimiter=',')
944             # read the first line of the file (it contains columns titles)
945             for row in reader:
946                 f = row
947                 break
948         elif fileformat == 'po':
949             reader = TinyPoFile(fileobj)
950             f = ['type', 'name', 'res_id', 'src', 'value', 'comments']
951
952             # Make a reader for the POT file and be somewhat defensive for the
953             # stable branch.
954             if fileobj.name.endswith('.po'):
955                 try:
956                     # Normally the path looks like /path/to/xxx/i18n/lang.po
957                     # and we try to find the corresponding
958                     # /path/to/xxx/i18n/xxx.pot file.
959                     head, _ = os.path.split(fileobj.name)
960                     head2, _ = os.path.split(head)
961                     head3, tail3 = os.path.split(head2)
962                     pot_handle = misc.file_open(os.path.join(head3, tail3, 'i18n', tail3 + '.pot'))
963                     pot_reader = TinyPoFile(pot_handle)
964                 except:
965                     pass
966
967         else:
968             _logger.error('Bad file format: %s', fileformat)
969             raise Exception(_('Bad file format'))
970
971         # Read the POT `reference` comments, and keep them indexed by source
972         # string.
973         pot_targets = {}
974         for type, name, res_id, src, _, comments in pot_reader:
975             if type is not None:
976                 pot_targets.setdefault(src, {'value': None, 'targets': []})
977                 pot_targets[src]['targets'].append((type, name, res_id))
978
979         # read the rest of the file
980         irt_cursor = trans_obj._get_import_cursor(cr, SUPERUSER_ID, context=context)
981
982         def process_row(row):
983             """Process a single PO (or POT) entry."""
984             # skip empty rows and rows where the translation field (=last fiefd) is empty
985             #if (not row) or (not row[-1]):
986             #    return
987
988             # dictionary which holds values for this line of the csv file
989             # {'lang': ..., 'type': ..., 'name': ..., 'res_id': ...,
990             #  'src': ..., 'value': ..., 'module':...}
991             dic = dict.fromkeys(('name', 'res_id', 'src', 'type', 'imd_model', 'imd_name', 'module', 'value', 'comments'))
992             dic['lang'] = lang
993             for i, field in enumerate(f):
994                 dic[field] = row[i]
995
996             # Get the `reference` comments from the POT.
997             src = row[3]
998             if pot_reader and src in pot_targets:
999                 pot_targets[src]['targets'] = filter(lambda x: x != row[:3], pot_targets[src]['targets'])
1000                 pot_targets[src]['value'] = row[4]
1001                 if not pot_targets[src]['targets']:
1002                     del pot_targets[src]
1003
1004             # This would skip terms that fail to specify a res_id
1005             if not dic.get('res_id'):
1006                 return
1007
1008             res_id = dic.pop('res_id')
1009             if res_id and isinstance(res_id, (int, long)) \
1010                 or (isinstance(res_id, basestring) and res_id.isdigit()):
1011                     dic['res_id'] = int(res_id)
1012                     dic['module'] = module_name
1013             else:
1014                 tmodel = dic['name'].split(',')[0]
1015                 if '.' in res_id:
1016                     tmodule, tname = res_id.split('.', 1)
1017                 else:
1018                     tmodule = False
1019                     tname = res_id
1020                 dic['imd_model'] = tmodel
1021                 dic['imd_name'] =  tname
1022                 dic['module'] = tmodule
1023                 dic['res_id'] = None
1024
1025             irt_cursor.push(dic)
1026
1027         # First process the entries from the PO file (doing so also fills/removes
1028         # the entries from the POT file).
1029         for row in reader:
1030             process_row(row)
1031
1032         # Then process the entries implied by the POT file (which is more
1033         # correct w.r.t. the targets) if some of them remain.
1034         pot_rows = []
1035         for src in pot_targets:
1036             value = pot_targets[src]['value']
1037             for type, name, res_id in pot_targets[src]['targets']:
1038                 pot_rows.append((type, name, res_id, src, value, comments))
1039         for row in pot_rows:
1040             process_row(row)
1041
1042         irt_cursor.finish()
1043         trans_obj.clear_caches()
1044         if verbose:
1045             _logger.info("translation file loaded succesfully")
1046     except IOError:
1047         filename = '[lang: %s][format: %s]' % (iso_lang or 'new', fileformat)
1048         _logger.exception("couldn't read translation file %s", filename)
1049
1050 def get_locales(lang=None):
1051     if lang is None:
1052         lang = locale.getdefaultlocale()[0]
1053
1054     if os.name == 'nt':
1055         lang = _LOCALE2WIN32.get(lang, lang)
1056
1057     def process(enc):
1058         ln = locale._build_localename((lang, enc))
1059         yield ln
1060         nln = locale.normalize(ln)
1061         if nln != ln:
1062             yield nln
1063
1064     for x in process('utf8'): yield x
1065
1066     prefenc = locale.getpreferredencoding()
1067     if prefenc:
1068         for x in process(prefenc): yield x
1069
1070         prefenc = {
1071             'latin1': 'latin9',
1072             'iso-8859-1': 'iso8859-15',
1073             'cp1252': '1252',
1074         }.get(prefenc.lower())
1075         if prefenc:
1076             for x in process(prefenc): yield x
1077
1078     yield lang
1079
1080
1081
1082 def resetlocale():
1083     # locale.resetlocale is bugged with some locales.
1084     for ln in get_locales():
1085         try:
1086             return locale.setlocale(locale.LC_ALL, ln)
1087         except locale.Error:
1088             continue
1089
1090 def load_language(cr, lang):
1091     """Loads a translation terms for a language.
1092     Used mainly to automate language loading at db initialization.
1093
1094     :param lang: language ISO code with optional _underscore_ and l10n flavor (ex: 'fr', 'fr_BE', but not 'fr-BE')
1095     :type lang: str
1096     """
1097     registry = openerp.registry(cr.dbname)
1098     language_installer = registry['base.language.install']
1099     oid = language_installer.create(cr, SUPERUSER_ID, {'lang': lang})
1100     language_installer.lang_install(cr, SUPERUSER_ID, [oid], context=None)
1101
1102 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
1103