diff --git a/addons/fleet/data/fleet_cars_data.xml b/addons/fleet/data/fleet_cars_data.xml
index 34412ec64c2..c2f4c011ddc 100644
--- a/addons/fleet/data/fleet_cars_data.xml
+++ b/addons/fleet/data/fleet_cars_data.xml
@@ -56,7 +56,7 @@
Citroen
-
+
Corre La Licorne
@@ -128,7 +128,7 @@
Lancia
-
+
Land Rover
@@ -236,7 +236,7 @@
Subaru
-
+
Tesla Motors
diff --git a/addons/website_sale/i18n/website_sale.pot b/addons/website_sale/i18n/website_sale.pot
index ff700b10b6c..ed359bf1a12 100644
--- a/addons/website_sale/i18n/website_sale.pot
+++ b/addons/website_sale/i18n/website_sale.pot
@@ -2655,7 +2655,7 @@ msgid "eCommerce Shipping Costs"
msgstr ""
#. module: website_sale
-#: model_terms:ir.ui.view,arch_db:website_sale.search count
+#: model_terms:ir.ui.view,arch_db:website_sale.search_count_box
msgid "found)"
msgstr ""
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index cff60a50803..c4c9c001343 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -109,7 +109,7 @@
-
+
( found)
diff --git a/addons/website_sale_coupon/tests/test_shop_sale_coupon.py b/addons/website_sale_coupon/tests/test_shop_sale_coupon.py
index 24061326689..e34b6904638 100644
--- a/addons/website_sale_coupon/tests/test_shop_sale_coupon.py
+++ b/addons/website_sale_coupon/tests/test_shop_sale_coupon.py
@@ -11,5 +11,5 @@ class TestUi(HttpCase):
def test_01_admin_shop_sale_coupon_tour(self):
# pre enable "Show # found" option to avoid race condition...
- self.env.ref("website_sale.search count").write({"active": True})
+ self.env.ref("website_sale.search_count_box").write({"active": True})
self.start_tour("/", 'shop_sale_coupon', login="admin")
diff --git a/debian/control b/debian/control
index 2cba3381590..a88c570b198 100644
--- a/debian/control
+++ b/debian/control
@@ -30,6 +30,7 @@ Depends:
python3-mako,
python3-mock,
python3-passlib,
+ python3-polib,
python3-psutil,
python3-psycopg2,
python3-pydot,
diff --git a/odoo/addons/base/data/res.country.state.csv b/odoo/addons/base/data/res.country.state.csv
index 32d84c78980..9a5535bf462 100644
--- a/odoo/addons/base/data/res.country.state.csv
+++ b/odoo/addons/base/data/res.country.state.csv
@@ -504,7 +504,7 @@ state_mx_nay,mx,"Nayarit","NAY"
state_mx_nl,mx,"Nuevo León","NLE"
state_mx_oax,mx,"Oaxaca","OAX"
state_mx_pue,mx,"Puebla","PUE"
-state_mx_q roo,mx,"Quintana Roo","ROO"
+state_mx_q_roo,mx,"Quintana Roo","ROO"
state_mx_qro,mx,"Querétaro","QUE"
state_mx_sin,mx,"Sinaloa","SIN"
state_mx_slp,mx,"San Luis Potosí","SLP"
diff --git a/odoo/addons/base/models/ir_translation.py b/odoo/addons/base/models/ir_translation.py
index bb3918b70c3..9c3b5a18fcc 100644
--- a/odoo/addons/base/models/ir_translation.py
+++ b/odoo/addons/base/models/ir_translation.py
@@ -55,37 +55,6 @@ class IrTranslationImport(object):
""" Feed a translation, as a dictionary, into the cursor """
params = dict(trans_dict, state="translated")
- if params['type'] == 'view':
- # ugly hack for QWeb views - pending refactoring of translations in master
- if params['imd_model'] == 'website':
- params['imd_model'] = "ir.ui.view"
- # non-QWeb views do not need a matching res_id in case they do not
- # have an xml id -> force to 0 to avoid dropping them
- elif params['res_id'] is None and not params['imd_name']:
- # maybe we should insert this translation for all views of the
- # given model?
- params['res_id'] = 0
-
- # backward compatibility: convert 'field', 'help', 'view' into 'model'
- if params['type'] == 'field':
- model, field = params['name'].split(',')
- params['type'] = 'model'
- params['name'] = 'ir.model.fields,field_description'
- params['imd_model'] = 'ir.model.fields'
- params['imd_name'] = 'field_%s__%s' % (model.replace('.', '_'), field)
-
- elif params['type'] == 'help':
- model, field = params['name'].split(',')
- params['type'] = 'model'
- params['name'] = 'ir.model.fields,help'
- params['imd_model'] = 'ir.model.fields'
- params['imd_name'] = 'field_%s__%s' % (model.replace('.', '_'), field)
-
- elif params['type'] == 'view':
- params['type'] = 'model'
- params['name'] = 'ir.ui.view,arch_db'
- params['imd_model'] = "ir.ui.view"
-
self._rows.append((params['name'], params['lang'], params['res_id'],
params['src'], params['type'], params['imd_model'],
params['module'], params['imd_name'], params['value'],
diff --git a/odoo/addons/test_translation_import/i18n/fr.po b/odoo/addons/test_translation_import/i18n/fr.po
index 3e3abcfe2ab..25056bc043b 100644
--- a/odoo/addons/test_translation_import/i18n/fr.po
+++ b/odoo/addons/test_translation_import/i18n/fr.po
@@ -23,6 +23,12 @@ msgstr ""
msgid "1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB"
msgstr "1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB in french"
+#. module: test_translation_import
+#: selection:test.translation.import,import_type:0
+msgid "Bar"
+msgstr "Bar in french"
+
+# Note: the line in pot is 21 and is 14 here
#. module: test_translation_import
#: code:addons/test_translation_import/models.py:14
#, python-format
@@ -30,15 +36,20 @@ msgid "Ijkl"
msgstr "Ijkl in french"
#. module: test_translation_import
-#: model:ir.model,name:test_translation_import.model_test_translation_import
-msgid "test.translation.import"
-msgstr "test.translation.import in french"
+#: selection:test.translation.import,import_type:0
+msgid "Foo"
+msgstr "Foo in french"
#. module: test_translation_import
#: model:ir.model.fields,help:test_translation_import.field_test_translation_import__name
msgid "Efgh"
msgstr "Efgh in french"
+#. module: test_translation_import
+#: model:ir.model,name:test_translation_import.model_test_translation_import
+msgid "Test: Translation Import"
+msgstr "Test: Import de traductions"
+
#. module: test_translation_import
#: model:ir.actions.act_window,name:test_translation_import.action_test_translation_import
#: model:ir.ui.menu,name:test_translation_import.menu_test_translation_import
diff --git a/odoo/addons/test_translation_import/i18n/fr_BE.po b/odoo/addons/test_translation_import/i18n/fr_BE.po
index 1d41cc94dcb..371d1f00606 100644
--- a/odoo/addons/test_translation_import/i18n/fr_BE.po
+++ b/odoo/addons/test_translation_import/i18n/fr_BE.po
@@ -21,8 +21,8 @@ msgstr "Ijkl in belgian french"
#. module: test_translation_import
#: model:ir.model,name:test_translation_import.model_test_translation_import
-msgid "test.translation.import"
-msgstr "test.translation.import in belgian french"
+msgid "Test: Translation Import"
+msgstr "Test: Import de traductions in belgian french"
#. module: test_translation_import
#: model:ir.model.fields,help:test_translation_import.field_test_translation_import__name
diff --git a/odoo/addons/test_translation_import/i18n/test_translation_import.pot b/odoo/addons/test_translation_import/i18n/test_translation_import.pot
index 39333d2b363..d9e87023131 100644
--- a/odoo/addons/test_translation_import/i18n/test_translation_import.pot
+++ b/odoo/addons/test_translation_import/i18n/test_translation_import.pot
@@ -30,14 +30,29 @@ msgid "1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB"
msgstr ""
#. module: test_translation_import
-#: code:addons/test_translation_import/models.py:14
+#: code:addons/test_translation_import/models.py:21
#, python-format
-msgid "Ijkl"
+msgid "Accounting"
msgstr ""
#. module: test_translation_import
-#: model:ir.model,name:test_translation_import.model_test_translation_import
-msgid "test.translation.import"
+#: selection:test.translation.import,import_type:0
+msgid "Bar"
+msgstr ""
+
+#. module: test_translation_import
+#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__create_uid
+msgid "Created by"
+msgstr ""
+
+#. module: test_translation_import
+#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__create_date
+msgid "Created on"
+msgstr ""
+
+#. module: test_translation_import
+#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__display_name
+msgid "Display Name"
msgstr ""
#. module: test_translation_import
@@ -45,25 +60,64 @@ msgstr ""
msgid "Efgh"
msgstr ""
+#. module: test_translation_import
+#: selection:test.translation.import,import_type:0
+msgid "Foo"
+msgstr ""
+
+#. module: test_translation_import
+#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__id
+msgid "ID"
+msgstr ""
+
+#. module: test_translation_import
+#: code:addons/test_translation_import/models.py:21
+#, python-format
+msgid "Ijkl"
+msgstr ""
+
+#. module: test_translation_import
+#: code:addons/test_translation_import/models.py:19
+#, python-format
+msgid "Klingon"
+msgstr ""
+
+#. module: test_translation_import
+#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import____last_update
+msgid "Last Modified on"
+msgstr ""
+
+#. module: test_translation_import
+#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__write_uid
+msgid "Last Updated by"
+msgstr ""
+
+#. module: test_translation_import
+#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__write_date
+msgid "Last Updated on"
+msgstr ""
+
+#. module: test_translation_import
+#: model:ir.ui.menu,name:test_translation_import.menu_test_translation
+msgid "Test translation"
+msgstr ""
+
#. module: test_translation_import
#: model:ir.actions.act_window,name:test_translation_import.action_test_translation_import
#: model:ir.ui.menu,name:test_translation_import.menu_test_translation_import
msgid "Test translation import"
msgstr ""
-#. module: test_translation_import
-#: model:ir.ui.menu,name:test_translation_import.menu_test_translation
-msgid "Test translation"
-msgstr ""
-
#. module: test_translation_import
#: code:addons/base/models/arthur.py:42
#: code:addons/base/models/arthur.py:43
+#, python-format
msgid "Test translation with two code lines"
msgstr ""
#. module: test_translation_import
#: code:addons/base/models/ford.py:42
+#, python-format
msgid "Test translation with a code type but different line number in pot"
msgstr ""
@@ -71,5 +125,11 @@ msgstr ""
#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__other_name
#: code:addons/base/models/marvin.py:42
#: code:addons/base/models/trillian.py:42
+#, python-format
msgid "Test translation with two code type and model"
msgstr ""
+
+#. module: test_translation_import
+#: model:ir.model,name:test_translation_import.model_test_translation_import
+msgid "Test: Translation Import"
+msgstr ""
diff --git a/odoo/addons/test_translation_import/models.py b/odoo/addons/test_translation_import/models.py
index 9e803190eea..35c3c33d5dc 100644
--- a/odoo/addons/test_translation_import/models.py
+++ b/odoo/addons/test_translation_import/models.py
@@ -10,6 +10,10 @@ class m(models.TransientModel):
name = fields.Char('1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB',
size=32, help='Efgh')
other_name = fields.Char('Test translation with two code type and model')
+ import_type = fields.Selection([
+ ('foo', 'Foo'),
+ ('bar', 'Bar'),
+ ])
_('Ijkl')
diff --git a/odoo/addons/test_translation_import/tests/test_term_count.py b/odoo/addons/test_translation_import/tests/test_term_count.py
index 58716ad6ec5..90bef9417ae 100644
--- a/odoo/addons/test_translation_import/tests/test_term_count.py
+++ b/odoo/addons/test_translation_import/tests/test_term_count.py
@@ -17,11 +17,32 @@ class TestTermCount(common.TransactionCase):
Just make sure we have as many translation entries as we wanted.
"""
odoo.tools.trans_load(self.cr, 'test_translation_import/i18n/fr.po', 'fr_FR', module_name='test_translation_import', verbose=False)
- ids = self.env['ir.translation'].search([
+ translations = self.env['ir.translation'].search([
('lang', '=', 'fr_FR'),
('src', '=', '1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB'),
- ])
- self.assertEqual(len(ids), 2)
+ ], order='type')
+ self.assertEqual(len(translations), 2)
+ self.assertEqual(translations[0].type, 'code')
+ self.assertEqual(translations[0].module, 'test_translation_import')
+ self.assertEqual(translations[0].name, 'addons/test_translation_import/models.py')
+ self.assertEqual(translations[0].comments, '')
+ self.assertEqual(translations[0].res_id, 15)
+ self.assertEqual(translations[1].type, 'model')
+ self.assertEqual(translations[1].module, 'test_translation_import')
+ self.assertEqual(translations[1].name, 'ir.model.fields,field_description')
+ self.assertEqual(translations[1].comments, '')
+ field = self.env['ir.model.fields'].search([('model', '=', 'test.translation.import'), ('name', '=', 'name')])
+ self.assertEqual(translations[1].res_id, field.id)
+
+ translations = self.env['ir.translation'].search([
+ ('lang', '=', 'fr_FR'),
+ ('type', '=', 'selection'),
+ ('module', '=', 'test_translation_import'),
+ ], order='src')
+ self.assertEqual(len(translations), 2)
+ self.assertEqual(translations[0].name, 'test.translation.import,import_type')
+ self.assertEqual(translations[0].res_id, 0)
+
def test_count_term_module(self):
"""
@@ -34,6 +55,7 @@ class TestTermCount(common.TransactionCase):
('module', '=', 'test_translation_import'),
])
self.assertEqual(len(translations), 1)
+ self.assertEqual(translations.res_id, 21)
def test_noupdate(self):
"""
diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py
index 16ce6af0bfd..160cce4f6e4 100644
--- a/odoo/tools/translate.py
+++ b/odoo/tools/translate.py
@@ -7,6 +7,7 @@ import io
import locale
import logging
import os
+import polib
import re
import tarfile
import tempfile
@@ -15,6 +16,7 @@ from collections import defaultdict
from datetime import datetime
from os.path import join
+from pathlib import Path
from babel.messages import extract
from lxml import etree, html
@@ -476,257 +478,275 @@ def unquote(str):
"""Returns unquoted PO term string, with special PO characters unescaped"""
return re_escaped_char.sub(_sub_replacement, str[1:-1])
-# class to handle po files
-class PoFile(object):
- def __init__(self, buffer):
- # TextIOWrapper closes its underlying buffer on close *and* can't
- # handle actual file objects (on python 2)
- self.buffer = codecs.StreamReaderWriter(
- stream=buffer,
- Reader=codecs.getreader('utf-8'),
- Writer=codecs.getwriter('utf-8')
- )
+def TranslationFileReader(source, fileformat='po'):
+ """ Iterate over translation file to return Odoo translation entries """
+ if fileformat == 'csv':
+ return CSVFileReader(source)
+ if fileformat == 'po':
+ return PoFileReader(source)
+ _logger.info('Bad file format: %s', fileformat)
+ raise Exception(_('Bad file format: %s') % fileformat)
+
+class CSVFileReader:
+ def __init__(self, source):
+ self.source = pycompat.csv_reader(source, quotechar='"', delimiter=',')
+ # read the first line of the file (it contains columns titles)
+ self.fields = next(self.source)
def __iter__(self):
- self.buffer.seek(0)
- self.lines = self._get_lines()
- self.lines_count = len(self.lines)
+ for entry in self.source:
+ yield zip(self.fields, entry)
- self.first = True
- self.extra_lines= []
- return self
+class PoFileReader:
+ """ Iterate over po file to return Odoo translation entries """
+ def __init__(self, source):
- def _get_lines(self):
- lines = self.buffer.readlines()
- # remove the BOM (Byte Order Mark):
- if len(lines):
- lines[0] = lines[0].lstrip(u"\ufeff")
+ def get_pot_path(source_name):
+ # when fileobj is a TemporaryFile, its name is an inter in P3, a string in P2
+ if isinstance(source_name, str) and source_name.endswith('.po'):
+ # Normally the path looks like /path/to/xxx/i18n/lang.po
+ # and we try to find the corresponding
+ # /path/to/xxx/i18n/xxx.pot file.
+ # (Sometimes we have 'i18n_extra' instead of just 'i18n')
+ path = Path(source_name)
+ filename = path.parent.parent.name + '.pot'
+ pot_path = path.with_name(filename)
+ return pot_path.exists() and str(pot_path) or False
+ return False
- lines.append('') # ensure that the file ends with at least an empty line
- return lines
-
- def cur_line(self):
- return self.lines_count - len(self.lines)
-
- def next(self):
- trans_type = name = res_id = source = trad = module = None
- if self.extra_lines:
- trans_type, name, res_id, source, trad, comments = self.extra_lines.pop(0)
- if not res_id:
- res_id = '0'
+ # polib accepts a path or the file content as a string, not a fileobj
+ if isinstance(source, str):
+ self.pofile = polib.pofile(source)
+ pot_path = get_pot_path(source)
else:
- comments = []
- targets = []
- line = None
- fuzzy = False
- while not line:
- if 0 == len(self.lines):
- raise StopIteration()
- line = self.lines.pop(0).strip()
- while line.startswith('#'):
- if line.startswith('#~ '):
- break
- if line.startswith('#.'):
- line = line[2:].strip()
- if not line.startswith('module:'):
- comments.append(line)
- else:
- module = line[7:].strip()
- elif line.startswith('#:'):
- # Process the `reference` comments. Each line can specify
- # multiple targets (e.g. model, view, code, selection,
- # ...). For each target, we will return an additional
- # entry.
- for lpart in line[2:].strip().split(' '):
- trans_info = lpart.strip().split(':',2)
- if trans_info and len(trans_info) == 2:
- # looks like the translation trans_type is missing, which is not
- # unexpected because it is not a GetText standard. Default: 'code'
- trans_info[:0] = ['code']
- if trans_info and len(trans_info) == 3:
- # this is a ref line holding the destination info (model, field, record)
- targets.append(trans_info)
- elif line.startswith('#,') and (line[2:].strip() == 'fuzzy'):
- fuzzy = True
- line = self.lines.pop(0).strip()
- if not self.lines:
- raise StopIteration()
- while not line:
- # allow empty lines between comments and msgid
- line = self.lines.pop(0).strip()
- if line.startswith('#~ '):
- while line.startswith('#~ ') or not line.strip():
- if 0 == len(self.lines):
- raise StopIteration()
- line = self.lines.pop(0)
- # This has been a deprecated entry, don't return anything
- return next(self)
+ # either a BufferedIOBase or result from NamedTemporaryFile
+ self.pofile = polib.pofile(source.read().decode())
+ pot_path = get_pot_path(source.name)
- if not line.startswith('msgid'):
- raise Exception("malformed file: bad line: %s" % line)
- source = unquote(line[6:])
- line = self.lines.pop(0).strip()
- if not source and self.first:
- self.first = False
- # if the source is "" and it's the first msgid, it's the special
- # msgstr with the information about the translate and the
- # translator; we skip it
- self.extra_lines = []
- while line:
- line = self.lines.pop(0).strip()
- return next(self)
+ if pot_path:
+ # Make a reader for the POT file
+ # (Because the POT comments are correct on GitHub but the
+ # PO comments tends to be outdated. See LP bug 933496.)
+ self.pofile.merge(polib.pofile(pot_path))
- while not line.startswith('msgstr'):
- if not line:
- raise Exception('malformed file at %d'% self.cur_line())
- source += unquote(line)
- line = self.lines.pop(0).strip()
+ def __iter__(self):
+ for entry in self.pofile:
+ if entry.obsolete:
+ continue
- trad = unquote(line[7:])
- line = self.lines.pop(0).strip()
- while line:
- trad += unquote(line)
- line = self.lines.pop(0).strip()
+ # in case of moduleS keep only the first
+ match = re.match(r"(module[s]?): (\w+)", entry.comment)
+ _, module = match.groups()
+ comments = "\n".join([c for c in entry.comment.split('\n') if not c.startswith('module:')])
+ source = entry.msgid
+ translation = entry.msgstr
+ found_code_occurrence = False
+ for occurrence, line_number in entry.occurrences:
+ match = re.match(r'(model|model_terms):([\w.]+),([\w]+):(\w+)\.(\w+)', occurrence)
+ if match:
+ type, model_name, field_name, module, xmlid = match.groups()
+ yield {
+ 'type': type,
+ 'imd_model': model_name,
+ 'name': model_name+','+field_name,
+ 'imd_name': xmlid,
+ 'res_id': None,
+ 'src': source,
+ 'value': translation,
+ 'comments': comments,
+ 'module': module,
+ }
+ continue
- if targets and not fuzzy:
- # Use the first target for the current entry (returned at the
- # end of this next() call), and keep the others to generate
- # additional entries (returned the next next() calls).
- trans_type, name, res_id = targets.pop(0)
- code = trans_type == 'code'
- for t, n, r in targets:
- if t == 'code' and code: continue
- if t == 'code':
- code = True
- self.extra_lines.append((t, n, r, source, trad, comments))
+ match = re.match(r'(code):([\w/.]+)', occurrence)
+ if match:
+ type, name = match.groups()
+ if found_code_occurrence:
+ # unicity constrain on code translation
+ continue
+ found_code_occurrence = True
+ yield {
+ 'type': type,
+ 'name': name,
+ 'src': source,
+ 'value': translation,
+ 'comments': comments,
+ 'res_id': int(line_number),
+ 'module': module,
+ }
+ continue
- if name is None:
- if not fuzzy:
- _logger.warning('Missing "#:" formatted comment at line %d for the following source:\n\t%s',
- self.cur_line(), source[:30])
- return next(self)
- return trans_type, name, res_id, source, trad, '\n'.join(comments), module
- __next__ = next
+ match = re.match(r'(selection):([\w.]+),([\w]+)', occurrence)
+ if match:
+ type, model_name, field_name = match.groups()
+ yield {
+ 'type': type,
+ 'model': model_name,
+ 'name': model_name+','+field_name,
+ 'src': source,
+ 'value': translation,
+ 'comments': comments,
+ 'res_id': int(line_number),
+ 'module': module,
+ }
+ continue
- def write_infos(self, modules):
+ match = re.match(r'(sql_constraint|constraint):([\w.]+)', occurrence)
+ if match:
+ type, model = match.groups()
+ yield {
+ 'type': type,
+ 'name': model,
+ 'src': source,
+ 'value': translation,
+ 'comments': comments,
+ 'res_id': int(line_number),
+ 'module': module,
+ }
+ continue
+
+ _logger.error("malformed po file: unknown occurrence: %s", occurrence)
+
+def TranslationFileWriter(target, fileformat='po', lang=None, modules=None):
+ """ Iterate over translation file to return Odoo translation entries """
+ if fileformat == 'csv':
+ return CSVFileWriter(target)
+
+ if fileformat == 'po':
+ return PoFileWriter(target, modules=modules, lang=lang)
+
+ if fileformat == 'tgz':
+ return TarFileWriter(target, lang=lang)
+
+ raise Exception(_('Unrecognized extension: must be one of '
+ '.csv, .po, or .tgz (received .%s).') % fileformat)
+
+
+class CSVFileWriter:
+ def __init__(self, target):
+ self.writer = pycompat.csv_writer(target, dialect='UNIX')
+ # write header first
+ self.writer.writerow(("module","type","name","res_id","src","value","comments"))
+
+
+ def write_rows(self, rows):
+ for module, type, name, res_id, src, trad, comments in rows:
+ comments = '\n'.join(comments)
+ self.writer.writerow((module, type, name, res_id, src, trad, comments))
+
+
+class PoFileWriter:
+ """ Iterate over po file to return Odoo translation entries """
+ def __init__(self, target, modules, lang):
import odoo.release as release
- self.buffer.write(u"# Translation of %(project)s.\n" \
- "# This file contains the translation of the following modules:\n" \
- "%(modules)s" \
- "#\n" \
- "msgid \"\"\n" \
- "msgstr \"\"\n" \
- '''"Project-Id-Version: %(project)s %(version)s\\n"\n''' \
- '''"Report-Msgid-Bugs-To: \\n"\n''' \
- '''"POT-Creation-Date: %(now)s\\n"\n''' \
- '''"PO-Revision-Date: %(now)s\\n"\n''' \
- '''"Last-Translator: <>\\n"\n''' \
- '''"Language-Team: \\n"\n''' \
- '''"MIME-Version: 1.0\\n"\n''' \
- '''"Content-Type: text/plain; charset=UTF-8\\n"\n''' \
- '''"Content-Transfer-Encoding: \\n"\n''' \
- '''"Plural-Forms: \\n"\n''' \
- "\n"
- % { 'project': release.description,
- 'version': release.version,
- 'modules': ''.join("#\t* %s\n" % m for m in modules),
- 'now': datetime.utcnow().strftime('%Y-%m-%d %H:%M')+"+0000",
- }
- )
+ self.buffer = target
+ self.lang = lang
+ self.po = polib.POFile()
- def write(self, modules, tnrs, source, trad, comments=None):
+ self.po.header = "Translation of %s.\n" \
+ "This file contains the translation of the following modules:\n" \
+ "%s" % (release.description, ''.join("\t* %s\n" % m for m in modules))
+ now = datetime.utcnow().strftime('%Y-%m-%d %H:%M+0000')
+ self.po.metadata = {
+ 'Project-Id-Version': "%s %s" % (release.description, release.version),
+ 'Report-Msgid-Bugs-To': '',
+ 'POT-Creation-Date': now,
+ 'PO-Revision-Date': now,
+ 'Last-Translator': '',
+ 'Language-Team': '',
+ 'MIME-Version': '1.0',
+ 'Content-Type': 'text/plain; charset=UTF-8',
+ 'Content-Transfer-Encoding': '',
+ 'Plural-Forms': '',
+ }
- plurial = len(modules) > 1 and 's' or ''
- self.buffer.write(u"#. module%s: %s\n" % (plurial, ', '.join(modules)))
+ def write_rows(self, rows):
+ # we now group the translations by source. That means one translation per source.
+ grouped_rows = {}
+ for module, type, name, res_id, src, trad, comments in rows:
+ row = grouped_rows.setdefault(src, {})
+ row.setdefault('modules', set()).add(module)
+ if not row.get('translation') and trad != src:
+ row['translation'] = trad
+ row.setdefault('tnrs', []).append((type, name, res_id))
+ row.setdefault('comments', set()).update(comments)
+ for src, row in sorted(grouped_rows.items()):
+ if not self.lang:
+ # translation template, so no translation value
+ row['translation'] = ''
+ elif not row.get('translation'):
+ row['translation'] = ''
+ self.add_entry(row['modules'], row['tnrs'], src, row['translation'], row['comments'])
+
+ # buffer expects bytes
+ self.buffer.write(str(self.po).encode())
+
+ def add_entry(self, modules, tnrs, source, trad, comments=None):
+ entry = polib.POEntry(
+ msgid=source,
+ msgstr=trad,
+ )
+ plural = len(modules) > 1 and 's' or ''
+ entry.comment = "module%s: %s" % (plural, ', '.join(modules))
if comments:
- self.buffer.write(u''.join(('#. %s\n' % c for c in comments)))
+ entry.comment += "\n" + "\n".join(comments)
code = False
for typy, name, res_id in tnrs:
- self.buffer.write(u"#: %s:%s:%s\n" % (typy, name, res_id))
if typy == 'code':
code = True
-
+ if isinstance(res_id, int) or res_id.isdigit():
+ # second term of occurrence must be a digit
+ # occurrence line at 0 are discarded when rendered to string
+ entry.occurrences.append((u"%s:%s" % (typy, name), str(res_id)))
+ else:
+ entry.occurrences.append((u"%s:%s:%s" % (typy, name, res_id), ''))
if code:
- # only strings in python code are python formatted
- self.buffer.write(u"#, python-format\n")
+ entry.flags.append("python-format")
+ self.po.append(entry)
- msg = (
- u"msgid %s\n"
- u"msgstr %s\n\n"
- ) % (
- quote(str(source)),
- quote(str(trad))
- )
- self.buffer.write(msg)
+class TarFileWriter:
+
+ def __init__(self, target, lang):
+ self.tar = tarfile.open(fileobj=target, mode='w|gz')
+ self.lang = lang
+
+ def write_rows(self, rows):
+ rows_by_module = defaultdict(list)
+ for row in rows:
+ module = row[0]
+ rows_by_module[module].append(row)
+
+ for mod, modrows in rows_by_module.items():
+ with io.BytesIO() as buf:
+ po = PoFileWriter(buf, modules=[mod], lang=self.lang)
+ po.write_rows(modrows)
+ buf.seek(0)
+
+ info = tarfile.TarInfo(
+ join(mod, 'i18n', '{basename}.{ext}'.format(
+ basename=self.lang or mod,
+ ext='po' if self.lang else 'pot',
+ )))
+ # addfile will read bytes from the buffer so
+ # size *must* be set first
+ info.size = len(buf.getvalue())
+
+ self.tar.addfile(info, fileobj=buf)
+
+ self.tar.close()
# Methods to export the translation file
def trans_export(lang, modules, buffer, format, cr):
- def _process(format, modules, rows, buffer, lang):
- if format == 'csv':
- writer = pycompat.csv_writer(buffer, dialect='UNIX')
- # write header first
- writer.writerow(("module","type","name","res_id","src","value","comments"))
- for module, type, name, res_id, src, trad, comments in rows:
- comments = '\n'.join(comments)
- writer.writerow((module, type, name, res_id, src, trad, comments))
-
- elif format == 'po':
- writer = PoFile(buffer)
- writer.write_infos(modules)
-
- # we now group the translations by source. That means one translation per source.
- grouped_rows = {}
- for module, type, name, res_id, src, trad, comments in rows:
- row = grouped_rows.setdefault(src, {})
- row.setdefault('modules', set()).add(module)
- if not row.get('translation') and trad != src:
- row['translation'] = trad
- row.setdefault('tnrs', []).append((type, name, res_id))
- row.setdefault('comments', set()).update(comments)
-
- for src, row in sorted(grouped_rows.items()):
- if not lang:
- # translation template, so no translation value
- row['translation'] = ''
- elif not row.get('translation'):
- row['translation'] = ''
- writer.write(row['modules'], row['tnrs'], src, row['translation'], row['comments'])
-
- elif format == 'tgz':
- rows_by_module = defaultdict(list)
- for row in rows:
- module = row[0]
- rows_by_module[module].append(row)
-
- with tarfile.open(fileobj=buffer, mode='w|gz') as tar:
- for mod, modrows in rows_by_module.items():
- with io.BytesIO() as buf:
- _process('po', [mod], modrows, buf, lang)
- buf.seek(0)
-
- info = tarfile.TarInfo(
- join(mod, 'i18n', '{basename}.{ext}'.format(
- basename=lang or mod,
- ext='po' if lang else 'pot',
- )))
- # addfile will read bytes from the buffer so
- # size *must* be set first
- info.size = len(buf.getvalue())
-
- tar.addfile(info, fileobj=buf)
- else:
- raise Exception(_('Unrecognized extension: must be one of '
- '.csv, .po, or .tgz (received .%s).') % format)
-
translations = trans_generate(lang, modules, cr)
modules = set(t[0] for t in translations)
- _process(format, modules, translations, buffer, lang)
+ writer = TranslationFileWriter(buffer, fileformat=format, lang=lang, modules=modules)
+ writer.write_rows(translations)
del translations
@@ -1032,60 +1052,9 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True,
# lets create the language with locale information
Lang.load_lang(lang=lang, lang_name=lang_name)
- # Parse also the POT: it will possibly provide additional targets.
- # (Because the POT comments are correct on Launchpad but not the
- # PO comments due to a Launchpad limitation. See LP bug 933496.)
- pot_reader = []
- use_pot_reference = False
-
# now, the serious things: we read the language file
fileobj.seek(0)
- if fileformat == 'csv':
- reader = pycompat.csv_reader(fileobj, quotechar='"', delimiter=',')
- # read the first line of the file (it contains columns titles)
- fields = next(reader)
-
- elif fileformat == 'po':
- reader = PoFile(fileobj)
- fields = ['type', 'name', 'res_id', 'src', 'value', 'comments', 'module']
-
- # Make a reader for the POT file and be somewhat defensive for the
- # stable branch.
-
- # when fileobj is a TemporaryFile, its name is an interget in P3, a string in P2
- if isinstance(fileobj.name, str) and fileobj.name.endswith('.po'):
- try:
- # Normally the path looks like /path/to/xxx/i18n/lang.po
- # and we try to find the corresponding
- # /path/to/xxx/i18n/xxx.pot file.
- # (Sometimes we have 'i18n_extra' instead of just 'i18n')
- addons_module_i18n, _ignored = os.path.split(fileobj.name)
- addons_module, i18n_dir = os.path.split(addons_module_i18n)
- addons, module = os.path.split(addons_module)
- pot_handle = file_open(os.path.join(
- addons, module, i18n_dir, module + '.pot'), mode='rb')
- pot_reader = PoFile(pot_handle)
- use_pot_reference = True
- except:
- pass
-
- else:
- _logger.info('Bad file format: %s', fileformat)
- raise Exception(_('Bad file format: %s') % fileformat)
-
- # Read the POT references, and keep them indexed by source string.
- class Target(object):
- def __init__(self):
- self.value = None
- self.targets = set() # set of (type, name, res_id)
- self.comments = None
-
- pot_targets = defaultdict(Target)
- for type, name, res_id, src, _ignored, comments, module in pot_reader:
- if type is not None:
- target = pot_targets[src]
- target.targets.add((type, name, type != 'code' and res_id or 0))
- target.comments = comments
+ reader = TranslationFileReader(fileobj, fileformat=fileformat)
# read the rest of the file
irt_cursor = Translation._get_import_cursor()
@@ -1098,43 +1067,14 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True,
dic = dict.fromkeys(('type', 'name', 'res_id', 'src', 'value',
'comments', 'imd_model', 'imd_name', 'module'))
dic['lang'] = lang
- dic.update(zip(fields, row))
+ dic.update(row)
# do not import empty values
if not env.context.get('create_empty_translation', False) and not dic['value']:
return
- if use_pot_reference:
- # discard the target from the POT targets.
- src = dic['src']
- target_key = (dic['type'], dic['name'], dic['type'] != 'code' and dic['res_id'] or 0)
- target = pot_targets.get(src)
- if not target or target_key not in target.targets:
- _logger.info("Translation '%s' (%s, %s, %s) not found in reference pot, skipping",
- src[:60], dic['type'], dic['name'], dic['res_id'])
- return
-
- target.value = dic['value']
- target.targets.discard(target_key)
-
- # This would skip terms that fail to specify a res_id
- res_id = dic['res_id']
- if not res_id and dic['type'] != 'code':
- return
-
- if isinstance(res_id, int) or \
- (isinstance(res_id, str) and res_id.isdigit()):
- dic['res_id'] = int(res_id)
- if module_name:
- dic['module'] = module_name
- else:
- # res_id is an xml id
- dic['res_id'] = None
- dic['imd_model'] = dic['name'].split(',')[0]
- if '.' in res_id:
- dic['module'], dic['imd_name'] = res_id.split('.', 1)
- else:
- dic['module'], dic['imd_name'] = module_name, res_id
+ if dic['type'] == 'code' and module_name:
+ dic['module'] = module_name
irt_cursor.push(dic)
@@ -1143,17 +1083,6 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True,
for row in reader:
process_row(row)
- if use_pot_reference:
- # Then process the entries implied by the POT file (which is more
- # correct w.r.t. the targets) if some of them remain.
- pot_rows = []
- for src, target in pot_targets.items():
- if target.value:
- for type, name, res_id in target.targets:
- pot_rows.append((type, name, res_id, src, target.value, target.comments))
- for row in pot_rows:
- process_row(row)
-
irt_cursor.finish()
Translation.clear_caches()
if verbose:
diff --git a/requirements.txt b/requirements.txt
index 723b96efae5..68bd257ddc6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -21,6 +21,7 @@ num2words==0.5.6
ofxparse==0.16
passlib==1.6.5
Pillow==4.0.0
+polib==1.1.0
psutil==4.3.1; sys_platform != 'win32'
psycopg2==2.7.3.1; sys_platform != 'win32'
pydot==1.2.3
diff --git a/setup.cfg b/setup.cfg
index 33f97e70429..5b0fa922022 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -28,6 +28,7 @@ requires =
python3-pillow
python3-psutil
python3-psycopg2
+ python3-polib
python3-pydot
python3-pyldap
python3-pyparsing
diff --git a/setup/win32/winpy_requirements.txt b/setup/win32/winpy_requirements.txt
index 41d3fd5adff..ad7081536b4 100644
--- a/setup/win32/winpy_requirements.txt
+++ b/setup/win32/winpy_requirements.txt
@@ -15,6 +15,7 @@ num2words==0.5.4
ofxparse==0.16
passlib==1.6.5
Pillow>=3.4.1
+polib>=1.1.0
psutil>=4.3.1
psycopg2==2.7.1
pydot==1.2.3