add file_size
[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.pooler as pooler
29 import openerp.sql_db as sql_db
30 import re
31 import logging
32 import tarfile
33 import tempfile
34 import threading
35 from babel.messages import extract
36 from os.path import join
37
38 from datetime import datetime
39 from lxml import etree
40
41 import config
42 import misc
43 from misc import UpdateableStr
44 from misc import SKIPPED_ELEMENT_TYPES
45 import osutil
46 from openerp import SUPERUSER_ID
47
48 _logger = logging.getLogger(__name__)
49
50 # used to notify web client that these translations should be loaded in the UI
51 WEB_TRANSLATION_COMMENT = "openerp-web"
52
53 _LOCALE2WIN32 = {
54     'af_ZA': 'Afrikaans_South Africa',
55     'sq_AL': 'Albanian_Albania',
56     'ar_SA': 'Arabic_Saudi Arabia',
57     'eu_ES': 'Basque_Spain',
58     'be_BY': 'Belarusian_Belarus',
59     'bs_BA': 'Serbian (Latin)',
60     'bg_BG': 'Bulgarian_Bulgaria',
61     'ca_ES': 'Catalan_Spain',
62     'hr_HR': 'Croatian_Croatia',
63     'zh_CN': 'Chinese_China',
64     'zh_TW': 'Chinese_Taiwan',
65     'cs_CZ': 'Czech_Czech Republic',
66     'da_DK': 'Danish_Denmark',
67     'nl_NL': 'Dutch_Netherlands',
68     'et_EE': 'Estonian_Estonia',
69     'fa_IR': 'Farsi_Iran',
70     'ph_PH': 'Filipino_Philippines',
71     'fi_FI': 'Finnish_Finland',
72     'fr_FR': 'French_France',
73     'fr_BE': 'French_France',
74     'fr_CH': 'French_France',
75     'fr_CA': 'French_France',
76     'ga': 'Scottish Gaelic',
77     'gl_ES': 'Galician_Spain',
78     'ka_GE': 'Georgian_Georgia',
79     'de_DE': 'German_Germany',
80     'el_GR': 'Greek_Greece',
81     'gu': 'Gujarati_India',
82     'he_IL': 'Hebrew_Israel',
83     'hi_IN': 'Hindi',
84     'hu': 'Hungarian_Hungary',
85     'is_IS': 'Icelandic_Iceland',
86     'id_ID': 'Indonesian_indonesia',
87     'it_IT': 'Italian_Italy',
88     'ja_JP': 'Japanese_Japan',
89     'kn_IN': 'Kannada',
90     'km_KH': 'Khmer',
91     'ko_KR': 'Korean_Korea',
92     'lo_LA': 'Lao_Laos',
93     'lt_LT': 'Lithuanian_Lithuania',
94     'lat': 'Latvian_Latvia',
95     'ml_IN': 'Malayalam_India',
96     'id_ID': 'Indonesian_indonesia',
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     'mi_NZ': 'Maori',
107     'sr_CS': 'Serbian (Cyrillic)_Serbia and Montenegro',
108     'sk_SK': 'Slovak_Slovakia',
109     'sl_SI': 'Slovenian_Slovenia',
110     #should find more specific locales for spanish countries,
111     #but better than nothing
112     'es_AR': 'Spanish_Spain',
113     'es_BO': 'Spanish_Spain',
114     'es_CL': 'Spanish_Spain',
115     'es_CO': 'Spanish_Spain',
116     'es_CR': 'Spanish_Spain',
117     'es_DO': 'Spanish_Spain',
118     'es_EC': 'Spanish_Spain',
119     'es_ES': 'Spanish_Spain',
120     'es_GT': 'Spanish_Spain',
121     'es_HN': 'Spanish_Spain',
122     'es_MX': 'Spanish_Spain',
123     'es_NI': 'Spanish_Spain',
124     'es_PA': 'Spanish_Spain',
125     'es_PE': 'Spanish_Spain',
126     'es_PR': 'Spanish_Spain',
127     'es_PY': 'Spanish_Spain',
128     'es_SV': 'Spanish_Spain',
129     'es_UY': 'Spanish_Spain',
130     'es_VE': 'Spanish_Spain',
131     'sv_SE': 'Swedish_Sweden',
132     'ta_IN': 'English_Australia',
133     'th_TH': 'Thai_Thailand',
134     'mi_NZ': 'Maori',
135     'tr_TR': 'Turkish_Turkey',
136     'uk_UA': 'Ukrainian_Ukraine',
137     'vi_VN': 'Vietnamese_Viet Nam',
138     'tlh_TLH': 'Klingon',
139
140 }
141
142
143 class UNIX_LINE_TERMINATOR(csv.excel):
144     lineterminator = '\n'
145
146 csv.register_dialect("UNIX", UNIX_LINE_TERMINATOR)
147
148 #
149 # Warning: better use self.pool.get('ir.translation')._get_source if you can
150 #
151 def translate(cr, name, source_type, lang, source=None):
152     if source and name:
153         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))
154     elif name:
155         cr.execute('select value from ir_translation where lang=%s and type=%s and name=%s', (lang, source_type, str(name)))
156     elif source:
157         cr.execute('select value from ir_translation where lang=%s and type=%s and src=%s', (lang, source_type, source))
158     res_trans = cr.fetchone()
159     res = res_trans and res_trans[0] or False
160     return res
161
162 class GettextAlias(object):
163
164     def _get_db(self):
165         # find current DB based on thread/worker db name (see netsvc)
166         db_name = getattr(threading.currentThread(), 'dbname', None)
167         if db_name:
168             return sql_db.db_connect(db_name)
169
170     def _get_cr(self, frame, allow_create=True):
171         is_new_cr = False
172         cr = frame.f_locals.get('cr', frame.f_locals.get('cursor'))
173         if not cr:
174             s = frame.f_locals.get('self', {})
175             cr = getattr(s, 'cr', None)
176         if not cr and allow_create:
177             db = self._get_db()
178             if db:
179                 cr = db.cursor()
180                 is_new_cr = True
181         return cr, is_new_cr
182
183     def _get_uid(self, frame):
184         return frame.f_locals.get('uid') or frame.f_locals.get('user')
185
186     def _get_lang(self, frame):
187         lang = None
188         ctx = frame.f_locals.get('context')
189         if not ctx:
190             kwargs = frame.f_locals.get('kwargs')
191             if kwargs is None:
192                 args = frame.f_locals.get('args')
193                 if args and isinstance(args, (list, tuple)) \
194                         and isinstance(args[-1], dict):
195                     ctx = args[-1]
196             elif isinstance(kwargs, dict):
197                 ctx = kwargs.get('context')
198         if ctx:
199             lang = ctx.get('lang')
200         s = frame.f_locals.get('self', {})
201         if not lang:
202             c = getattr(s, 'localcontext', None)
203             if c:
204                 lang = c.get('lang')
205         if not 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                 lang = pool.get('res.users').context_get(cr, uid)['lang']
215         return lang
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                     pool = pooler.get_pool(cr.dbname)
234                     res = pool.get('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 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     for n in de:
557         res.extend(trans_parse_view(n))
558     return res
559
560 # tests whether an object is in a list of modules
561 def in_modules(object_name, modules):
562     if 'all' in modules:
563         return True
564
565     module_dict = {
566         'ir': 'base',
567         'res': 'base',
568         'workflow': 'base',
569     }
570     module = object_name.split('.')[0]
571     module = module_dict.get(module, module)
572     return module in modules
573
574
575 def babel_extract_qweb(fileobj, keywords, comment_tags, options):
576     """Babel message extractor for qweb template files.
577     :param fileobj: the file-like object the messages should be extracted from
578     :param keywords: a list of keywords (i.e. function names) that should
579                      be recognized as translation functions
580     :param comment_tags: a list of translator tags to search for and
581                          include in the results
582     :param options: a dictionary of additional options (optional)
583     :return: an iterator over ``(lineno, funcname, message, comments)``
584              tuples
585     :rtype: ``iterator``
586     """
587     result = []
588     def handle_text(text, lineno):
589         text = (text or "").strip()
590         if len(text) > 1: # Avoid mono-char tokens like ':' ',' etc.
591             result.append((lineno, None, text, []))
592
593     # not using elementTree.iterparse because we need to skip sub-trees in case
594     # the ancestor element had a reason to be skipped
595     def iter_elements(current_element):
596         for el in current_element:
597             if isinstance(el, SKIPPED_ELEMENT_TYPES): continue
598             if "t-js" not in el.attrib and \
599                     not ("t-jquery" in el.attrib and "t-operation" not in el.attrib) and \
600                     not ("t-translation" in el.attrib and el.attrib["t-translation"].strip() == "off"):
601                 handle_text(el.text, el.sourceline)
602                 for att in ('title', 'alt', 'label', 'placeholder'):
603                     if att in el.attrib:
604                         handle_text(el.attrib[att], el.sourceline)
605                 iter_elements(el)
606             handle_text(el.tail, el.sourceline)
607
608     tree = etree.parse(fileobj)
609     iter_elements(tree.getroot())
610
611     return result
612
613
614 def trans_generate(lang, modules, cr):
615     dbname = cr.dbname
616
617     pool = pooler.get_pool(dbname)
618     trans_obj = pool.get('ir.translation')
619     model_data_obj = pool.get('ir.model.data')
620     uid = 1
621     l = pool.models.items()
622     l.sort()
623
624     query = 'SELECT name, model, res_id, module'    \
625             '  FROM ir_model_data'
626
627     query_models = """SELECT m.id, m.model, imd.module
628             FROM ir_model AS m, ir_model_data AS imd
629             WHERE m.id = imd.res_id AND imd.model = 'ir.model' """
630
631     if 'all_installed' in modules:
632         query += ' WHERE module IN ( SELECT name FROM ir_module_module WHERE state = \'installed\') '
633         query_models += " AND imd.module in ( SELECT name FROM ir_module_module WHERE state = 'installed') "
634     query_param = None
635     if 'all' not in modules:
636         query += ' WHERE module IN %s'
637         query_models += ' AND imd.module in %s'
638         query_param = (tuple(modules),)
639     query += ' ORDER BY module, model, name'
640     query_models += ' ORDER BY module, model'
641
642     cr.execute(query, query_param)
643
644     _to_translate = []
645     def push_translation(module, type, name, id, source, comments=None):
646         tuple = (module, source, name, id, type, comments or [])
647         # empty and one-letter terms are ignored, they probably are not meant to be
648         # translated, and would be very hard to translate anyway.
649         if not source or len(source.strip()) <= 1:
650             _logger.debug("Ignoring empty or 1-letter source term: %r", tuple)
651             return
652         if tuple not in _to_translate:
653             _to_translate.append(tuple)
654
655     def encode(s):
656         if isinstance(s, unicode):
657             return s.encode('utf8')
658         return s
659
660     for (xml_name,model,res_id,module) in cr.fetchall():
661         module = encode(module)
662         model = encode(model)
663         xml_name = "%s.%s" % (module, encode(xml_name))
664
665         if not pool.get(model):
666             _logger.error("Unable to find object %r", model)
667             continue
668
669         exists = pool.get(model).exists(cr, uid, res_id)
670         if not exists:
671             _logger.warning("Unable to find object %r with id %d", model, res_id)
672             continue
673         obj = pool.get(model).browse(cr, uid, res_id)
674
675         if model=='ir.ui.view':
676             d = etree.XML(encode(obj.arch))
677             for t in trans_parse_view(d):
678                 push_translation(module, 'view', encode(obj.model), 0, t)
679         elif model=='ir.actions.wizard':
680             service_name = 'wizard.'+encode(obj.wiz_name)
681             import openerp.netsvc as netsvc
682             if netsvc.Service._services.get(service_name):
683                 obj2 = netsvc.Service._services[service_name]
684                 for state_name, state_def in obj2.states.iteritems():
685                     if 'result' in state_def:
686                         result = state_def['result']
687                         if result['type'] != 'form':
688                             continue
689                         name = "%s,%s" % (encode(obj.wiz_name), state_name)
690
691                         def_params = {
692                             'string': ('wizard_field', lambda s: [encode(s)]),
693                             'selection': ('selection', lambda s: [encode(e[1]) for e in ((not callable(s)) and s or [])]),
694                             'help': ('help', lambda s: [encode(s)]),
695                         }
696
697                         # export fields
698                         if not result.has_key('fields'):
699                             _logger.warning("res has no fields: %r", result)
700                             continue
701                         for field_name, field_def in result['fields'].iteritems():
702                             res_name = name + ',' + field_name
703
704                             for fn in def_params:
705                                 if fn in field_def:
706                                     transtype, modifier = def_params[fn]
707                                     for val in modifier(field_def[fn]):
708                                         push_translation(module, transtype, res_name, 0, val)
709
710                         # export arch
711                         arch = result['arch']
712                         if arch and not isinstance(arch, UpdateableStr):
713                             d = etree.XML(arch)
714                             for t in trans_parse_view(d):
715                                 push_translation(module, 'wizard_view', name, 0, t)
716
717                         # export button labels
718                         for but_args in result['state']:
719                             button_name = but_args[0]
720                             button_label = but_args[1]
721                             res_name = name + ',' + button_name
722                             push_translation(module, 'wizard_button', res_name, 0, button_label)
723
724         elif model=='ir.model.fields':
725             try:
726                 field_name = encode(obj.name)
727             except AttributeError, exc:
728                 _logger.error("name error in %s: %s", xml_name, str(exc))
729                 continue
730             objmodel = pool.get(obj.model)
731             if not objmodel or not field_name in objmodel._columns:
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._table._columns.items():
783             if field_def.translate:
784                 name = model + "," + field_name
785                 try:
786                     trad = getattr(obj, field_name) or ''
787                 except:
788                     trad = ''
789                 push_translation(module, 'model', name, xml_name, encode(trad))
790
791         # End of data for ir.model.data query results
792
793     cr.execute(query_models, query_param)
794
795     def push_constraint_msg(module, term_type, model, msg):
796         if not hasattr(msg, '__call__'):
797             push_translation(encode(module), term_type, encode(model), 0, encode(msg))
798
799     def push_local_constraints(module, model, cons_type='sql_constraints'):
800         """Climb up the class hierarchy and ignore inherited constraints
801            from other modules"""
802         term_type = 'sql_constraint' if cons_type == 'sql_constraints' else 'constraint'
803         msg_pos = 2 if cons_type == 'sql_constraints' else 1
804         for cls in model.__class__.__mro__:
805             if getattr(cls, '_module', None) != module:
806                 continue
807             constraints = getattr(cls, '_local_' + cons_type, [])
808             for constraint in constraints:
809                 push_constraint_msg(module, term_type, model._name, constraint[msg_pos])
810             
811     for (_, model, module) in cr.fetchall():
812         model_obj = pool.get(model)
813
814         if not model_obj:
815             _logger.error("Unable to find object %r", model)
816             continue
817
818         if model_obj._constraints:
819             push_local_constraints(module, model_obj, 'constraints')
820
821         if model_obj._sql_constraints:
822             push_local_constraints(module, model_obj, 'sql_constraints')
823
824     def get_module_from_path(path, mod_paths=None):
825         if not mod_paths:
826             # First, construct a list of possible paths
827             def_path = os.path.abspath(os.path.join(config.config['root_path'], 'addons'))     # default addons path (base)
828             ad_paths= map(lambda m: os.path.abspath(m.strip()),config.config['addons_path'].split(','))
829             mod_paths=[def_path]
830             for adp in ad_paths:
831                 mod_paths.append(adp)
832                 if not os.path.isabs(adp):
833                     mod_paths.append(adp)
834                 elif adp.startswith(def_path):
835                     mod_paths.append(adp[len(def_path)+1:])
836         for mp in mod_paths:
837             if path.startswith(mp) and (os.path.dirname(path) != mp):
838                 path = path[len(mp)+1:]
839                 return path.split(os.path.sep)[0]
840         return 'base'   # files that are not in a module are considered as being in 'base' module
841
842     modobj = pool.get('ir.module.module')
843     installed_modids = modobj.search(cr, uid, [('state', '=', 'installed')])
844     installed_modules = map(lambda m: m['name'], modobj.read(cr, uid, installed_modids, ['name']))
845
846     root_path = os.path.join(config.config['root_path'], 'addons')
847
848     apaths = map(os.path.abspath, map(str.strip, config.config['addons_path'].split(',')))
849     if root_path in apaths:
850         path_list = apaths
851     else :
852         path_list = [root_path,] + apaths
853
854     # Also scan these non-addon paths
855     for bin_path in ['osv', 'report' ]:
856         path_list.append(os.path.join(config.config['root_path'], bin_path))
857
858     _logger.debug("Scanning modules at paths: ", path_list)
859
860     mod_paths = []
861
862     def verified_module_filepaths(fname, path, root):
863         fabsolutepath = join(root, fname)
864         frelativepath = fabsolutepath[len(path):]
865         display_path = "addons%s" % frelativepath
866         module = get_module_from_path(fabsolutepath, mod_paths=mod_paths)
867         if (('all' in modules) or (module in modules)) and module in installed_modules:
868             return module, fabsolutepath, frelativepath, display_path
869         return None, None, None, None
870
871     def babel_extract_terms(fname, path, root, extract_method="python", trans_type='code',
872                                extra_comments=None, extract_keywords={'_': None}):
873         module, fabsolutepath, _, display_path = verified_module_filepaths(fname, path, root)
874         extra_comments = extra_comments or []
875         if module:
876             src_file = open(fabsolutepath, 'r')
877             try:
878                 for lineno, message, comments in extract.extract(extract_method, src_file,
879                                                                  keywords=extract_keywords):
880                     push_translation(module, trans_type, display_path, lineno,
881                                      encode(message), comments + extra_comments)
882             except Exception:
883                 _logger.exception("Failed to extract terms from %s", fabsolutepath)
884             finally:
885                 src_file.close()
886
887     for path in path_list:
888         _logger.debug("Scanning files of modules at %s", path)
889         for root, dummy, files in osutil.walksymlinks(path):
890             for fname in fnmatch.filter(files, '*.py'):
891                 babel_extract_terms(fname, path, root)
892             for fname in fnmatch.filter(files, '*.mako'):
893                 babel_extract_terms(fname, path, root, trans_type='report')
894             # Javascript source files in the static/src/js directory, rest is ignored (libs)
895             if fnmatch.fnmatch(root, '*/static/src/js*'):
896                 for fname in fnmatch.filter(files, '*.js'):
897                     babel_extract_terms(fname, path, root, 'javascript',
898                                         extra_comments=[WEB_TRANSLATION_COMMENT],
899                                         extract_keywords={'_t': None, '_lt': None})
900             # QWeb template files
901             if fnmatch.fnmatch(root, '*/static/src/xml*'):
902                 for fname in fnmatch.filter(files, '*.xml'):
903                     babel_extract_terms(fname, path, root, 'openerp.tools.translate:babel_extract_qweb',
904                                         extra_comments=[WEB_TRANSLATION_COMMENT])
905
906     out = []
907     _to_translate.sort()
908     # translate strings marked as to be translated
909     for module, source, name, id, type, comments in _to_translate:
910         trans = '' if not lang else trans_obj._get_source(cr, uid, name, type, lang, source)
911         out.append([module, type, name, id, source, encode(trans) or '', comments])
912     return out
913
914 def trans_load(cr, filename, lang, verbose=True, module_name=None, context=None):
915     try:
916         fileobj = misc.file_open(filename)
917         _logger.info("loading %s", filename)
918         fileformat = os.path.splitext(filename)[-1][1:].lower()
919         result = trans_load_data(cr, fileobj, fileformat, lang, verbose=verbose, module_name=module_name, context=context)
920         fileobj.close()
921         return result
922     except IOError:
923         if verbose:
924             _logger.error("couldn't read translation file %s", filename)
925         return None
926
927 def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, module_name=None, context=None):
928     """Populates the ir_translation table."""
929     if verbose:
930         _logger.info('loading translation file for language %s', lang)
931     if context is None:
932         context = {}
933     db_name = cr.dbname
934     pool = pooler.get_pool(db_name)
935     lang_obj = pool.get('res.lang')
936     trans_obj = pool.get('ir.translation')
937     iso_lang = misc.get_iso_codes(lang)
938     try:
939         ids = lang_obj.search(cr, SUPERUSER_ID, [('code','=', lang)])
940
941         if not ids:
942             # lets create the language with locale information
943             lang_obj.load_lang(cr, SUPERUSER_ID, lang=lang, lang_name=lang_name)
944
945
946         # now, the serious things: we read the language file
947         fileobj.seek(0)
948         if fileformat == 'csv':
949             reader = csv.reader(fileobj, quotechar='"', delimiter=',')
950             # read the first line of the file (it contains columns titles)
951             for row in reader:
952                 f = row
953                 break
954         elif fileformat == 'po':
955             reader = TinyPoFile(fileobj)
956             f = ['type', 'name', 'res_id', 'src', 'value', 'comments']
957         else:
958             _logger.error('Bad file format: %s', fileformat)
959             raise Exception(_('Bad file format'))
960
961         # read the rest of the file
962         line = 1
963         irt_cursor = trans_obj._get_import_cursor(cr, SUPERUSER_ID, context=context)
964
965         for row in reader:
966             line += 1
967             # skip empty rows and rows where the translation field (=last fiefd) is empty
968             #if (not row) or (not row[-1]):
969             #    continue
970
971             # dictionary which holds values for this line of the csv file
972             # {'lang': ..., 'type': ..., 'name': ..., 'res_id': ...,
973             #  'src': ..., 'value': ..., 'module':...}
974             dic = dict.fromkeys(('name', 'res_id', 'src', 'type', 'imd_model', 'imd_name', 'module', 'value', 'comments'))
975             dic['lang'] = lang
976             for i, field in enumerate(f):
977                 dic[field] = row[i]
978
979             # This would skip terms that fail to specify a res_id
980             if not dic.get('res_id'):
981                 continue
982
983             res_id = dic.pop('res_id')
984             if res_id and isinstance(res_id, (int, long)) \
985                 or (isinstance(res_id, basestring) and res_id.isdigit()):
986                     dic['res_id'] = int(res_id)
987                     dic['module'] = module_name
988             else:
989                 tmodel = dic['name'].split(',')[0]
990                 if '.' in res_id:
991                     tmodule, tname = res_id.split('.', 1)
992                 else:
993                     tmodule = False
994                     tname = res_id
995                 dic['imd_model'] = tmodel
996                 dic['imd_name'] =  tname
997                 dic['module'] = tmodule
998                 dic['res_id'] = None
999
1000             irt_cursor.push(dic)
1001
1002         irt_cursor.finish()
1003         if verbose:
1004             _logger.info("translation file loaded succesfully")
1005     except IOError:
1006         filename = '[lang: %s][format: %s]' % (iso_lang or 'new', fileformat)
1007         _logger.exception("couldn't read translation file %s", filename)
1008
1009 def get_locales(lang=None):
1010     if lang is None:
1011         lang = locale.getdefaultlocale()[0]
1012
1013     if os.name == 'nt':
1014         lang = _LOCALE2WIN32.get(lang, lang)
1015
1016     def process(enc):
1017         ln = locale._build_localename((lang, enc))
1018         yield ln
1019         nln = locale.normalize(ln)
1020         if nln != ln:
1021             yield nln
1022
1023     for x in process('utf8'): yield x
1024
1025     prefenc = locale.getpreferredencoding()
1026     if prefenc:
1027         for x in process(prefenc): yield x
1028
1029         prefenc = {
1030             'latin1': 'latin9',
1031             'iso-8859-1': 'iso8859-15',
1032             'cp1252': '1252',
1033         }.get(prefenc.lower())
1034         if prefenc:
1035             for x in process(prefenc): yield x
1036
1037     yield lang
1038
1039
1040
1041 def resetlocale():
1042     # locale.resetlocale is bugged with some locales.
1043     for ln in get_locales():
1044         try:
1045             return locale.setlocale(locale.LC_ALL, ln)
1046         except locale.Error:
1047             continue
1048
1049 def load_language(cr, lang):
1050     """Loads a translation terms for a language.
1051     Used mainly to automate language loading at db initialization.
1052
1053     :param lang: language ISO code with optional _underscore_ and l10n flavor (ex: 'fr', 'fr_BE', but not 'fr-BE')
1054     :type lang: str
1055     """
1056     pool = pooler.get_pool(cr.dbname)
1057     language_installer = pool.get('base.language.install')
1058     uid = 1
1059     oid = language_installer.create(cr, uid, {'lang': lang})
1060     language_installer.lang_install(cr, uid, [oid], context=None)
1061
1062 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
1063