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