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