diff options
-rw-r--r-- | CHANGES.rst | 4 | ||||
-rw-r--r-- | src/zope/i18n/__init__.py | 22 | ||||
-rw-r--r-- | src/zope/i18n/format.py | 8 | ||||
-rw-r--r-- | src/zope/i18n/gettextmessagecatalog.py | 48 | ||||
-rw-r--r-- | src/zope/i18n/interfaces/__init__.py | 18 | ||||
-rw-r--r-- | src/zope/i18n/negotiator.py | 4 | ||||
-rw-r--r-- | src/zope/i18n/simpletranslationdomain.py | 8 | ||||
-rw-r--r-- | src/zope/i18n/tests/de-default.mo | bin | 347 -> 483 bytes | |||
-rw-r--r-- | src/zope/i18n/tests/de-default.po | 6 | ||||
-rw-r--r-- | src/zope/i18n/tests/en-default.mo | bin | 343 -> 478 bytes | |||
-rw-r--r-- | src/zope/i18n/tests/en-default.po | 6 | ||||
-rw-r--r-- | src/zope/i18n/tests/pl-default.po | 22 | ||||
-rw-r--r-- | src/zope/i18n/tests/test_imessagecatalog.py | 4 | ||||
-rw-r--r-- | src/zope/i18n/tests/test_plurals.py | 109 | ||||
-rw-r--r-- | src/zope/i18n/translationdomain.py | 46 | ||||
-rw-r--r-- | src/zope/i18n/zcml.py | 2 |
16 files changed, 271 insertions, 36 deletions
diff --git a/CHANGES.rst b/CHANGES.rst index cbd6bd4..18238e6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,9 @@ 4.5 (unreleased) ================ -- Nothing changed yet. +- Add support for pluralization. ``translate()`` now takes the + additional optional arguments ``msgid_plural``, ``default_plural`` + and ``number`` in order to support it. 4.4 (2018-10-05) diff --git a/src/zope/i18n/__init__.py b/src/zope/i18n/__init__.py index 3088074..854223c 100644 --- a/src/zope/i18n/__init__.py +++ b/src/zope/i18n/__init__.py @@ -39,6 +39,7 @@ class _FallbackNegotiator(object): def getLanguage(self, _allowed, _context): return None + _fallback_negotiator = _FallbackNegotiator() @@ -79,8 +80,10 @@ def negotiate(context): negotiator = queryUtility(INegotiator, default=_fallback_negotiator) return negotiator.getLanguage(ALLOWED_LANGUAGES, context) + def translate(msgid, domain=None, mapping=None, context=None, - target_language=None, default=None): + target_language=None, default=None, msgid_plural=None, + default_plural=None, number=None): """Translate text. First setup some test components: @@ -160,6 +163,9 @@ def translate(msgid, domain=None, mapping=None, context=None, domain = msgid.domain default = msgid.default mapping = msgid.mapping + msgid_plural = msgid.msgid_plural + default_plural = msgid.default_plural + number = msgid.number if default is None: default = text_type(msgid) @@ -181,7 +187,10 @@ def translate(msgid, domain=None, mapping=None, context=None, if target_language is None and context is not None: target_language = negotiate(context) - return util.translate(msgid, mapping, context, target_language, default) + return util.translate( + msgid, mapping, context, target_language, default, + msgid_plural, default_plural, number) + def interpolate(text, mapping=None): """Insert the data passed from mapping into the text. @@ -197,15 +206,16 @@ def interpolate(text, mapping=None): Interpolation variables can be used more than once in the text: - >>> print(interpolate(u"This is $name version ${version}. ${name} $version!", - ... mapping)) + >>> print(interpolate( + ... u"This is $name version ${version}. ${name} $version!", mapping)) This is Zope version 3. Zope 3! In case if the variable wasn't found in the mapping or '$$' form was used no substitution will happens: - >>> print(interpolate(u"This is $name $version. $unknown $$name $${version}.", - ... mapping)) + >>> print(interpolate( + ... u"This is $name $version. $unknown $$name $${version}.", mapping)) + This is Zope 3. $unknown $$name $${version}. >>> print(interpolate(u"This is ${name}")) diff --git a/src/zope/i18n/format.py b/src/zope/i18n/format.py index ee2e239..96f5ef3 100644 --- a/src/zope/i18n/format.py +++ b/src/zope/i18n/format.py @@ -33,6 +33,7 @@ try: except NameError: pass # Py3 + def roundHalfUp(n): """Works like round() in python2.x @@ -42,18 +43,20 @@ def roundHalfUp(n): """ return math.floor(n + math.copysign(0.5, n)) + def _findFormattingCharacterInPattern(char, pattern): return [entry for entry in pattern if isinstance(entry, tuple) and entry[0] == char] + class DateTimeParseError(Exception): """Error is raised when parsing of datetime failed.""" + @implementer(IDateTimeFormat) class DateTimeFormat(object): __doc__ = IDateTimeFormat.__doc__ - _DATETIMECHARS = "aGyMdEDFwWhHmsSkKz" calendar = None @@ -486,11 +489,11 @@ class NumberFormat(object): return text_type(text) - DEFAULT = 0 IN_QUOTE = 1 IN_DATETIMEFIELD = 2 + class DateTimePatternParseError(Exception): """DateTime Pattern Parse Error""" @@ -760,6 +763,7 @@ SUFFIX = 7 PADDING4 = 8 GROUPING = 9 + class NumberPatternParseError(Exception): """Number Pattern Parse Error""" diff --git a/src/zope/i18n/gettextmessagecatalog.py b/src/zope/i18n/gettextmessagecatalog.py index 307e5be..99f1951 100644 --- a/src/zope/i18n/gettextmessagecatalog.py +++ b/src/zope/i18n/gettextmessagecatalog.py @@ -14,6 +14,7 @@ """A simple implementation of a Message Catalog. """ +from functools import wraps from gettext import GNUTranslations from zope.i18n.interfaces import IGlobalMessageCatalog from zope.interface import implementer @@ -22,7 +23,24 @@ from zope.interface import implementer class _KeyErrorRaisingFallback(object): def ugettext(self, message): raise KeyError(message) + + def ungettext(self, singular, plural, n): + raise KeyError(singular) + gettext = ugettext + ngettext = ungettext + + +def plural_formatting(func): + @wraps(func) + def pformat(catalog, singular, plural, n, *args, **kwargs): + msg = func(catalog, singular, plural, n, *args, **kwargs) + try: + return msg % n + except TypeError: + # The message cannot be formatted : return it "raw". + return msg + return pformat @implementer(IGlobalMessageCatalog) @@ -33,13 +51,20 @@ class GettextMessageCatalog(object): def __init__(self, language, domain, path_to_file): """Initialize the message catalog""" - self.language = language.decode('utf-8') if isinstance(language, bytes) else language - self.domain = domain.decode("utf-8") if isinstance(domain, bytes) else domain + self.language = ( + language.decode('utf-8') if isinstance(language, bytes) + else language) + self.domain = ( + domain.decode("utf-8") if isinstance(domain, bytes) + else domain) self._path_to_file = path_to_file self.reload() catalog = self._catalog catalog.add_fallback(_KeyErrorRaisingFallback()) - self._gettext = catalog.gettext if str is not bytes else catalog.ugettext + self._gettext = ( + catalog.gettext if str is not bytes else catalog.ugettext) + self._ngettext = ( + catalog.ngettext if str is not bytes else catalog.ungettext) def reload(self): 'See IMessageCatalog' @@ -50,6 +75,23 @@ class GettextMessageCatalog(object): 'See IMessageCatalog' return self._gettext(id) + @plural_formatting + def getPluralMessage(self, singular, plural, n): + 'See IMessageCatalog' + return self._ngettext(singular, plural, n) + + @plural_formatting + def queryPluralMessage(self, singular, plural, n, dft1=None, dft2=None): + 'See IMessageCatalog' + try: + return self._ngettext(singular, plural, n) + except KeyError: + # Here, we use the catalog plural function to determine + # if `n` triggers a plural form or not. + if self._catalog.plural(n): + return dft2 + return dft1 + def queryMessage(self, id, default=None): 'See IMessageCatalog' try: diff --git a/src/zope/i18n/interfaces/__init__.py b/src/zope/i18n/interfaces/__init__.py index 397c8f5..5935fc1 100644 --- a/src/zope/i18n/interfaces/__init__.py +++ b/src/zope/i18n/interfaces/__init__.py @@ -60,13 +60,16 @@ class IMessageCatalog(Interface): An exception is raised if the message id is not found. """ - + def queryMessage(msgid, default=None): """Look for the appropriate text for the given message id. If the message id is not found, default is returned. """ + # FIX ME ADD PLURAL METHODS IF WE DECIDE TO KEEP THEM SEPARATED FROM + # THE SINGULAR METHODS. + language = TextLine( title=u"Language", description=u"The language the catalog translates to.", @@ -118,6 +121,10 @@ class ITranslationDomain(Interface): target_language -- The language to translate to. + msgid_plural -- The id of the plural message that should be translated. + + number -- The number of items linked to the plural of the message. + context -- An object that provides contextual information for determining client language preferences. It must implement or have an adapter that implements IUserPreferredLanguages. @@ -133,7 +140,8 @@ class ITranslationDomain(Interface): required=True) def translate(msgid, mapping=None, context=None, target_language=None, - default=None): + default=None, msgid_plural=None, default_plural=None, + number=None): """Return the translation for the message referred to by msgid. Return the default if no translation is found. @@ -147,6 +155,7 @@ class ITranslationDomain(Interface): """ + class IFallbackTranslationDomainFactory(Interface): """Factory for creating fallback translation domains @@ -158,6 +167,7 @@ class IFallbackTranslationDomainFactory(Interface): """Return a fallback translation domain for the given domain id. """ + class ITranslator(Interface): """A collaborative object which contains the domain, context, and locale. @@ -165,7 +175,8 @@ class ITranslator(Interface): the domain, context, and target language. """ - def translate(msgid, mapping=None, default=None): + def translate(msgid, mapping=None, default=None, + msgid_plural=None, default_plural=None, number=None): """Translate the source msgid using the given mapping. See ITranslationService for details. @@ -212,6 +223,7 @@ class IUserPreferredLanguages(Interface): languages first. """ + class IModifiableUserPreferredLanguages(IUserPreferredLanguages): def setPreferredLanguages(languages): diff --git a/src/zope/i18n/negotiator.py b/src/zope/i18n/negotiator.py index 66417c0..3c5fa17 100644 --- a/src/zope/i18n/negotiator.py +++ b/src/zope/i18n/negotiator.py @@ -14,16 +14,17 @@ """Language Negotiator """ from zope.interface import implementer - from zope.i18n.interfaces import INegotiator from zope.i18n.interfaces import IUserPreferredLanguages + def normalize_lang(lang): lang = lang.strip().lower() lang = lang.replace('_', '-') lang = lang.replace(' ', '') return lang + def normalize_langs(langs): # Make a mapping from normalized->original so we keep can match # the normalized lang and return the original string. @@ -32,6 +33,7 @@ def normalize_langs(langs): n_langs[normalize_lang(l)] = l return n_langs + @implementer(INegotiator) class Negotiator(object): diff --git a/src/zope/i18n/simpletranslationdomain.py b/src/zope/i18n/simpletranslationdomain.py index b50fb68..b093db8 100644 --- a/src/zope/i18n/simpletranslationdomain.py +++ b/src/zope/i18n/simpletranslationdomain.py @@ -18,8 +18,10 @@ from zope.component import getUtility from zope.i18n.interfaces import ITranslationDomain, INegotiator from zope.i18n import interpolate + text_type = str if bytes is not str else unicode + @implementer(ITranslationDomain) class SimpleTranslationDomain(object): """This is the simplest implementation of the ITranslationDomain I @@ -39,12 +41,14 @@ class SimpleTranslationDomain(object): def __init__(self, domain, messages=None): """Initializes the object. No arguments are needed.""" - self.domain = domain.decode("utf-8") if isinstance(domain, bytes) else domain + self.domain = ( + domain.decode("utf-8") if isinstance(domain, bytes) else domain) self.messages = messages if messages is not None else {} assert self.messages is not None def translate(self, msgid, mapping=None, context=None, - target_language=None, default=None): + target_language=None, default=None, msgid_plural=None, + default_plural=None, number=None): '''See interface ITranslationDomain''' # Find out what the target language should be if target_language is None and context is not None: diff --git a/src/zope/i18n/tests/de-default.mo b/src/zope/i18n/tests/de-default.mo Binary files differindex 392a0ff..f49b8da 100644 --- a/src/zope/i18n/tests/de-default.mo +++ b/src/zope/i18n/tests/de-default.mo diff --git a/src/zope/i18n/tests/de-default.po b/src/zope/i18n/tests/de-default.po index 99a5f8d..7d3b2f6 100644 --- a/src/zope/i18n/tests/de-default.po +++ b/src/zope/i18n/tests/de-default.po @@ -6,9 +6,15 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=ISO-8859-1\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" msgid "short_greeting" msgstr "Hallo!" msgid "greeting" msgstr "Hallo $name, wie geht es Dir?" + +msgid "There is one file." +msgid_plural "There are %d files." +msgstr[0] "Es gibt eine Datei." +msgstr[1] "Es gibt %d Dateien." diff --git a/src/zope/i18n/tests/en-default.mo b/src/zope/i18n/tests/en-default.mo Binary files differindex 4164029..3e9dc9e 100644 --- a/src/zope/i18n/tests/en-default.mo +++ b/src/zope/i18n/tests/en-default.mo diff --git a/src/zope/i18n/tests/en-default.po b/src/zope/i18n/tests/en-default.po index a3d843b..fc45a11 100644 --- a/src/zope/i18n/tests/en-default.po +++ b/src/zope/i18n/tests/en-default.po @@ -6,9 +6,15 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=ISO-8859-1\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" msgid "short_greeting" msgstr "Hello!" msgid "greeting" msgstr "Hello $name, how are you?" + +msgid "There is one file." +msgid_plural "There are %d files." +msgstr[0] "There is one file." +msgstr[1] "There are %d files." diff --git a/src/zope/i18n/tests/pl-default.po b/src/zope/i18n/tests/pl-default.po new file mode 100644 index 0000000..3951a9c --- /dev/null +++ b/src/zope/i18n/tests/pl-default.po @@ -0,0 +1,22 @@ +msgid "" +msgstr "" +"Project-Id-Version: Zope 3\n" +"PO-Revision-Date: 2018-09-04 11:05+0100\n" +"Last-Translator: Zope 3 contributors\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" + + +msgid "short_greeting" +msgstr "Cześć !" + +msgid "greeting" +msgstr "Cześć $name, jak się masz?" + +msgid "There is one file." +msgid_plural "There are %d files." +msgstr[0] "Istnieje %d plik." +msgstr[1] "Istnieją %d pliki." +msgstr[2] "Istnieją %d plików." diff --git a/src/zope/i18n/tests/test_imessagecatalog.py b/src/zope/i18n/tests/test_imessagecatalog.py index 2856c67..803d4f0 100644 --- a/src/zope/i18n/tests/test_imessagecatalog.py +++ b/src/zope/i18n/tests/test_imessagecatalog.py @@ -43,24 +43,20 @@ class TestIMessageCatalog(unittest.TestCase): self.assertEqual(catalog.getMessage('short_greeting'), 'Hello!') self.assertRaises(KeyError, catalog.getMessage, 'foo') - def testQueryMessage(self): catalog = self._catalog self.assertEqual(catalog.queryMessage('short_greeting'), 'Hello!') self.assertEqual(catalog.queryMessage('foo'), None) self.assertEqual(catalog.queryMessage('foo', 'bar'), 'bar') - def testGetLanguage(self): catalog = self._catalog self.assertEqual(catalog.language, 'en') - def testGetDomain(self): catalog = self._catalog self.assertEqual(catalog.domain, 'default') - def testGetIdentifier(self): catalog = self._catalog self.assertEqual(catalog.getIdentifier(), self._getUniqueIndentifier()) diff --git a/src/zope/i18n/tests/test_plurals.py b/src/zope/i18n/tests/test_plurals.py new file mode 100644 index 0000000..a0ca9bf --- /dev/null +++ b/src/zope/i18n/tests/test_plurals.py @@ -0,0 +1,109 @@ +############################################################################## +# +# Copyright (c) 2001, 2002 Zope Foundation and Contributors. +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## +"""Test a gettext implementation of a Message Catalog. +""" +import os +import unittest +from zope.i18n import tests +from zope.i18n.gettextmessagecatalog import GettextMessageCatalog +from zope.i18n.tests import test_imessagecatalog + + +class TestPlurals(unittest.TestCase): + + def _getMessageCatalog(self, locale, variant="default"): + path = os.path.dirname(tests.__file__) + self._path = os.path.join(path, '%s-%s.mo' % (locale, variant)) + catalog = GettextMessageCatalog(locale, variant, self._path) + return catalog + + def test_GermanPlurals(self): + """Germanic languages such as english and german share the plural + rule. We test the german here. + """ + catalog = self._getMessageCatalog('de') + self.assertEqual(catalog.language, 'de') + + self.assertEqual(catalog.getPluralMessage( + 'There is one file.', 'There are %d files.', 1), + 'Es gibt eine Datei.') + self.assertEqual(catalog.getPluralMessage( + 'There is one file.', 'There are %d files.', 3), + 'Es gibt 3 Dateien.') + + self.assertEqual(catalog.getPluralMessage( + 'There is one file.', 'There are %d files.', 0), + 'Es gibt 0 Dateien.') + + # Unknown id + self.assertRaises(KeyError, catalog.getPluralMessage, + 'There are %d files.', 'bar', 6) + + # Query without default values + self.assertEqual(catalog.queryPluralMessage( + 'There is one file.', 'There are %d files.', 1), + 'Es gibt eine Datei.') + self.assertEqual(catalog.queryPluralMessage( + 'There is one file.', 'There are %d files.', 3), + 'Es gibt 3 Dateien.') + + # Query with default values + self.assertEqual(catalog.queryPluralMessage( + 'There are %d files.', 'There is one file.', 1, + 'Es gibt 1 Datei.', 'Es gibt %d Dateien !', ), + 'Es gibt 1 Datei.') + self.assertEqual(catalog.queryPluralMessage( + 'There are %d files.', 'There is one file.', 3, + 'Es gibt 1 Datei.', 'Es gibt %d Dateien !', ), + 'Es gibt 3 Dateien !') + + def test_PolishPlurals(self): + """Polish has a complex rule for plurals. It makes for a good + test subject. + """ + catalog = self._getMessageCatalog('pl') + self.assertEqual(catalog.language, 'pl') + + self.assertEqual(catalog.getPluralMessage( + 'There is one file.', 'There are %d files.', 0), + "Istnieją 0 plików.") + + self.assertEqual(catalog.getPluralMessage( + 'There is one file.', 'There are %d files.', 1), + "Istnieje 1 plik.") + + self.assertEqual(catalog.getPluralMessage( + 'There is one file.', 'There are %d files.', 3), + "Istnieją 3 pliki.") + + self.assertEqual(catalog.getPluralMessage( + 'There is one file.', 'There are %d files.', 17), + "Istnieją 17 plików.") + + self.assertEqual(catalog.getPluralMessage( + 'There is one file.', 'There are %d files.', 23), + "Istnieją 23 pliki.") + + self.assertEqual(catalog.getPluralMessage( + 'There is one file.', 'There are %d files.', 28), + "Istnieją 28 plików.") + + +def test_suite(): + return unittest.TestSuite(( + unittest.makeSuite(TestPlurals), + )) + +if __name__ == '__main__': + unittest.main(defaultTest='test_suite') diff --git a/src/zope/i18n/translationdomain.py b/src/zope/i18n/translationdomain.py index 684fa9b..c7f064e 100644 --- a/src/zope/i18n/translationdomain.py +++ b/src/zope/i18n/translationdomain.py @@ -34,11 +34,13 @@ LANGUAGE_FALLBACKS = ['en'] text_type = str if bytes is not str else unicode + @zope.interface.implementer(ITranslationDomain) class TranslationDomain(object): def __init__(self, domain, fallbacks=None): - self.domain = domain.decode("utf-8") if isinstance(domain, bytes) else domain + self.domain = ( + domain.decode("utf-8") if isinstance(domain, bytes) else domain) # _catalogs maps (language, domain) to IMessageCatalog instances self._catalogs = {} # _data maps IMessageCatalog.getIdentifier() to IMessageCatalog @@ -63,7 +65,8 @@ class TranslationDomain(object): self._fallbacks = fallbacks def translate(self, msgid, mapping=None, context=None, - target_language=None, default=None): + target_language=None, default=None, + msgid_plural=None, default_plural=None, number=None): """See zope.i18n.interfaces.ITranslationDomain""" # if the msgid is empty, let's save a lot of calculations and return # an empty string. @@ -78,37 +81,43 @@ class TranslationDomain(object): target_language = negotiator.getLanguage(langs, context) return self._recursive_translate( - msgid, mapping, target_language, default, context) + msgid, mapping, target_language, default, context, + msgid_plural, default_plural, number) def _recursive_translate(self, msgid, mapping, target_language, default, - context, seen=None): + context, msgid_plural, default_plural, number, + seen=None): """Recursively translate msg.""" # MessageID attributes override arguments if isinstance(msgid, Message): if msgid.domain != self.domain: - return translate(msgid, msgid.domain, mapping, context, - target_language, default) + return translate( + msgid, msgid.domain, mapping, context, target_language, + default, msgid_plural, default_plural, number) default = msgid.default mapping = msgid.mapping + msgid_plural = msgid.msgid_plural + default_plural = msgid.default_plural + number = msgid.number # Recursively translate mappings, if they are translatable if (mapping is not None and Message in (type(m) for m in mapping.values())): if seen is None: seen = set() - seen.add(msgid) + seen.add((msgid, msgid_plural)) mapping = mapping.copy() for key, value in mapping.items(): if isinstance(value, Message): # TODO Why isn't there an IMessage interface? # https://bugs.launchpad.net/zope3/+bug/220122 - if value in seen: + if (value, value.msgid_plural) in seen: raise ValueError( "Circular reference in mappings detected: %s" % value) mapping[key] = self._recursive_translate( - value, mapping, target_language, - default, context, seen) + value, mapping, target_language, default, context, + msgid_plural, default_plural, number, seen) if default is None: default = text_type(msgid) @@ -128,12 +137,23 @@ class TranslationDomain(object): # single catalog. More importantly, it is extremely helpful # when testing and the test language is used, because it # allows the test language to get the default. - text = self._data[catalog_names[0]].queryMessage( - msgid, default) + if msgid_plural is not None: + # This is a plural + text = self._data[catalog_names[0]].queryPluralMessage( + msgid, msgid_plural, number, default, default_plural) + else: + text = self._data[catalog_names[0]].queryMessage( + msgid, default) else: for name in catalog_names: catalog = self._data[name] - s = catalog.queryMessage(msgid) + if msgid_plural is not None: + # This is a plural + s = catalog.queryPluralMessage( + msgid, msgid_plural, number, + default, default_plural) + else: + s = catalog.queryMessage(msgid) if s is not None: text = s break diff --git a/src/zope/i18n/zcml.py b/src/zope/i18n/zcml.py index 39a4a29..4df9ccb 100644 --- a/src/zope/i18n/zcml.py +++ b/src/zope/i18n/zcml.py @@ -100,7 +100,7 @@ def registerTranslations(_context, directory, domain='*'): loaded = True domain_file = os.path.basename(domain_path) name = domain_file[:-3] - if not name in domains: + if name not in domains: domains[name] = {} domains[name][language] = domain_path if loaded: |