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