[FIX] translations: don't skip first line of translations when extracting module...
[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.get('ir.translation')
639     model_data_obj = registry.get('ir.model.data')
640     uid = 1
641     l = registry.models.items()
642     l.sort()
643
644     query = 'SELECT name, model, res_id, module'    \
645             '  FROM ir_model_data'
646
647     query_models = """SELECT m.id, m.model, imd.module
648             FROM ir_model AS m, ir_model_data AS imd
649             WHERE m.id = imd.res_id AND imd.model = 'ir.model' """
650
651     if 'all_installed' in modules:
652         query += ' WHERE module IN ( SELECT name FROM ir_module_module WHERE state = \'installed\') '
653         query_models += " AND imd.module in ( SELECT name FROM ir_module_module WHERE state = 'installed') "
654     query_param = None
655     if 'all' not in modules:
656         query += ' WHERE module IN %s'
657         query_models += ' AND imd.module in %s'
658         query_param = (tuple(modules),)
659     query += ' ORDER BY module, model, name'
660     query_models += ' ORDER BY module, model'
661
662     cr.execute(query, query_param)
663
664     _to_translate = []
665     def push_translation(module, type, name, id, source, comments=None):
666         tuple = (module, source, name, id, type, comments or [])
667         # empty and one-letter terms are ignored, they probably are not meant to be
668         # translated, and would be very hard to translate anyway.
669         if not source or len(source.strip()) <= 1:
670             return
671         if tuple not in _to_translate:
672             _to_translate.append(tuple)
673
674     def encode(s):
675         if isinstance(s, unicode):
676             return s.encode('utf8')
677         return s
678
679     def push(mod, type, name, res_id, term):
680         term = (term or '').strip()
681         if len(term) > 2:
682             push_translation(mod, type, name, res_id, term)
683
684     def get_root_view(xml_id):
685         view = model_data_obj.xmlid_to_object(cr, uid, xml_id)
686         if view:
687             while view.mode != 'primary':
688                 view = view.inherit_id
689         xml_id = view.get_external_id(cr, uid).get(view.id, xml_id)
690         return xml_id
691
692     for (xml_name,model,res_id,module) in cr.fetchall():
693         module = encode(module)
694         model = encode(model)
695         xml_name = "%s.%s" % (module, encode(xml_name))
696
697         if model not in registry:
698             _logger.error("Unable to find object %r", model)
699             continue
700
701         if not registry[model]._translate:
702             # explicitly disabled
703             continue
704
705         exists = registry[model].exists(cr, uid, res_id)
706         if not exists:
707             _logger.warning("Unable to find object %r with id %d", model, res_id)
708             continue
709         obj = registry[model].browse(cr, uid, res_id)
710
711         if model=='ir.ui.view':
712             d = etree.XML(encode(obj.arch))
713             if obj.type == 'qweb':
714                 view_id = get_root_view(xml_name)
715                 push_qweb = lambda t,l: push(module, 'view', 'website', view_id, t)
716                 _extract_translatable_qweb_terms(d, push_qweb)
717             else:
718                 push_view = lambda t,l: push(module, 'view', obj.model, xml_name, t)
719                 trans_parse_view(d, push_view)
720         elif model=='ir.actions.wizard':
721             pass # TODO Can model really be 'ir.actions.wizard' ?
722
723         elif model=='ir.model.fields':
724             try:
725                 field_name = encode(obj.name)
726             except AttributeError, exc:
727                 _logger.error("name error in %s: %s", xml_name, str(exc))
728                 continue
729             objmodel = registry.get(obj.model)
730             if (objmodel is None or field_name not in objmodel._columns
731                     or not objmodel._translate):
732                 continue
733             field_def = objmodel._columns[field_name]
734
735             name = "%s,%s" % (encode(obj.model), field_name)
736             push_translation(module, 'field', name, 0, encode(field_def.string))
737
738             if field_def.help:
739                 push_translation(module, 'help', name, 0, encode(field_def.help))
740
741             if field_def.translate:
742                 ids = objmodel.search(cr, uid, [])
743                 obj_values = objmodel.read(cr, uid, ids, [field_name])
744                 for obj_value in obj_values:
745                     res_id = obj_value['id']
746                     if obj.name in ('ir.model', 'ir.ui.menu'):
747                         res_id = 0
748                     model_data_ids = model_data_obj.search(cr, uid, [
749                         ('model', '=', model),
750                         ('res_id', '=', res_id),
751                         ])
752                     if not model_data_ids:
753                         push_translation(module, 'model', name, 0, encode(obj_value[field_name]))
754
755             if hasattr(field_def, 'selection') and isinstance(field_def.selection, (list, tuple)):
756                 for dummy, val in field_def.selection:
757                     push_translation(module, 'selection', name, 0, encode(val))
758
759         elif model=='ir.actions.report.xml':
760             name = encode(obj.report_name)
761             fname = ""
762             if obj.report_rml:
763                 fname = obj.report_rml
764                 parse_func = trans_parse_rml
765                 report_type = "report"
766             elif obj.report_xsl:
767                 fname = obj.report_xsl
768                 parse_func = trans_parse_xsl
769                 report_type = "xsl"
770             if fname and obj.report_type in ('pdf', 'xsl'):
771                 try:
772                     report_file = misc.file_open(fname)
773                     try:
774                         d = etree.parse(report_file)
775                         for t in parse_func(d.iter()):
776                             push_translation(module, report_type, name, 0, t)
777                     finally:
778                         report_file.close()
779                 except (IOError, etree.XMLSyntaxError):
780                     _logger.exception("couldn't export translation for report %s %s %s", name, report_type, fname)
781
782         for field_name, field_def in obj._columns.items():
783             if model == 'ir.model' and field_name == 'name' and obj.name == obj.model:
784                 # ignore model name if it is the technical one, nothing to translate
785                 continue
786             if field_def.translate:
787                 name = model + "," + field_name
788                 try:
789                     term = obj[field_name] or ''
790                 except:
791                     term = ''
792                 push_translation(module, 'model', name, xml_name, encode(term))
793
794         # End of data for ir.model.data query results
795
796     cr.execute(query_models, query_param)
797
798     def push_constraint_msg(module, term_type, model, msg):
799         if not hasattr(msg, '__call__'):
800             push_translation(encode(module), term_type, encode(model), 0, encode(msg))
801
802     def push_local_constraints(module, model, cons_type='sql_constraints'):
803         """Climb up the class hierarchy and ignore inherited constraints
804            from other modules"""
805         term_type = 'sql_constraint' if cons_type == 'sql_constraints' else 'constraint'
806         msg_pos = 2 if cons_type == 'sql_constraints' else 1
807         for cls in model.__class__.__mro__:
808             if getattr(cls, '_module', None) != module:
809                 continue
810             constraints = getattr(cls, '_local_' + cons_type, [])
811             for constraint in constraints:
812                 push_constraint_msg(module, term_type, model._name, constraint[msg_pos])
813             
814     for (_, model, module) in cr.fetchall():
815         if model not in registry:
816             _logger.error("Unable to find object %r", model)
817             continue
818
819         model_obj = registry[model]
820
821         if model_obj._constraints:
822             push_local_constraints(module, model_obj, 'constraints')
823
824         if model_obj._sql_constraints:
825             push_local_constraints(module, model_obj, 'sql_constraints')
826
827     modobj = registry['ir.module.module']
828     installed_modids = modobj.search(cr, uid, [('state', '=', 'installed')])
829     installed_modules = map(lambda m: m['name'], modobj.read(cr, uid, installed_modids, ['name']))
830
831     path_list = list(openerp.modules.module.ad_paths)
832     # Also scan these non-addon paths
833     for bin_path in ['osv', 'report' ]:
834         path_list.append(os.path.join(config.config['root_path'], bin_path))
835
836     _logger.debug("Scanning modules at paths: %s", path_list)
837
838     mod_paths = list(path_list)
839
840     def get_module_from_path(path):
841         for mp in mod_paths:
842             if path.startswith(mp) and (os.path.dirname(path) != mp):
843                 path = path[len(mp)+1:]
844                 return path.split(os.path.sep)[0]
845         return 'base'   # files that are not in a module are considered as being in 'base' module
846
847     def verified_module_filepaths(fname, path, root):
848         fabsolutepath = join(root, fname)
849         frelativepath = fabsolutepath[len(path):]
850         display_path = "addons%s" % frelativepath
851         module = get_module_from_path(fabsolutepath)
852         if ('all' in modules or module in modules) and module in installed_modules:
853             return module, fabsolutepath, frelativepath, display_path
854         return None, None, None, None
855
856     def babel_extract_terms(fname, path, root, extract_method="python", trans_type='code',
857                                extra_comments=None, extract_keywords={'_': None}):
858         module, fabsolutepath, _, display_path = verified_module_filepaths(fname, path, root)
859         extra_comments = extra_comments or []
860         if module:
861             src_file = open(fabsolutepath, 'r')
862             try:
863                 for extracted in extract.extract(extract_method, src_file,
864                                                  keywords=extract_keywords):
865                     # Babel 0.9.6 yields lineno, message, comments
866                     # Babel 1.3 yields lineno, message, comments, context
867                     lineno, message, comments = extracted[:3] 
868                     push_translation(module, trans_type, display_path, lineno,
869                                      encode(message), comments + extra_comments)
870             except Exception:
871                 _logger.exception("Failed to extract terms from %s", fabsolutepath)
872             finally:
873                 src_file.close()
874
875     for path in path_list:
876         _logger.debug("Scanning files of modules at %s", path)
877         for root, dummy, files in osutil.walksymlinks(path):
878             for fname in fnmatch.filter(files, '*.py'):
879                 babel_extract_terms(fname, path, root)
880             # mako provides a babel extractor: http://docs.makotemplates.org/en/latest/usage.html#babel
881             for fname in fnmatch.filter(files, '*.mako'):
882                 babel_extract_terms(fname, path, root, 'mako', trans_type='report')
883             # Javascript source files in the static/src/js directory, rest is ignored (libs)
884             if fnmatch.fnmatch(root, '*/static/src/js*'):
885                 for fname in fnmatch.filter(files, '*.js'):
886                     babel_extract_terms(fname, path, root, 'javascript',
887                                         extra_comments=[WEB_TRANSLATION_COMMENT],
888                                         extract_keywords={'_t': None, '_lt': None})
889             # QWeb template files
890             if fnmatch.fnmatch(root, '*/static/src/xml*'):
891                 for fname in fnmatch.filter(files, '*.xml'):
892                     babel_extract_terms(fname, path, root, 'openerp.tools.translate:babel_extract_qweb',
893                                         extra_comments=[WEB_TRANSLATION_COMMENT])
894
895     out = []
896     _to_translate.sort()
897     # translate strings marked as to be translated
898     for module, source, name, id, type, comments in _to_translate:
899         trans = '' if not lang else trans_obj._get_source(cr, uid, name, type, lang, source)
900         out.append([module, type, name, id, source, encode(trans) or '', comments])
901     return out
902
903 def trans_load(cr, filename, lang, verbose=True, module_name=None, context=None):
904     try:
905         fileobj = misc.file_open(filename)
906         _logger.info("loading %s", filename)
907         fileformat = os.path.splitext(filename)[-1][1:].lower()
908         result = trans_load_data(cr, fileobj, fileformat, lang, verbose=verbose, module_name=module_name, context=context)
909         fileobj.close()
910         return result
911     except IOError:
912         if verbose:
913             _logger.error("couldn't read translation file %s", filename)
914         return None
915
916 def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, module_name=None, context=None):
917     """Populates the ir_translation table."""
918     if verbose:
919         _logger.info('loading translation file for language %s', lang)
920     if context is None:
921         context = {}
922     db_name = cr.dbname
923     registry = openerp.registry(db_name)
924     lang_obj = registry.get('res.lang')
925     trans_obj = registry.get('ir.translation')
926     iso_lang = misc.get_iso_codes(lang)
927     try:
928         ids = lang_obj.search(cr, SUPERUSER_ID, [('code','=', lang)])
929
930         if not ids:
931             # lets create the language with locale information
932             lang_obj.load_lang(cr, SUPERUSER_ID, lang=lang, lang_name=lang_name)
933
934         # Parse also the POT: it will possibly provide additional targets.
935         # (Because the POT comments are correct on Launchpad but not the
936         # PO comments due to a Launchpad limitation. See LP bug 933496.)
937         pot_reader = []
938
939         # now, the serious things: we read the language file
940         fileobj.seek(0)
941         if fileformat == 'csv':
942             reader = csv.reader(fileobj, quotechar='"', delimiter=',')
943             # read the first line of the file (it contains columns titles)
944             for row in reader:
945                 f = row
946                 break
947         elif fileformat == 'po':
948             reader = TinyPoFile(fileobj)
949             f = ['type', 'name', 'res_id', 'src', 'value', 'comments']
950
951             # Make a reader for the POT file and be somewhat defensive for the
952             # stable branch.
953             if fileobj.name.endswith('.po'):
954                 try:
955                     # Normally the path looks like /path/to/xxx/i18n/lang.po
956                     # and we try to find the corresponding
957                     # /path/to/xxx/i18n/xxx.pot file.
958                     head, _ = os.path.split(fileobj.name)
959                     head2, _ = os.path.split(head)
960                     head3, tail3 = os.path.split(head2)
961                     pot_handle = misc.file_open(os.path.join(head3, tail3, 'i18n', tail3 + '.pot'))
962                     pot_reader = TinyPoFile(pot_handle)
963                 except:
964                     pass
965
966         else:
967             _logger.error('Bad file format: %s', fileformat)
968             raise Exception(_('Bad file format'))
969
970         # Read the POT `reference` comments, and keep them indexed by source
971         # string.
972         pot_targets = {}
973         for type, name, res_id, src, _, comments in pot_reader:
974             if type is not None:
975                 pot_targets.setdefault(src, {'value': None, 'targets': []})
976                 pot_targets[src]['targets'].append((type, name, res_id))
977
978         # read the rest of the file
979         irt_cursor = trans_obj._get_import_cursor(cr, SUPERUSER_ID, context=context)
980
981         def process_row(row):
982             """Process a single PO (or POT) entry."""
983             # skip empty rows and rows where the translation field (=last fiefd) is empty
984             #if (not row) or (not row[-1]):
985             #    return
986
987             # dictionary which holds values for this line of the csv file
988             # {'lang': ..., 'type': ..., 'name': ..., 'res_id': ...,
989             #  'src': ..., 'value': ..., 'module':...}
990             dic = dict.fromkeys(('name', 'res_id', 'src', 'type', 'imd_model', 'imd_name', 'module', 'value', 'comments'))
991             dic['lang'] = lang
992             for i, field in enumerate(f):
993                 dic[field] = row[i]
994
995             # Get the `reference` comments from the POT.
996             src = row[3]
997             if pot_reader and src in pot_targets:
998                 pot_targets[src]['targets'] = filter(lambda x: x != row[:3], pot_targets[src]['targets'])
999                 pot_targets[src]['value'] = row[4]
1000                 if not pot_targets[src]['targets']:
1001                     del pot_targets[src]
1002
1003             # This would skip terms that fail to specify a res_id
1004             if not dic.get('res_id'):
1005                 return
1006
1007             res_id = dic.pop('res_id')
1008             if res_id and isinstance(res_id, (int, long)) \
1009                 or (isinstance(res_id, basestring) and res_id.isdigit()):
1010                     dic['res_id'] = int(res_id)
1011                     dic['module'] = module_name
1012             else:
1013                 tmodel = dic['name'].split(',')[0]
1014                 if '.' in res_id:
1015                     tmodule, tname = res_id.split('.', 1)
1016                 else:
1017                     tmodule = False
1018                     tname = res_id
1019                 dic['imd_model'] = tmodel
1020                 dic['imd_name'] =  tname
1021                 dic['module'] = tmodule
1022                 dic['res_id'] = None
1023
1024             irt_cursor.push(dic)
1025
1026         # First process the entries from the PO file (doing so also fills/removes
1027         # the entries from the POT file).
1028         for row in reader:
1029             process_row(row)
1030
1031         # Then process the entries implied by the POT file (which is more
1032         # correct w.r.t. the targets) if some of them remain.
1033         pot_rows = []
1034         for src in pot_targets:
1035             value = pot_targets[src]['value']
1036             for type, name, res_id in pot_targets[src]['targets']:
1037                 pot_rows.append((type, name, res_id, src, value, comments))
1038         for row in pot_rows:
1039             process_row(row)
1040
1041         irt_cursor.finish()
1042         trans_obj.clear_caches()
1043         if verbose:
1044             _logger.info("translation file loaded succesfully")
1045     except IOError:
1046         filename = '[lang: %s][format: %s]' % (iso_lang or 'new', fileformat)
1047         _logger.exception("couldn't read translation file %s", filename)
1048
1049 def get_locales(lang=None):
1050     if lang is None:
1051         lang = locale.getdefaultlocale()[0]
1052
1053     if os.name == 'nt':
1054         lang = _LOCALE2WIN32.get(lang, lang)
1055
1056     def process(enc):
1057         ln = locale._build_localename((lang, enc))
1058         yield ln
1059         nln = locale.normalize(ln)
1060         if nln != ln:
1061             yield nln
1062
1063     for x in process('utf8'): yield x
1064
1065     prefenc = locale.getpreferredencoding()
1066     if prefenc:
1067         for x in process(prefenc): yield x
1068
1069         prefenc = {
1070             'latin1': 'latin9',
1071             'iso-8859-1': 'iso8859-15',
1072             'cp1252': '1252',
1073         }.get(prefenc.lower())
1074         if prefenc:
1075             for x in process(prefenc): yield x
1076
1077     yield lang
1078
1079
1080
1081 def resetlocale():
1082     # locale.resetlocale is bugged with some locales.
1083     for ln in get_locales():
1084         try:
1085             return locale.setlocale(locale.LC_ALL, ln)
1086         except locale.Error:
1087             continue
1088
1089 def load_language(cr, lang):
1090     """Loads a translation terms for a language.
1091     Used mainly to automate language loading at db initialization.
1092
1093     :param lang: language ISO code with optional _underscore_ and l10n flavor (ex: 'fr', 'fr_BE', but not 'fr-BE')
1094     :type lang: str
1095     """
1096     registry = openerp.registry(cr.dbname)
1097     language_installer = registry['base.language.install']
1098     oid = language_installer.create(cr, SUPERUSER_ID, {'lang': lang})
1099     language_installer.lang_install(cr, SUPERUSER_ID, [oid], context=None)
1100
1101 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
1102