From 9962a26a6935789efbf17776967c1493c276d933 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sun, 29 Sep 2024 17:17:12 +0200 Subject: [PATCH 01/45] convert the function that uses google translate to an adapter based lookup, so that extra services can be registered --- .../app/multilingual/browser/translate.py | 74 ++++++++----------- src/plone/app/multilingual/configure.zcml | 6 ++ .../app/multilingual/google_translate.py | 70 ++++++++++++++++++ src/plone/app/multilingual/interfaces.py | 16 ++++ 4 files changed, 123 insertions(+), 43 deletions(-) create mode 100644 src/plone/app/multilingual/google_translate.py diff --git a/src/plone/app/multilingual/browser/translate.py b/src/plone/app/multilingual/browser/translate.py index e5348f3b..ed43f2f3 100644 --- a/src/plone/app/multilingual/browser/translate.py +++ b/src/plone/app/multilingual/browser/translate.py @@ -1,53 +1,23 @@ +import json + from Acquisition import aq_inner from plone.app.multilingual import _ +<<<<<<< HEAD from plone.app.multilingual.interfaces import IMultiLanguageExtraOptionsSchema from plone.app.multilingual.interfaces import ITranslationManager from plone.app.uuid.utils import uuidToObject +======= +from plone.app.multilingual.interfaces import ( + IExternalTranslationService, + ITranslationManager, +) +>>>>>>> 3fcea08 (convert the function that uses google translate to an adapter based lookup, so that extra services can be registered) from plone.base.interfaces import ILanguage -from plone.registry.interfaces import IRegistry from plone.uuid.interfaces import IUUID from Products.Five import BrowserView -from zope.component import getUtility - -import json -import urllib - - -def google_translate(question, key, lang_target, lang_source): - length = len(question) - translated = "" - url = "https://www.googleapis.com/language/translate/v2" - temp_question = question - while length > 400: - temp_question = question[:399] - index = temp_question.rfind(" ") - temp_question = temp_question[:index] - question = question[index:] - length = len(question) - data = { - "key": key, - "target": lang_target, - "source": lang_source, - "q": temp_question, - } - params = urllib.parse.urlencode(data) +from zope.component import getAdapters, getMultiAdapter - retorn = urllib.request.urlopen(url + "?" + params) - translated += json.loads(retorn.read())["data"]["translations"][0][ - "translatedText" - ] - - data = { - "key": key, - "target": lang_target, - "source": lang_source, - "q": temp_question, - } - params = urllib.parse.urlencode(data) - - retorn = urllib.request.urlopen(url + "?" + params) - translated += json.loads(retorn.read())["data"]["translations"][0]["translatedText"] - return json.dumps({"data": translated}) +from plone.app.multilingual import logger class gtranslation_service_dexterity(BrowserView): @@ -83,9 +53,27 @@ def __call__(self): question = question.raw else: return _("Invalid field") - return google_translate( - question, settings.google_translation_key, lang_target, lang_source + + portal_state_view = getMultiAdapter( + (self.context, self.request), name="plone_portal_state" ) + portal = portal_state_view.portal() + adapters = getAdapters((portal,), IExternalTranslationService) + + for adapter_name, adapter in adapters: + logger.debug("Checking adapter named %s", adapter_name) + available_languages = adapter.available_languages() + if ( + not available_languages + or (lang_source, lang_target) in available_languages + ): + translation = adapter.translate_content( + question, lang_source, lang_target + ) + + return json.dumps({"data": translation}) + + return json.dumps({"data": ""}) class TranslationForm(BrowserView): diff --git a/src/plone/app/multilingual/configure.zcml b/src/plone/app/multilingual/configure.zcml index 2eef1c92..4b96e03b 100644 --- a/src/plone/app/multilingual/configure.zcml +++ b/src/plone/app/multilingual/configure.zcml @@ -192,4 +192,10 @@ handler=".setuphandlers.step_uninstall_various" /> + + + diff --git a/src/plone/app/multilingual/google_translate.py b/src/plone/app/multilingual/google_translate.py new file mode 100644 index 00000000..82ba7b26 --- /dev/null +++ b/src/plone/app/multilingual/google_translate.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- + +import json +import urllib + +from plone.app.multilingual.interfaces import ( + IExternalTranslationService, + IMultiLanguageExtraOptionsSchema, +) +from plone.base.interfaces import IPloneSiteRoot +from plone.registry.interfaces import IRegistry +from zope.component import getUtility, adapter +from zope.interface import implementer + + +@implementer(IExternalTranslationService) +@adapter(IPloneSiteRoot) +class GoogleCloudTranslationAPI: + """implement the external translation using Google Cloud Translation API""" + + def __init__(self, context): + self.context = context + + def available_languages(self): + # All languages are supported + return [] + + def translate_content(self, content, source_language, target_language): + registry = getUtility(IRegistry) + settings = registry.forInterface( + IMultiLanguageExtraOptionsSchema, prefix="plone" + ) + + question = content + length = len(question) + translated = "" + url = "https://www.googleapis.com/language/translate/v2" + temp_question = question + while length > 400: + temp_question = question[:399] + index = temp_question.rfind(" ") + temp_question = temp_question[:index] + question = question[index:] + length = len(question) + data = { + "key": settings.google_translation_key, + "target": target_language, + "source": source_language, + "q": temp_question, + } + params = urllib.parse.urlencode(data) + + retorn = urllib.request.urlopen(url + "?" + params) + translated += json.loads(retorn.read())["data"]["translations"][0][ + "translatedText" + ] + + data = { + "key": settings.google_translation_key, + "target": target_language, + "source": source_language, + "q": temp_question, + } + params = urllib.parse.urlencode(data) + + retorn = urllib.request.urlopen(url + "?" + params) + translated += json.loads(retorn.read())["data"]["translations"][0][ + "translatedText" + ] + return translated diff --git a/src/plone/app/multilingual/interfaces.py b/src/plone/app/multilingual/interfaces.py index d70201d6..a357e380 100644 --- a/src/plone/app/multilingual/interfaces.py +++ b/src/plone/app/multilingual/interfaces.py @@ -278,3 +278,19 @@ class IMultiLanguageExtraOptionsSchema(ILanguageSchema): required=True, vocabulary=selector_policies, ) + + +class IExternalTranslationService(Interface): + """This interface is provided to allow external translation services + to be plugged-in in Plone to use them to translate content + """ + + def available_languages(): + """return the list of tuples that represents language pairs this adapter is enabled for. + An empty list means that all languages are supported + """ + + def translate_content(content, source_language, target_language): + """translate the given content from the source to the target language. + It should return the translated string + """ From d9af445796072d442f6216233a3bab7781f84c1d Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sun, 29 Sep 2024 17:26:42 +0200 Subject: [PATCH 02/45] implement adapter ordering --- .../app/multilingual/browser/translate.py | 23 ++++++++++--------- .../app/multilingual/google_translate.py | 7 +++--- src/plone/app/multilingual/interfaces.py | 12 ++++++++++ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/plone/app/multilingual/browser/translate.py b/src/plone/app/multilingual/browser/translate.py index ed43f2f3..e1d8f5d1 100644 --- a/src/plone/app/multilingual/browser/translate.py +++ b/src/plone/app/multilingual/browser/translate.py @@ -15,9 +15,7 @@ from plone.base.interfaces import ILanguage from plone.uuid.interfaces import IUUID from Products.Five import BrowserView -from zope.component import getAdapters, getMultiAdapter - -from plone.app.multilingual import logger +from zope.component import getAdapters class gtranslation_service_dexterity(BrowserView): @@ -54,14 +52,16 @@ def __call__(self): else: return _("Invalid field") - portal_state_view = getMultiAdapter( - (self.context, self.request), name="plone_portal_state" - ) - portal = portal_state_view.portal() - adapters = getAdapters((portal,), IExternalTranslationService) + adapters = [ + adapter + for _, adapter in getAdapters( + (self.context,), IExternalTranslationService + ) + ] + + sorted_adapters = sorted(adapters, key=lambda x: x.order) - for adapter_name, adapter in adapters: - logger.debug("Checking adapter named %s", adapter_name) + for adapter in sorted_adapters: available_languages = adapter.available_languages() if ( not available_languages @@ -71,7 +71,8 @@ def __call__(self): question, lang_source, lang_target ) - return json.dumps({"data": translation}) + if translation: + return json.dumps({"data": translation}) return json.dumps({"data": ""}) diff --git a/src/plone/app/multilingual/google_translate.py b/src/plone/app/multilingual/google_translate.py index 82ba7b26..3bcc225a 100644 --- a/src/plone/app/multilingual/google_translate.py +++ b/src/plone/app/multilingual/google_translate.py @@ -7,17 +7,18 @@ IExternalTranslationService, IMultiLanguageExtraOptionsSchema, ) -from plone.base.interfaces import IPloneSiteRoot from plone.registry.interfaces import IRegistry from zope.component import getUtility, adapter -from zope.interface import implementer +from zope.interface import implementer, Interface @implementer(IExternalTranslationService) -@adapter(IPloneSiteRoot) +@adapter(Interface) class GoogleCloudTranslationAPI: """implement the external translation using Google Cloud Translation API""" + order = 999 + def __init__(self, context): self.context = context diff --git a/src/plone/app/multilingual/interfaces.py b/src/plone/app/multilingual/interfaces.py index a357e380..3c442b81 100644 --- a/src/plone/app/multilingual/interfaces.py +++ b/src/plone/app/multilingual/interfaces.py @@ -283,8 +283,20 @@ class IMultiLanguageExtraOptionsSchema(ILanguageSchema): class IExternalTranslationService(Interface): """This interface is provided to allow external translation services to be plugged-in in Plone to use them to translate content + + Register a named adapter from (context,) to this interface to install + a new external translation service. + + To control the order of the services, user the 'order' attribute. The lower + the sooner this service will be used. + + The available_languages method can also be used to register the adapter + just to some language pairs. + """ + order = schema.Int(title="Order") + def available_languages(): """return the list of tuples that represents language pairs this adapter is enabled for. An empty list means that all languages are supported From 6b4ad2b4b67331599d9849720aae085fc9609fdd Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sun, 29 Sep 2024 17:42:05 +0200 Subject: [PATCH 03/45] implement availability of service --- src/plone/app/multilingual/browser/translate.py | 1 + src/plone/app/multilingual/browser/utils.py | 16 +++++++++------- src/plone/app/multilingual/google_translate.py | 8 ++++++++ src/plone/app/multilingual/interfaces.py | 3 +++ .../tests/test_external_translation_services.py | 0 5 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 src/plone/app/multilingual/tests/test_external_translation_services.py diff --git a/src/plone/app/multilingual/browser/translate.py b/src/plone/app/multilingual/browser/translate.py index e1d8f5d1..9e9272f1 100644 --- a/src/plone/app/multilingual/browser/translate.py +++ b/src/plone/app/multilingual/browser/translate.py @@ -57,6 +57,7 @@ def __call__(self): for _, adapter in getAdapters( (self.context,), IExternalTranslationService ) + if adapter.is_available() ] sorted_adapters = sorted(adapters, key=lambda x: x.order) diff --git a/src/plone/app/multilingual/browser/utils.py b/src/plone/app/multilingual/browser/utils.py index 20382fde..8e4a6153 100644 --- a/src/plone/app/multilingual/browser/utils.py +++ b/src/plone/app/multilingual/browser/utils.py @@ -8,13 +8,14 @@ from plone.app.multilingual.interfaces import IMultiLanguageExtraOptionsSchema from plone.app.multilingual.interfaces import ITranslationLocator from plone.app.multilingual.interfaces import ITranslationManager +from plone.app.multilingual.interfaces import IExternalTranslationService from plone.app.multilingual.manager import TranslationManager from plone.base.interfaces import ILanguage from plone.i18n.locales.interfaces import IContentLanguageAvailability from plone.registry.interfaces import IRegistry from Products.CMFCore.utils import getToolByName from Products.Five import BrowserView -from zope.component import getMultiAdapter +from zope.component import getMultiAdapter, getAdapters from zope.component import getUtility from zope.component.hooks import getSite @@ -52,12 +53,13 @@ def objToTranslate(self): return self.context def gtenabled(self): - registry = getUtility(IRegistry) - settings = registry.forInterface( - IMultiLanguageExtraOptionsSchema, prefix="plone" - ) - key = settings.google_translation_key - return key is not None and len(key.strip()) > 0 + adapters = [ + adapter + for _, adapter in getAdapters((self.context,), IExternalTranslationService) + if adapter.is_available() + ] + + return len(adapters) > 0 def languages(self): """Deprecated""" diff --git a/src/plone/app/multilingual/google_translate.py b/src/plone/app/multilingual/google_translate.py index 3bcc225a..20101ebf 100644 --- a/src/plone/app/multilingual/google_translate.py +++ b/src/plone/app/multilingual/google_translate.py @@ -22,6 +22,14 @@ class GoogleCloudTranslationAPI: def __init__(self, context): self.context = context + def is_available(self): + registry = getUtility(IRegistry) + settings = registry.forInterface( + IMultiLanguageExtraOptionsSchema, prefix="plone" + ) + key = settings.google_translation_key + return key is not None and len(key.strip()) > 0 + def available_languages(self): # All languages are supported return [] diff --git a/src/plone/app/multilingual/interfaces.py b/src/plone/app/multilingual/interfaces.py index 3c442b81..011b6400 100644 --- a/src/plone/app/multilingual/interfaces.py +++ b/src/plone/app/multilingual/interfaces.py @@ -297,6 +297,9 @@ class IExternalTranslationService(Interface): order = schema.Int(title="Order") + def is_available(): + """return whether this service is available""" + def available_languages(): """return the list of tuples that represents language pairs this adapter is enabled for. An empty list means that all languages are supported diff --git a/src/plone/app/multilingual/tests/test_external_translation_services.py b/src/plone/app/multilingual/tests/test_external_translation_services.py new file mode 100644 index 00000000..e69de29b From ab8821212bbec39c05851028734ded40e4f16a29 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sun, 29 Sep 2024 17:52:08 +0200 Subject: [PATCH 04/45] changelog --- news/467.feature | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 news/467.feature diff --git a/news/467.feature b/news/467.feature new file mode 100644 index 00000000..025898d9 --- /dev/null +++ b/news/467.feature @@ -0,0 +1,2 @@ +Reimplement usage of Google Translate API as an adapter registration, to be able to provide different translation services +[erral] From 70699ec69fb12ec1c45c56fa532f166a052d35f1 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sun, 29 Sep 2024 17:59:17 +0200 Subject: [PATCH 05/45] black --- src/plone/app/multilingual/google_translate.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/plone/app/multilingual/google_translate.py b/src/plone/app/multilingual/google_translate.py index 20101ebf..34c52f33 100644 --- a/src/plone/app/multilingual/google_translate.py +++ b/src/plone/app/multilingual/google_translate.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import json import urllib From 823f4fc74a61ede136a261d43117438c4b1cfaa6 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sun, 29 Sep 2024 17:59:30 +0200 Subject: [PATCH 06/45] isort --- src/plone/app/multilingual/browser/translate.py | 9 ++++----- src/plone/app/multilingual/browser/utils.py | 5 +++-- src/plone/app/multilingual/google_translate.py | 16 ++++++++-------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/plone/app/multilingual/browser/translate.py b/src/plone/app/multilingual/browser/translate.py index 9e9272f1..9fc55e61 100644 --- a/src/plone/app/multilingual/browser/translate.py +++ b/src/plone/app/multilingual/browser/translate.py @@ -1,22 +1,21 @@ -import json - from Acquisition import aq_inner from plone.app.multilingual import _ -<<<<<<< HEAD from plone.app.multilingual.interfaces import IMultiLanguageExtraOptionsSchema from plone.app.multilingual.interfaces import ITranslationManager from plone.app.uuid.utils import uuidToObject -======= from plone.app.multilingual.interfaces import ( IExternalTranslationService, ITranslationManager, ) ->>>>>>> 3fcea08 (convert the function that uses google translate to an adapter based lookup, so that extra services can be registered) +from plone.app.multilingual.interfaces import IExternalTranslationService +from plone.app.multilingual.interfaces import ITranslationManager from plone.base.interfaces import ILanguage from plone.uuid.interfaces import IUUID from Products.Five import BrowserView from zope.component import getAdapters +import json + class gtranslation_service_dexterity(BrowserView): def __call__(self): diff --git a/src/plone/app/multilingual/browser/utils.py b/src/plone/app/multilingual/browser/utils.py index 8e4a6153..350c12db 100644 --- a/src/plone/app/multilingual/browser/utils.py +++ b/src/plone/app/multilingual/browser/utils.py @@ -4,18 +4,19 @@ from Acquisition import aq_parent from plone.app.i18n.locales.browser.selector import LanguageSelector from plone.app.multilingual.browser.selector import LanguageSelectorViewlet +from plone.app.multilingual.interfaces import IExternalTranslationService from plone.app.multilingual.interfaces import ILanguageIndependentFolder from plone.app.multilingual.interfaces import IMultiLanguageExtraOptionsSchema from plone.app.multilingual.interfaces import ITranslationLocator from plone.app.multilingual.interfaces import ITranslationManager -from plone.app.multilingual.interfaces import IExternalTranslationService from plone.app.multilingual.manager import TranslationManager from plone.base.interfaces import ILanguage from plone.i18n.locales.interfaces import IContentLanguageAvailability from plone.registry.interfaces import IRegistry from Products.CMFCore.utils import getToolByName from Products.Five import BrowserView -from zope.component import getMultiAdapter, getAdapters +from zope.component import getAdapters +from zope.component import getMultiAdapter from zope.component import getUtility from zope.component.hooks import getSite diff --git a/src/plone/app/multilingual/google_translate.py b/src/plone/app/multilingual/google_translate.py index 34c52f33..eb1ca147 100644 --- a/src/plone/app/multilingual/google_translate.py +++ b/src/plone/app/multilingual/google_translate.py @@ -1,14 +1,14 @@ +from plone.app.multilingual.interfaces import IExternalTranslationService +from plone.app.multilingual.interfaces import IMultiLanguageExtraOptionsSchema +from plone.registry.interfaces import IRegistry +from zope.component import adapter +from zope.component import getUtility +from zope.interface import implementer +from zope.interface import Interface + import json import urllib -from plone.app.multilingual.interfaces import ( - IExternalTranslationService, - IMultiLanguageExtraOptionsSchema, -) -from plone.registry.interfaces import IRegistry -from zope.component import getUtility, adapter -from zope.interface import implementer, Interface - @implementer(IExternalTranslationService) @adapter(Interface) From 327d9ac81ac46947aa950080a363722c55ce8737 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sun, 29 Sep 2024 17:59:43 +0200 Subject: [PATCH 07/45] format --- src/plone/app/multilingual/configure.zcml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plone/app/multilingual/configure.zcml b/src/plone/app/multilingual/configure.zcml index 4b96e03b..0bb0b890 100644 --- a/src/plone/app/multilingual/configure.zcml +++ b/src/plone/app/multilingual/configure.zcml @@ -194,8 +194,8 @@ + factory=".google_translate.GoogleCloudTranslationAPI" + name="google_translate" + /> From 2feb1591bb041cc3d69e816268e85b2f052b3542 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sun, 29 Sep 2024 19:13:29 +0200 Subject: [PATCH 08/45] remove empty --- .../app/multilingual/tests/test_external_translation_services.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/plone/app/multilingual/tests/test_external_translation_services.py diff --git a/src/plone/app/multilingual/tests/test_external_translation_services.py b/src/plone/app/multilingual/tests/test_external_translation_services.py deleted file mode 100644 index e69de29b..00000000 From 708981393b964e2a95f41b5edac1d574fe8f36c8 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Mon, 30 Sep 2024 10:46:29 +0200 Subject: [PATCH 09/45] tests --- .../test_external_translation_services.py | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/plone/app/multilingual/tests/test_external_translation_services.py diff --git a/src/plone/app/multilingual/tests/test_external_translation_services.py b/src/plone/app/multilingual/tests/test_external_translation_services.py new file mode 100644 index 00000000..86403573 --- /dev/null +++ b/src/plone/app/multilingual/tests/test_external_translation_services.py @@ -0,0 +1,132 @@ +import unittest +from plone.app.multilingual.interfaces import ( + IExternalTranslationService, + IPloneAppMultilingualInstalled, + ITranslationManager, +) + +from zope.component import provideAdapter, adapter +from zope.interface import implementer, Interface, alsoProvides +from plone.app.multilingual.testing import PAM_FUNCTIONAL_TESTING +from plone.testing._z2_testbrowser import Browser +from plone.dexterity.utils import createContentInContainer +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD + +import json +import transaction + + +@implementer(IExternalTranslationService) +@adapter(Interface) +class NiTranslator: + order = 30 + + def __init__(self, context): + self.context = context + + def is_available(self): + return True + + def available_languages(self): + # All + return [] + + def translate_content(self, content, source_language, target_language): + return f"{content} NI!" + + +@implementer(IExternalTranslationService) +@adapter(Interface) +class DisabledTranslator: + order = 20 + + def __init__(self, context): + self.context = context + + def is_available(self): + return False + + def available_languages(self): + return [] + + def translate_content(self, content, source_language, target_language): + return "translation" + + +@implementer(IExternalTranslationService) +@adapter(Interface) +class CaEsTranslator: + order = 5 + + def __init__(self, context): + self.context = context + + def is_available(self): + return True + + def available_languages(self): + return [("ca", "es")] + + def translate_content(self, content, source_language, target_language): + return "text español" + + +class TestExternalServices(unittest.TestCase): + layer = PAM_FUNCTIONAL_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + alsoProvides(self.layer["request"], IPloneAppMultilingualInstalled) + # Setup test browser + self.browser = Browser(self.layer["app"]) + self.browser.handleErrors = False + self.browser.addHeader( + "Authorization", f"Basic {SITE_OWNER_NAME}:{SITE_OWNER_PASSWORD}" + ) + self.a_ca = createContentInContainer( + self.portal["ca"], "Document", title="Test document CA" + ) + + self.a_es = createContentInContainer( + self.portal["es"], "Document", title="Test document ES" + ) + + manager = ITranslationManager(self.a_ca) + manager.register_translation("es", self.a_es) + + provideAdapter(NiTranslator, name="ni_translator") + provideAdapter(DisabledTranslator, name="disabled_translator") + provideAdapter(CaEsTranslator, name="ca_es_translator") + + transaction.commit() + + def test_translation_ca_es(self): + """In this case the CaEsTranslator should be applied + because it has a smaller number in the order + and it has the ca-es language pair translation + availability + """ + self.browser.open( + f"{self.a_es.absolute_url()}/gtranslation_service", + data={"field": "IDublinCore.title", "lang_source": "ca"}, + ) + + result = json.loads(self.browser.contents) + + self.assertEqual(result.get("data"), "text español") + + def test_translation_es_ca(self): + """In this case the NiTranslator should be applied + because the previous translators have not this language + pair available or are disabled + """ + + self.browser.open( + f"{self.a_ca.absolute_url()}/gtranslation_service", + data={"field": "IDublinCore.title", "lang_source": "es"}, + ) + + result = json.loads(self.browser.contents) + + self.assertEqual(result.get("data"), "Test document ES NI!") From edfc222a15f87aa20c20ddb3e34a93af5d7379cc Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Mon, 30 Sep 2024 10:46:45 +0200 Subject: [PATCH 10/45] isort --- .../test_external_translation_services.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/plone/app/multilingual/tests/test_external_translation_services.py b/src/plone/app/multilingual/tests/test_external_translation_services.py index 86403573..ffb594d6 100644 --- a/src/plone/app/multilingual/tests/test_external_translation_services.py +++ b/src/plone/app/multilingual/tests/test_external_translation_services.py @@ -1,20 +1,20 @@ -import unittest -from plone.app.multilingual.interfaces import ( - IExternalTranslationService, - IPloneAppMultilingualInstalled, - ITranslationManager, -) - -from zope.component import provideAdapter, adapter -from zope.interface import implementer, Interface, alsoProvides +from plone.app.multilingual.interfaces import IExternalTranslationService +from plone.app.multilingual.interfaces import IPloneAppMultilingualInstalled +from plone.app.multilingual.interfaces import ITranslationManager from plone.app.multilingual.testing import PAM_FUNCTIONAL_TESTING -from plone.testing._z2_testbrowser import Browser -from plone.dexterity.utils import createContentInContainer from plone.app.testing import SITE_OWNER_NAME from plone.app.testing import SITE_OWNER_PASSWORD +from plone.dexterity.utils import createContentInContainer +from plone.testing._z2_testbrowser import Browser +from zope.component import adapter +from zope.component import provideAdapter +from zope.interface import alsoProvides +from zope.interface import implementer +from zope.interface import Interface import json import transaction +import unittest @implementer(IExternalTranslationService) From adb4fe4df8d1226310ef7ca6dab87c3797a22791 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 2 Oct 2024 09:39:28 +0200 Subject: [PATCH 11/45] isort --- src/plone/app/multilingual/browser/translate.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/plone/app/multilingual/browser/translate.py b/src/plone/app/multilingual/browser/translate.py index 9fc55e61..fd796137 100644 --- a/src/plone/app/multilingual/browser/translate.py +++ b/src/plone/app/multilingual/browser/translate.py @@ -1,14 +1,9 @@ from Acquisition import aq_inner from plone.app.multilingual import _ +from plone.app.multilingual.interfaces import IExternalTranslationService from plone.app.multilingual.interfaces import IMultiLanguageExtraOptionsSchema from plone.app.multilingual.interfaces import ITranslationManager from plone.app.uuid.utils import uuidToObject -from plone.app.multilingual.interfaces import ( - IExternalTranslationService, - ITranslationManager, -) -from plone.app.multilingual.interfaces import IExternalTranslationService -from plone.app.multilingual.interfaces import ITranslationManager from plone.base.interfaces import ILanguage from plone.uuid.interfaces import IUUID from Products.Five import BrowserView From 84266a9cd5cb1c4cdbaacc21569abe5351b75c89 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 2 Oct 2024 09:42:26 +0200 Subject: [PATCH 12/45] lint --- src/plone/app/multilingual/browser/translate.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/plone/app/multilingual/browser/translate.py b/src/plone/app/multilingual/browser/translate.py index fd796137..30abff57 100644 --- a/src/plone/app/multilingual/browser/translate.py +++ b/src/plone/app/multilingual/browser/translate.py @@ -31,10 +31,6 @@ def __call__(self): else: manager = ITranslationManager(self.context) - registry = getUtility(IRegistry) - settings = registry.forInterface( - IMultiLanguageExtraOptionsSchema, prefix="plone" - ) lang_target = ILanguage(self.context).get_language() lang_source = self.request.form["lang_source"] orig_object = manager.get_translation(lang_source) From e9e4aa0e00d78a5b67304fec246a07eaf4b60d83 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 2 Oct 2024 09:43:29 +0200 Subject: [PATCH 13/45] lint --- src/plone/app/multilingual/browser/translate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plone/app/multilingual/browser/translate.py b/src/plone/app/multilingual/browser/translate.py index 30abff57..ec44da06 100644 --- a/src/plone/app/multilingual/browser/translate.py +++ b/src/plone/app/multilingual/browser/translate.py @@ -1,7 +1,6 @@ from Acquisition import aq_inner from plone.app.multilingual import _ from plone.app.multilingual.interfaces import IExternalTranslationService -from plone.app.multilingual.interfaces import IMultiLanguageExtraOptionsSchema from plone.app.multilingual.interfaces import ITranslationManager from plone.app.uuid.utils import uuidToObject from plone.base.interfaces import ILanguage From 94f1de01b5acce2dc36855bf417399d189aada3f Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sat, 30 Nov 2024 12:56:45 -0300 Subject: [PATCH 14/45] convert to utilities --- setup.py | 2 + .../app/multilingual/browser/translate.py | 70 ++++--- src/plone/app/multilingual/configure.zcml | 10 +- .../app/multilingual/google_translate.py | 14 +- src/plone/app/multilingual/interfaces.py | 7 +- .../app/multilingual/restapi/__init__.py | 0 .../app/multilingual/restapi/configure.zcml | 26 +++ .../restapi/content_translation.py | 5 + .../multilingual/restapi/translate_text.py | 29 +++ .../restapi/translation_services.py | 21 ++ src/plone/app/multilingual/testing.py | 40 ++++ .../test_external_translation_services.py | 77 ++------ .../app/multilingual/tests/test_restapi.py | 184 ++++++++++++++++++ 13 files changed, 382 insertions(+), 103 deletions(-) create mode 100644 src/plone/app/multilingual/restapi/__init__.py create mode 100644 src/plone/app/multilingual/restapi/configure.zcml create mode 100644 src/plone/app/multilingual/restapi/content_translation.py create mode 100644 src/plone/app/multilingual/restapi/translate_text.py create mode 100644 src/plone/app/multilingual/restapi/translation_services.py create mode 100644 src/plone/app/multilingual/tests/test_restapi.py diff --git a/setup.py b/setup.py index 179e0022..cdd2b3f8 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ "plone.memoize", "plone.protect", "plone.registry", + "plone.restapi", "plone.schemaeditor", "plone.supermodel", "plone.uuid", @@ -63,6 +64,7 @@ "plone.testing", "robotsuite", "Products.CMFPlacefulWorkflow", + "plone.restapi", ], }, entry_points=""" diff --git a/src/plone/app/multilingual/browser/translate.py b/src/plone/app/multilingual/browser/translate.py index cc54e0e9..deeac861 100644 --- a/src/plone/app/multilingual/browser/translate.py +++ b/src/plone/app/multilingual/browser/translate.py @@ -6,11 +6,50 @@ from plone.base.interfaces import ILanguage from plone.uuid.interfaces import IUUID from Products.Five import BrowserView -from zope.component import getAdapters +from zope.component import getUtilitiesFor +from zope.component import getUtility import json +def translate_text(original_text, source_language, target_language, service=None): + """translate the text""" + + if service is not None: + # if an specific adapter is requested, use it if available + + adapter = getUtility(IExternalTranslationService, name=service) + if not adapter.is_available(): + return None + + adapters = [adapter] + + else: + + adapters = [ + adapter + for _, adapter in getUtilitiesFor(IExternalTranslationService) + if adapter.is_available() + ] + + sorted_adapters = sorted(adapters, key=lambda x: x.order) + + for adapter in sorted_adapters: + available_languages = adapter.available_languages() + if ( + not available_languages + or (source_language, target_language) in available_languages + ): + translation = adapter.translate_content( + original_text, source_language, target_language + ) + + if translation: + return translation + + return None + + class gtranslation_service_dexterity(BrowserView): def __call__(self): if self.request.method != "POST" and not ( @@ -41,30 +80,11 @@ def __call__(self): else: return _("Invalid field") - adapters = [ - adapter - for _, adapter in getAdapters( - (self.context,), IExternalTranslationService - ) - if adapter.is_available() - ] - - sorted_adapters = sorted(adapters, key=lambda x: x.order) - - for adapter in sorted_adapters: - available_languages = adapter.available_languages() - if ( - not available_languages - or (lang_source, lang_target) in available_languages - ): - translation = adapter.translate_content( - question, lang_source, lang_target - ) - - if translation: - return json.dumps({"data": translation}) - - return json.dumps({"data": ""}) + translation = translate_text(question, lang_source, lang_target) + if translation is None: + return json.dumps({"data": ""}) + + return json.dumps({"data": translation}) class TranslationForm(BrowserView): diff --git a/src/plone/app/multilingual/configure.zcml b/src/plone/app/multilingual/configure.zcml index 0bb0b890..25a4beb7 100644 --- a/src/plone/app/multilingual/configure.zcml +++ b/src/plone/app/multilingual/configure.zcml @@ -27,6 +27,11 @@ + + @@ -193,9 +198,10 @@ /> - diff --git a/src/plone/app/multilingual/google_translate.py b/src/plone/app/multilingual/google_translate.py index eb1ca147..8af23c71 100644 --- a/src/plone/app/multilingual/google_translate.py +++ b/src/plone/app/multilingual/google_translate.py @@ -1,25 +1,16 @@ -from plone.app.multilingual.interfaces import IExternalTranslationService from plone.app.multilingual.interfaces import IMultiLanguageExtraOptionsSchema from plone.registry.interfaces import IRegistry -from zope.component import adapter from zope.component import getUtility -from zope.interface import implementer -from zope.interface import Interface import json import urllib -@implementer(IExternalTranslationService) -@adapter(Interface) -class GoogleCloudTranslationAPI: +class GoogleCloudTranslationAPIFactory: """implement the external translation using Google Cloud Translation API""" order = 999 - def __init__(self, context): - self.context = context - def is_available(self): registry = getUtility(IRegistry) settings = registry.forInterface( @@ -75,3 +66,6 @@ def translate_content(self, content, source_language, target_language): "translatedText" ] return translated + + +GoogleCloudTranslationAPI = GoogleCloudTranslationAPIFactory() diff --git a/src/plone/app/multilingual/interfaces.py b/src/plone/app/multilingual/interfaces.py index 011b6400..abdd4737 100644 --- a/src/plone/app/multilingual/interfaces.py +++ b/src/plone/app/multilingual/interfaces.py @@ -284,13 +284,12 @@ class IExternalTranslationService(Interface): """This interface is provided to allow external translation services to be plugged-in in Plone to use them to translate content - Register a named adapter from (context,) to this interface to install - a new external translation service. + Register a utility to install a new external translation service. To control the order of the services, user the 'order' attribute. The lower the sooner this service will be used. - The available_languages method can also be used to register the adapter + The available_languages method can also be used to register the utility just to some language pairs. """ @@ -301,7 +300,7 @@ def is_available(): """return whether this service is available""" def available_languages(): - """return the list of tuples that represents language pairs this adapter is enabled for. + """return the list of tuples that represents language pairs this utility is enabled for. An empty list means that all languages are supported """ diff --git a/src/plone/app/multilingual/restapi/__init__.py b/src/plone/app/multilingual/restapi/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/plone/app/multilingual/restapi/configure.zcml b/src/plone/app/multilingual/restapi/configure.zcml new file mode 100644 index 00000000..da762b38 --- /dev/null +++ b/src/plone/app/multilingual/restapi/configure.zcml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/src/plone/app/multilingual/restapi/content_translation.py b/src/plone/app/multilingual/restapi/content_translation.py new file mode 100644 index 00000000..746888ca --- /dev/null +++ b/src/plone/app/multilingual/restapi/content_translation.py @@ -0,0 +1,5 @@ +from plone.restapi.services import Service + + +class ContentTranslation(Service): + pass diff --git a/src/plone/app/multilingual/restapi/translate_text.py b/src/plone/app/multilingual/restapi/translate_text.py new file mode 100644 index 00000000..c24ce66b --- /dev/null +++ b/src/plone/app/multilingual/restapi/translate_text.py @@ -0,0 +1,29 @@ +from plone.app.multilingual.browser.translate import translate_text +from plone.restapi.deserializer import json_body +from plone.restapi.services import Service + + +class TranslateTextService(Service): + """this endpoints tries to translate the given text into the given language, using the previously registered ExternalTranslationServices""" + + def reply(self): + body = json_body(self.request) + source_language = body.get("source_language") + target_language = body.get("target_language") + original_text = body.get("original_text") + service = body.get("service") + + translation = translate_text( + original_text, source_language, target_language, service + ) + + if translation is None: + self.request.response.setStatus(400) + return dict( + error=dict( + type="Translation service not available", + message="The requested translation service is not available.", + ) + ) + + return {"data": translation} diff --git a/src/plone/app/multilingual/restapi/translation_services.py b/src/plone/app/multilingual/restapi/translation_services.py new file mode 100644 index 00000000..315a422e --- /dev/null +++ b/src/plone/app/multilingual/restapi/translation_services.py @@ -0,0 +1,21 @@ +from plone.app.multilingual.interfaces import IExternalTranslationService +from plone.restapi.services import Service +from zope.component import getUtilitiesFor + + +class TranslationServices(Service): + """an endpoint to return all translation services registered in a portal that provide the IExternalTranslationService interface""" + + def reply(self): + result = [] + + for name, adapter in getUtilitiesFor(IExternalTranslationService): + item = {} + item["order"] = adapter.order + item["is_available"] = adapter.is_available() + item["available_languages"] = adapter.available_languages() + item["name"] = name + + result.append(item) + + return sorted(result, key=lambda x: x["order"], reverse=True) diff --git a/src/plone/app/multilingual/testing.py b/src/plone/app/multilingual/testing.py index aa64c841..a8381e0d 100644 --- a/src/plone/app/multilingual/testing.py +++ b/src/plone/app/multilingual/testing.py @@ -40,6 +40,46 @@ def disableCSRFProtection(): pass +class NiTranslator: + order = 30 + + def is_available(self): + return True + + def available_languages(self): + # All + return [] + + def translate_content(self, content, source_language, target_language): + return f"{content} NI!" + + +class DisabledTranslator: + order = 20 + + def is_available(self): + return False + + def available_languages(self): + return [] + + def translate_content(self, content, source_language, target_language): + return "translation" + + +class CaEsTranslator: + order = 5 + + def is_available(self): + return True + + def available_languages(self): + return [("ca", "es")] + + def translate_content(self, content, source_language, target_language): + return "text español" + + class PloneAppMultilingualLayer(PloneSandboxLayer): defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,) diff --git a/src/plone/app/multilingual/tests/test_external_translation_services.py b/src/plone/app/multilingual/tests/test_external_translation_services.py index ffb594d6..9bd44d3e 100644 --- a/src/plone/app/multilingual/tests/test_external_translation_services.py +++ b/src/plone/app/multilingual/tests/test_external_translation_services.py @@ -1,77 +1,22 @@ from plone.app.multilingual.interfaces import IExternalTranslationService from plone.app.multilingual.interfaces import IPloneAppMultilingualInstalled from plone.app.multilingual.interfaces import ITranslationManager +from plone.app.multilingual.testing import CaEsTranslator +from plone.app.multilingual.testing import DisabledTranslator +from plone.app.multilingual.testing import NiTranslator from plone.app.multilingual.testing import PAM_FUNCTIONAL_TESTING from plone.app.testing import SITE_OWNER_NAME from plone.app.testing import SITE_OWNER_PASSWORD from plone.dexterity.utils import createContentInContainer from plone.testing._z2_testbrowser import Browser -from zope.component import adapter -from zope.component import provideAdapter +from zope.component import provideUtility from zope.interface import alsoProvides -from zope.interface import implementer -from zope.interface import Interface import json import transaction import unittest -@implementer(IExternalTranslationService) -@adapter(Interface) -class NiTranslator: - order = 30 - - def __init__(self, context): - self.context = context - - def is_available(self): - return True - - def available_languages(self): - # All - return [] - - def translate_content(self, content, source_language, target_language): - return f"{content} NI!" - - -@implementer(IExternalTranslationService) -@adapter(Interface) -class DisabledTranslator: - order = 20 - - def __init__(self, context): - self.context = context - - def is_available(self): - return False - - def available_languages(self): - return [] - - def translate_content(self, content, source_language, target_language): - return "translation" - - -@implementer(IExternalTranslationService) -@adapter(Interface) -class CaEsTranslator: - order = 5 - - def __init__(self, context): - self.context = context - - def is_available(self): - return True - - def available_languages(self): - return [("ca", "es")] - - def translate_content(self, content, source_language, target_language): - return "text español" - - class TestExternalServices(unittest.TestCase): layer = PAM_FUNCTIONAL_TESTING @@ -95,9 +40,17 @@ def setUp(self): manager = ITranslationManager(self.a_ca) manager.register_translation("es", self.a_es) - provideAdapter(NiTranslator, name="ni_translator") - provideAdapter(DisabledTranslator, name="disabled_translator") - provideAdapter(CaEsTranslator, name="ca_es_translator") + provideUtility( + NiTranslator(), IExternalTranslationService, name="ni_translator" + ) + provideUtility( + DisabledTranslator(), + IExternalTranslationService, + name="disabled_translator", + ) + provideUtility( + CaEsTranslator(), IExternalTranslationService, name="ca_es_translator" + ) transaction.commit() diff --git a/src/plone/app/multilingual/tests/test_restapi.py b/src/plone/app/multilingual/tests/test_restapi.py new file mode 100644 index 00000000..1f8078e5 --- /dev/null +++ b/src/plone/app/multilingual/tests/test_restapi.py @@ -0,0 +1,184 @@ +from plone import api +from plone.app.multilingual.interfaces import IExternalTranslationService +from plone.app.multilingual.testing import CaEsTranslator +from plone.app.multilingual.testing import DisabledTranslator +from plone.app.multilingual.testing import NiTranslator +from plone.app.multilingual.testing import PAM_ROBOT_TESTING +from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.restapi.testing import RelativeSession +from zope.component import provideUtility + +import transaction +import unittest + + +class TestDefaultTranslationServices(unittest.TestCase): + """Test the default translation services provided by plone.app.multilingual""" + + layer = PAM_ROBOT_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.request = self.layer["request"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + + def test_available_services(self): + """test that by default we have just one available service""" + api_result = self.api_session.get("/@translation-services") + self.assertEqual(len(api_result.json()), 1) + + +class TestSeveralTranslationServices(unittest.TestCase): + """Test that when we register several translation services, those are correctly exposed + in the REST API + """ + + layer = PAM_ROBOT_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.request = self.layer["request"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + provideUtility( + NiTranslator(), IExternalTranslationService, name="ni_translator" + ) + provideUtility( + DisabledTranslator(), + IExternalTranslationService, + name="disabled_translator", + ) + provideUtility( + CaEsTranslator(), IExternalTranslationService, name="ca_es_translator" + ) + + transaction.commit() + + def test_available_services(self): + """test that by default we have just one available service""" + api_result = self.api_session.get("/@translation-services") + self.assertEqual(len(api_result.json()), 4) + + def test_that_two_are_disabledd(self): + """we have registered an adapter that is disabled, check that we get that information correctly""" + api_result = self.api_session.get("/@translation-services") + results = api_result.json() + + disabled_adapters = [ + adapter for adapter in results if not adapter["is_available"] + ] + # There are 2 disabled adapters: the default provided one, Google Translate, and ours + self.assertEqual(len(disabled_adapters), 2) + + disabled_adapter_names = [adapter["name"] for adapter in disabled_adapters] + self.assertIn("disabled_translator", disabled_adapter_names) + self.assertIn("google_translate", disabled_adapter_names) + + def test_that_one_is_disabled(self): + """enter a dummy Google Translation key, to have this service enabled""" + api.portal.set_registry_record("plone.google_translation_key", "DUMMY KEY") + transaction.commit() + api_result = self.api_session.get("/@translation-services") + results = api_result.json() + + disabled_adapters = [ + adapter for adapter in results if not adapter["is_available"] + ] + # There are 1 disabled adapters: only ours + self.assertEqual(len(disabled_adapters), 1) + self.assertEqual(disabled_adapters[0]["name"], "disabled_translator") + + +class TestTranslateTextServices(unittest.TestCase): + layer = PAM_ROBOT_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.request = self.layer["request"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + provideUtility( + NiTranslator(), IExternalTranslationService, name="ni_translator" + ) + provideUtility( + DisabledTranslator(), + IExternalTranslationService, + name="disabled_translator", + ) + provideUtility( + CaEsTranslator(), IExternalTranslationService, name="ca_es_translator" + ) + + transaction.commit() + + def test_translation_ca_es(self): + """In this case the CaEsTranslator should be applied + because it has a smaller number in the order + and it has the ca-es language pair translation + availability + """ + + api_result = self.api_session.post( + "/@translate-text", + json={ + "original_text": "Some text", + "source_language": "ca", + "target_language": "es", + }, + ) + result = api_result.json() + + self.assertEqual(result.get("data"), "text español") + + def test_translation_es_fr(self): + """In this case the NiTranslator should be applied + because the previous translators have not this language + pair available or are disabled + """ + api_result = self.api_session.post( + "/@translate-text", + json={ + "original_text": "Some text", + "source_language": "es", + "target_language": "fr", + }, + ) + result = api_result.json() + + self.assertEqual(result.get("data"), "Some text NI!") + + def test_translation_given_service(self): + """test that using a given translation works, we are going to request the NI translator + in a context when in normal circumstances another different translator would be used + """ + api_result = self.api_session.post( + "/@translate-text", + json={ + "original_text": "Some other text", + "source_language": "ca", + "target_language": "es", + "service": "ni_translator", + }, + ) + result = api_result.json() + + self.assertEqual(result.get("data"), "Some other text NI!") From 3b183a8c2ecb8014bb298ed914a02561daf9b22c Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sat, 30 Nov 2024 13:01:44 -0300 Subject: [PATCH 15/45] changelog --- news/467.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/467.feature b/news/467.feature index 025898d9..51b221d5 100644 --- a/news/467.feature +++ b/news/467.feature @@ -1,2 +1,2 @@ -Reimplement usage of Google Translate API as an adapter registration, to be able to provide different translation services +Reimplement usage of Google Translate API as utility, to be able to provide different translation services [erral] From 0930656cef506b5399c2623cc627a1f2fdffb3a7 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sat, 30 Nov 2024 13:05:16 -0300 Subject: [PATCH 16/45] =?UTF-8?q?remove=20unneeded=C2=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plone/app/multilingual/restapi/content_translation.py | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 src/plone/app/multilingual/restapi/content_translation.py diff --git a/src/plone/app/multilingual/restapi/content_translation.py b/src/plone/app/multilingual/restapi/content_translation.py deleted file mode 100644 index 746888ca..00000000 --- a/src/plone/app/multilingual/restapi/content_translation.py +++ /dev/null @@ -1,5 +0,0 @@ -from plone.restapi.services import Service - - -class ContentTranslation(Service): - pass From b5d0d5c048ae623246c280351d28a81c85e192ad Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 8 Jan 2025 09:28:44 +0100 Subject: [PATCH 17/45] only translate non-empty values --- .../app/multilingual/browser/translate.py | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/plone/app/multilingual/browser/translate.py b/src/plone/app/multilingual/browser/translate.py index deeac861..1dabfa78 100644 --- a/src/plone/app/multilingual/browser/translate.py +++ b/src/plone/app/multilingual/browser/translate.py @@ -15,37 +15,40 @@ def translate_text(original_text, source_language, target_language, service=None): """translate the text""" - if service is not None: - # if an specific adapter is requested, use it if available + if original_text: + # Initial shortcut: translate only non-empty values - adapter = getUtility(IExternalTranslationService, name=service) - if not adapter.is_available(): - return None + if service is not None: + # if an specific adapter is requested, use it if available - adapters = [adapter] + adapter = getUtility(IExternalTranslationService, name=service) + if not adapter.is_available(): + return None - else: + adapters = [adapter] - adapters = [ - adapter - for _, adapter in getUtilitiesFor(IExternalTranslationService) - if adapter.is_available() - ] - - sorted_adapters = sorted(adapters, key=lambda x: x.order) - - for adapter in sorted_adapters: - available_languages = adapter.available_languages() - if ( - not available_languages - or (source_language, target_language) in available_languages - ): - translation = adapter.translate_content( - original_text, source_language, target_language - ) - - if translation: - return translation + else: + # Get all available adapters + adapters = [ + adapter + for _, adapter in getUtilitiesFor(IExternalTranslationService) + if adapter.is_available() + ] + + sorted_adapters = sorted(adapters, key=lambda x: x.order) + + for adapter in sorted_adapters: + available_languages = adapter.available_languages() + if ( + not available_languages + or (source_language, target_language) in available_languages + ): + translation = adapter.translate_content( + original_text, source_language, target_language + ) + + if translation: + return translation return None From fb21293faef64d492f983869998a4033dd3bff84 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Wed, 8 Jan 2025 09:44:22 +0100 Subject: [PATCH 18/45] add plone.restapi test requirements --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index cdd2b3f8..5a6b6dc1 100644 --- a/setup.py +++ b/setup.py @@ -64,7 +64,7 @@ "plone.testing", "robotsuite", "Products.CMFPlacefulWorkflow", - "plone.restapi", + "plone.restapi[test]", ], }, entry_points=""" From 29a0845e7a1cc7dea4a3eb226d7b2c26fd10c8e4 Mon Sep 17 00:00:00 2001 From: wesleybl Date: Fri, 28 Nov 2025 15:46:21 -0300 Subject: [PATCH 19/45] Adds the volto.blocks behavior to LRF if `plone.volto` is installed. During the installation of `plone.app.multilingual`, checks if `plone.volto` is installed. If it is, adds the volto.blocks behavior to LRF. --- news/518.feature | 1 + src/plone/app/multilingual/setuphandlers.py | 33 ++++++++- src/plone/app/multilingual/testing.py | 48 ++++++++++++ .../app/multilingual/tests/test_setup.py | 74 +++++++++++++++++++ 4 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 news/518.feature diff --git a/news/518.feature b/news/518.feature new file mode 100644 index 00000000..6913708d --- /dev/null +++ b/news/518.feature @@ -0,0 +1 @@ +Adds the volto.blocks behavior to LRF if `plone.volto` is installed. @wesleybl diff --git a/src/plone/app/multilingual/setuphandlers.py b/src/plone/app/multilingual/setuphandlers.py index 3bdde1df..a352fdd9 100644 --- a/src/plone/app/multilingual/setuphandlers.py +++ b/src/plone/app/multilingual/setuphandlers.py @@ -1,7 +1,8 @@ -from logging import getLogger +from plone.app.multilingual import logger from plone.app.multilingual.browser.setup import SetupMultilingualSite from plone.app.multilingual.itg import addAttributeTG from plone.base.interfaces import INonInstallable +from plone.base.utils import get_installer from Products.CMFCore.utils import getToolByName from zope.component.hooks import getSite from zope.interface import implementer @@ -19,10 +20,35 @@ def getNonInstallableProfiles(self): ] +def add_volto_blocks_behavior_to_lrf(portal): + """Add volto.blocks behavior to LRF if plone.volto is installed. + + When plone.volto is installed before plone.app.multilingual, the LRF type + should have the volto.blocks behavior to support Volto's block-based editing. + """ + installer = get_installer(portal) + + if not installer.is_product_installed("plone.volto"): + return + + types_tool = portal.portal_types + lrf_fti = types_tool.get("LRF") + if lrf_fti is None: + return + + behaviors = list(getattr(lrf_fti, "behaviors", ())) + if "volto.blocks" not in behaviors: + behaviors.append("volto.blocks") + lrf_fti.behaviors = tuple(behaviors) + logger.info("Added volto.blocks behavior to LRF type") + + def init_pam(tool): """After installation run setup to create LRF and LIF.""" + site = getSite() setup_tool = SetupMultilingualSite() - setup_tool.setupSite(getSite()) + setup_tool.setupSite(site) + add_volto_blocks_behavior_to_lrf(site) def step_default_various(context): @@ -84,5 +110,4 @@ def disable_language_switcher(portal): if site.default_view == "language-switcher": site.default_view = "listing_view" - log = getLogger("setuphandlers.disable_language_switcher") - log.info("Language switcher disabled") + logger.info("Language switcher disabled") diff --git a/src/plone/app/multilingual/testing.py b/src/plone/app/multilingual/testing.py index a8381e0d..66027c37 100644 --- a/src/plone/app/multilingual/testing.py +++ b/src/plone/app/multilingual/testing.py @@ -311,3 +311,51 @@ def create_translation(self, *args, **kwargs): PAM_INTEGRATION_PRESET_TESTING = PLONE_APP_MULTILINGUAL_PRESET_INTEGRATION_TESTING PAM_FUNCTIONAL_TESTING = PLONE_APP_MULTILINGUAL_FUNCTIONAL_TESTING PAM_ROBOT_TESTING = PLONE_APP_MULTILINGUAL_ROBOT_TESTING + + +# Layer for testing with plone.volto installed first +# Only define if plone.volto is available +try: + import plone.volto + + HAS_PLONE_VOLTO = True +except ImportError: + HAS_PLONE_VOLTO = False + +if HAS_PLONE_VOLTO: + + class VoltoMultilingualLayer(PloneSandboxLayer): + """Test layer that installs plone.volto before plone.app.multilingual. + + This layer only installs plone.volto. Tests using this layer should + install plone.app.multilingual:default themselves to test the + installation order behavior. + """ + + defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,) + + def setUpZope(self, app, configurationContext): + import plone.volto + + xmlconfig.file("configure.zcml", plone.volto, context=configurationContext) + xmlconfig.file( + "testing.zcml", plone.app.multilingual, context=configurationContext + ) + xmlconfig.file( + "overrides.zcml", plone.app.multilingual, context=configurationContext + ) + + def setUpPloneSite(self, portal): + # Only install plone.volto - tests should install plone.app.multilingual + + applyProfile(portal, "plone.volto:default") + + # Empower test user + setRoles(portal, TEST_USER_ID, ["Manager"]) + + VOLTO_MULTILINGUAL_FIXTURE = VoltoMultilingualLayer() + + VOLTO_MULTILINGUAL_INTEGRATION_TESTING = IntegrationTesting( + bases=(VOLTO_MULTILINGUAL_FIXTURE,), + name="plone.app.multilingual:VoltoIntegration", + ) diff --git a/src/plone/app/multilingual/tests/test_setup.py b/src/plone/app/multilingual/tests/test_setup.py index ca4a9a4d..9b75120b 100644 --- a/src/plone/app/multilingual/tests/test_setup.py +++ b/src/plone/app/multilingual/tests/test_setup.py @@ -12,6 +12,15 @@ import unittest +# Check if plone.volto is available and import layer if so +try: + from plone.app.multilingual.testing import HAS_PLONE_VOLTO + from plone.app.multilingual.testing import VOLTO_MULTILINGUAL_INTEGRATION_TESTING +except ImportError: + HAS_PLONE_VOLTO = False + VOLTO_MULTILINGUAL_INTEGRATION_TESTING = None + + class TestSetupMultilingualSite(unittest.TestCase): """Testing multilingual site without predefined languages.""" @@ -100,6 +109,19 @@ def test_lrf_has_locking_behavior(self): "LRF type should have plone.locking behavior enabled", ) + def test_lrf_does_not_have_volto_blocks_behavior(self): + """LRF type should NOT have volto.blocks behavior without Volto installed.""" + portal_types = getToolByName(self.portal, "portal_types") + lrf_type = portal_types.get("LRF") + + behaviors = getattr(lrf_type, "behaviors", ()) + + self.assertNotIn( + "volto.blocks", + behaviors, + "LRF type should not have volto.blocks behavior without Volto", + ) + class TestSetupMultilingualPresetSite(unittest.TestCase): """Testing multilingual site with predefined languages.""" @@ -127,3 +149,55 @@ def test_type_of_language_folders(self): """The created objects have to be 'Language Root Folder'.""" for lang in self.languages: self.assertEqual(self.portal.get(lang).portal_type, "LRF") + + +# Tests with plone.volto installed first +# Only define if plone.volto is available +if HAS_PLONE_VOLTO: + + class TestSetupWithVolto(unittest.TestCase): + """Testing multilingual site with plone.volto installed first. + + This test verifies that installing plone.app.multilingual:default + after plone.volto:default works correctly. + """ + + layer = VOLTO_MULTILINGUAL_INTEGRATION_TESTING + + def setUp(self): + """Setting up the test.""" + from plone.app.testing import applyProfile + + self.portal = self.layer["portal"] + self.request = self.layer["request"] + alsoProvides(self.layer["request"], IPloneAppMultilingualInstalled) + + # Install plone.app.multilingual after plone.volto is already installed + applyProfile(self.portal, "plone.app.multilingual:default") + + def test_volto_is_installed(self): + """plone.volto should be installed.""" + # Check if volto browserlayer is registered + from plone.browserlayer.utils import registered_layers + from plone.volto.interfaces import IPloneVoltoCoreLayer + + self.assertIn(IPloneVoltoCoreLayer, registered_layers()) + + def test_pam_is_installed(self): + """plone.app.multilingual should be installed after volto.""" + portal_types = getToolByName(self.portal, "portal_types") + # LRF type should exist + self.assertIn("LRF", portal_types.objectIds()) + + def test_lrf_has_volto_blocks_behavior(self): + """LRF type should have volto.blocks behavior when Volto is installed first.""" + portal_types = getToolByName(self.portal, "portal_types") + lrf_type = portal_types.get("LRF") + + behaviors = getattr(lrf_type, "behaviors", ()) + + self.assertIn( + "volto.blocks", + behaviors, + "LRF type should have volto.blocks behavior when Volto is installed first", + ) From 29bf8658e07656358552e6540164a0f42b5ad6e1 Mon Sep 17 00:00:00 2001 From: wesleybl Date: Tue, 2 Dec 2025 11:54:07 -0300 Subject: [PATCH 20/45] Move the function `add volto blocks_behavior_to_lrf` to the class `Setup Multilingual Site`. --- src/plone/app/multilingual/browser/setup.py | 24 ++++++++++++++++++ src/plone/app/multilingual/setuphandlers.py | 28 +-------------------- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/src/plone/app/multilingual/browser/setup.py b/src/plone/app/multilingual/browser/setup.py index e1ce0dc9..6110b0aa 100644 --- a/src/plone/app/multilingual/browser/setup.py +++ b/src/plone/app/multilingual/browser/setup.py @@ -7,6 +7,7 @@ from plone.app.multilingual.subscriber import set_recursive_language from plone.base.interfaces import ILanguage from plone.base.interfaces import INavigationRoot +from plone.base.utils import get_installer from plone.base.utils import unrestricted_construct_instance from plone.dexterity.interfaces import IDexterityFTI from plone.i18n.locales.languages import _combinedlanguagelist @@ -59,6 +60,7 @@ def setupSite(self, context, forceOneLanguage=False): self.ensure_translatable(self.folder_type) self.ensure_translatable(self.folder_type_language_independent) + self.add_volto_blocks_behavior_to_lrf() language_tool = getToolByName(self.context, "portal_languages") self.languages = languages = language_tool.getSupportedLanguages() @@ -286,3 +288,25 @@ def ensure_translatable(self, type_): if "plone.translatable" not in behaviors: behaviors.append("plone.translatable") fti._updateProperty("behaviors", tuple(behaviors)) + + def add_volto_blocks_behavior_to_lrf(self): + """Add volto.blocks behavior to LRF if plone.volto is installed. + + When plone.volto is installed before plone.app.multilingual, the LRF type + should have the volto.blocks behavior to support Volto's block-based editing. + """ + installer = get_installer(self.context) + + if not installer.is_product_installed("plone.volto"): + return + + types_tool = self.context.portal_types + lrf_fti = types_tool.get("LRF") + if lrf_fti is None: + return + + behaviors = list(getattr(lrf_fti, "behaviors", ())) + if "volto.blocks" not in behaviors: + behaviors.append("volto.blocks") + lrf_fti.behaviors = tuple(behaviors) + logger.info("Added volto.blocks behavior to LRF type") diff --git a/src/plone/app/multilingual/setuphandlers.py b/src/plone/app/multilingual/setuphandlers.py index a352fdd9..94737451 100644 --- a/src/plone/app/multilingual/setuphandlers.py +++ b/src/plone/app/multilingual/setuphandlers.py @@ -2,7 +2,6 @@ from plone.app.multilingual.browser.setup import SetupMultilingualSite from plone.app.multilingual.itg import addAttributeTG from plone.base.interfaces import INonInstallable -from plone.base.utils import get_installer from Products.CMFCore.utils import getToolByName from zope.component.hooks import getSite from zope.interface import implementer @@ -20,35 +19,10 @@ def getNonInstallableProfiles(self): ] -def add_volto_blocks_behavior_to_lrf(portal): - """Add volto.blocks behavior to LRF if plone.volto is installed. - - When plone.volto is installed before plone.app.multilingual, the LRF type - should have the volto.blocks behavior to support Volto's block-based editing. - """ - installer = get_installer(portal) - - if not installer.is_product_installed("plone.volto"): - return - - types_tool = portal.portal_types - lrf_fti = types_tool.get("LRF") - if lrf_fti is None: - return - - behaviors = list(getattr(lrf_fti, "behaviors", ())) - if "volto.blocks" not in behaviors: - behaviors.append("volto.blocks") - lrf_fti.behaviors = tuple(behaviors) - logger.info("Added volto.blocks behavior to LRF type") - - def init_pam(tool): """After installation run setup to create LRF and LIF.""" - site = getSite() setup_tool = SetupMultilingualSite() - setup_tool.setupSite(site) - add_volto_blocks_behavior_to_lrf(site) + setup_tool.setupSite(getSite()) def step_default_various(context): From 498b884b5333bd09a8cfe8f214a60164ff4145c6 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sun, 7 Dec 2025 11:59:58 +0100 Subject: [PATCH 21/45] zpretty --- src/plone/app/multilingual/configure.zcml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plone/app/multilingual/configure.zcml b/src/plone/app/multilingual/configure.zcml index 7db19587..ae08daaf 100644 --- a/src/plone/app/multilingual/configure.zcml +++ b/src/plone/app/multilingual/configure.zcml @@ -202,12 +202,12 @@ plone.registry.interfaces.IRecordModifiedEvent" handler=".subscriber.change_language_settings" /> - + + /> From 3e80c75c459967fec2d1170d83f7b736efa4f979 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sun, 7 Dec 2025 12:01:55 +0100 Subject: [PATCH 22/45] fix dependnecies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e070d448..73b70b41 100644 --- a/setup.py +++ b/setup.py @@ -57,8 +57,8 @@ "plone.browserlayer", "plone.rfc822", "plone.testing", + "plone.volto", "robotsuite", - "Products.CMFPlacefulWorkflow", "plone.restapi[test]", ], }, From 24ea463f7a2becfd858de3a5b61ea59d2efd0790 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Dec 2025 12:15:44 +0100 Subject: [PATCH 23/45] breaking --- .../app/multilingual/browser/configure.zcml | 8 -- .../browser/javascript/babel_helper.js | 16 ++-- .../browser/templates/dexterity_edit.pt | 4 +- .../app/multilingual/browser/translate.py | 86 +------------------ src/plone/app/multilingual/browser/utils.py | 2 +- src/plone/app/multilingual/configure.zcml | 7 -- .../app/multilingual/google_translate.py | 71 --------------- src/plone/app/multilingual/interfaces.py | 9 -- .../profiles/default/metadata.xml | 2 +- .../profiles/default/registry.xml | 1 - .../profiles/upgrades/to_1003/registry.xml | 11 +++ .../app/multilingual/restapi/configure.zcml | 9 +- .../multilingual/restapi/translate_text.py | 7 +- .../restapi/translation_service.py | 73 ++++++++++++++++ .../app/multilingual/translation_utils.py | 44 ++++++++++ src/plone/app/multilingual/upgrades.zcml | 21 +++++ 16 files changed, 175 insertions(+), 196 deletions(-) delete mode 100644 src/plone/app/multilingual/google_translate.py create mode 100644 src/plone/app/multilingual/profiles/upgrades/to_1003/registry.xml create mode 100644 src/plone/app/multilingual/restapi/translation_service.py create mode 100644 src/plone/app/multilingual/translation_utils.py diff --git a/src/plone/app/multilingual/browser/configure.zcml b/src/plone/app/multilingual/browser/configure.zcml index 0f4d6642..7f03d086 100644 --- a/src/plone/app/multilingual/browser/configure.zcml +++ b/src/plone/app/multilingual/browser/configure.zcml @@ -226,14 +226,6 @@ - - - - diff --git a/src/plone/app/multilingual/browser/translate.py b/src/plone/app/multilingual/browser/translate.py index 1dabfa78..e13e80d1 100644 --- a/src/plone/app/multilingual/browser/translate.py +++ b/src/plone/app/multilingual/browser/translate.py @@ -1,93 +1,9 @@ from Acquisition import aq_inner from plone.app.multilingual import _ -from plone.app.multilingual.interfaces import IExternalTranslationService from plone.app.multilingual.interfaces import ITranslationManager -from plone.app.uuid.utils import uuidToObject -from plone.base.interfaces import ILanguage + from plone.uuid.interfaces import IUUID from Products.Five import BrowserView -from zope.component import getUtilitiesFor -from zope.component import getUtility - -import json - - -def translate_text(original_text, source_language, target_language, service=None): - """translate the text""" - - if original_text: - # Initial shortcut: translate only non-empty values - - if service is not None: - # if an specific adapter is requested, use it if available - - adapter = getUtility(IExternalTranslationService, name=service) - if not adapter.is_available(): - return None - - adapters = [adapter] - - else: - # Get all available adapters - adapters = [ - adapter - for _, adapter in getUtilitiesFor(IExternalTranslationService) - if adapter.is_available() - ] - - sorted_adapters = sorted(adapters, key=lambda x: x.order) - - for adapter in sorted_adapters: - available_languages = adapter.available_languages() - if ( - not available_languages - or (source_language, target_language) in available_languages - ): - translation = adapter.translate_content( - original_text, source_language, target_language - ) - - if translation: - return translation - - return None - - -class gtranslation_service_dexterity(BrowserView): - def __call__(self): - if self.request.method != "POST" and not ( - "field" in self.request.form.keys() - and "lang_source" in self.request.form.keys() - ): - return _("Need a field") - else: - context_uid = self.request.form.get("context_uid", None) - if context_uid is None: - # try with context if no translation uid is present - manager = ITranslationManager(self.context) - else: - context = uuidToObject(context_uid) - if context is not None: - manager = ITranslationManager(context) - else: - manager = ITranslationManager(self.context) - - lang_target = ILanguage(self.context).get_language() - lang_source = self.request.form["lang_source"] - orig_object = manager.get_translation(lang_source) - field = self.request.form["field"].split(".")[-1] - if hasattr(orig_object, field): - question = getattr(orig_object, field, "") or "" - if hasattr(question, "raw"): - question = question.raw - else: - return _("Invalid field") - - translation = translate_text(question, lang_source, lang_target) - if translation is None: - return json.dumps({"data": ""}) - - return json.dumps({"data": translation}) class TranslationForm(BrowserView): diff --git a/src/plone/app/multilingual/browser/utils.py b/src/plone/app/multilingual/browser/utils.py index 350c12db..46ed768c 100644 --- a/src/plone/app/multilingual/browser/utils.py +++ b/src/plone/app/multilingual/browser/utils.py @@ -53,7 +53,7 @@ def getPortal(self): def objToTranslate(self): return self.context - def gtenabled(self): + def translations_enabled(self): adapters = [ adapter for _, adapter in getAdapters((self.context,), IExternalTranslationService) diff --git a/src/plone/app/multilingual/configure.zcml b/src/plone/app/multilingual/configure.zcml index ae08daaf..999463b5 100644 --- a/src/plone/app/multilingual/configure.zcml +++ b/src/plone/app/multilingual/configure.zcml @@ -203,11 +203,4 @@ handler=".subscriber.change_language_settings" /> - - - diff --git a/src/plone/app/multilingual/google_translate.py b/src/plone/app/multilingual/google_translate.py deleted file mode 100644 index 8af23c71..00000000 --- a/src/plone/app/multilingual/google_translate.py +++ /dev/null @@ -1,71 +0,0 @@ -from plone.app.multilingual.interfaces import IMultiLanguageExtraOptionsSchema -from plone.registry.interfaces import IRegistry -from zope.component import getUtility - -import json -import urllib - - -class GoogleCloudTranslationAPIFactory: - """implement the external translation using Google Cloud Translation API""" - - order = 999 - - def is_available(self): - registry = getUtility(IRegistry) - settings = registry.forInterface( - IMultiLanguageExtraOptionsSchema, prefix="plone" - ) - key = settings.google_translation_key - return key is not None and len(key.strip()) > 0 - - def available_languages(self): - # All languages are supported - return [] - - def translate_content(self, content, source_language, target_language): - registry = getUtility(IRegistry) - settings = registry.forInterface( - IMultiLanguageExtraOptionsSchema, prefix="plone" - ) - - question = content - length = len(question) - translated = "" - url = "https://www.googleapis.com/language/translate/v2" - temp_question = question - while length > 400: - temp_question = question[:399] - index = temp_question.rfind(" ") - temp_question = temp_question[:index] - question = question[index:] - length = len(question) - data = { - "key": settings.google_translation_key, - "target": target_language, - "source": source_language, - "q": temp_question, - } - params = urllib.parse.urlencode(data) - - retorn = urllib.request.urlopen(url + "?" + params) - translated += json.loads(retorn.read())["data"]["translations"][0][ - "translatedText" - ] - - data = { - "key": settings.google_translation_key, - "target": target_language, - "source": source_language, - "q": temp_question, - } - params = urllib.parse.urlencode(data) - - retorn = urllib.request.urlopen(url + "?" + params) - translated += json.loads(retorn.read())["data"]["translations"][0][ - "translatedText" - ] - return translated - - -GoogleCloudTranslationAPI = GoogleCloudTranslationAPIFactory() diff --git a/src/plone/app/multilingual/interfaces.py b/src/plone/app/multilingual/interfaces.py index abdd4737..53d17e28 100644 --- a/src/plone/app/multilingual/interfaces.py +++ b/src/plone/app/multilingual/interfaces.py @@ -254,15 +254,6 @@ class IMultiLanguageExtraOptionsSchema(ILanguageSchema): required=False, ) - google_translation_key = schema.TextLine( - title=_("heading_google_translation_key", default="Google Translation API Key"), - description=_( - "description_google_translation_key", - default="Is a paying API in order to use google translation " "service", - ), - required=False, - ) - selector_lookup_translations_policy = schema.Choice( title=_( "heading_selector_lookup_translations_policy", diff --git a/src/plone/app/multilingual/profiles/default/metadata.xml b/src/plone/app/multilingual/profiles/default/metadata.xml index acd6fba1..e7517f7c 100644 --- a/src/plone/app/multilingual/profiles/default/metadata.xml +++ b/src/plone/app/multilingual/profiles/default/metadata.xml @@ -1,6 +1,6 @@ - 1002 + 1003 profile-plone.app.dexterity:default diff --git a/src/plone/app/multilingual/profiles/default/registry.xml b/src/plone/app/multilingual/profiles/default/registry.xml index 9df56969..8fe8bb49 100644 --- a/src/plone/app/multilingual/profiles/default/registry.xml +++ b/src/plone/app/multilingual/profiles/default/registry.xml @@ -32,7 +32,6 @@ True False 7 - closest diff --git a/src/plone/app/multilingual/profiles/upgrades/to_1003/registry.xml b/src/plone/app/multilingual/profiles/upgrades/to_1003/registry.xml new file mode 100644 index 00000000..04923e2d --- /dev/null +++ b/src/plone/app/multilingual/profiles/upgrades/to_1003/registry.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/src/plone/app/multilingual/restapi/configure.zcml b/src/plone/app/multilingual/restapi/configure.zcml index da762b38..eea382b4 100644 --- a/src/plone/app/multilingual/restapi/configure.zcml +++ b/src/plone/app/multilingual/restapi/configure.zcml @@ -6,7 +6,6 @@ - + + diff --git a/src/plone/app/multilingual/restapi/translate_text.py b/src/plone/app/multilingual/restapi/translate_text.py index c24ce66b..4f7a73ef 100644 --- a/src/plone/app/multilingual/restapi/translate_text.py +++ b/src/plone/app/multilingual/restapi/translate_text.py @@ -1,4 +1,5 @@ -from plone.app.multilingual.browser.translate import translate_text +from plone.app.multilingual import _ +from plone.app.multilingual.translation_utils import translate_text from plone.restapi.deserializer import json_body from plone.restapi.services import Service @@ -21,8 +22,8 @@ def reply(self): self.request.response.setStatus(400) return dict( error=dict( - type="Translation service not available", - message="The requested translation service is not available.", + type=_("Translation service not available"), + message=_("The requested translation service is not available."), ) ) diff --git a/src/plone/app/multilingual/restapi/translation_service.py b/src/plone/app/multilingual/restapi/translation_service.py new file mode 100644 index 00000000..423a39ed --- /dev/null +++ b/src/plone/app/multilingual/restapi/translation_service.py @@ -0,0 +1,73 @@ +from plone.app.multilingual import _ +from plone.app.multilingual.interfaces import ITranslationManager +from plone.app.multilingual.translation_utils import translate_text +from plone.app.uuid.utils import uuidToObject +from plone.base.interfaces import ILanguage +from plone.restapi.deserializer import json_body +from plone.restapi.services import Service + + +class TranslationService(Service): + + def reply(self): + body = json_body(self.request) + if "field" not in body: + self.request.response.setStatus(400) + return { + "error": { + "type": _("Invalid parameter error"), + "message": _( + "The parameter ${field} is required.", + mapping={"field": "field"}, + ), + } + } + + if "lang_source" not in body: + self.request.response.setStatus(400) + return { + "error": { + "type": _("Invalid parameter error"), + "message": _( + "The parameter ${field} is required.", + mapping={"field": "lang_source"}, + ), + } + } + + context_uid = body.get("context_uid", None) + if context_uid is None: + # try with context if no translation uid is present + manager = ITranslationManager(self.context) + else: + context = uuidToObject(context_uid) + if context is not None: + manager = ITranslationManager(context) + else: + manager = ITranslationManager(self.context) + + lang_target = ILanguage(self.context).get_language() + lang_source = self.request.form["lang_source"] + orig_object = manager.get_translation(lang_source) + field = self.request.form["field"].split(".")[-1] + if hasattr(orig_object, field): + question = getattr(orig_object, field, "") or "" + if hasattr(question, "raw"): + question = question.raw + else: + self.request.response.setStatus(400) + return { + "error": { + "type": _("Invalid field error"), + "message": _( + "The field ${field} is not valid.", + mapping={"field": field}, + ), + } + } + + translation = translate_text(question, lang_source, lang_target) + if translation is None: + return {"data": ""} + + return {"data": translation} diff --git a/src/plone/app/multilingual/translation_utils.py b/src/plone/app/multilingual/translation_utils.py new file mode 100644 index 00000000..41750e13 --- /dev/null +++ b/src/plone/app/multilingual/translation_utils.py @@ -0,0 +1,44 @@ +from plone.app.multilingual.interfaces import IExternalTranslationService +from zope.component import getUtilitiesFor +from zope.component import getUtility + + +def translate_text(original_text, source_language, target_language, service=None): + """translate the text""" + + if original_text: + # Initial shortcut: translate only non-empty values + + if service is not None: + # if an specific adapter is requested, use it if available + + adapter = getUtility(IExternalTranslationService, name=service) + if not adapter.is_available(): + return None + + adapters = [adapter] + + else: + # Get all available adapters + adapters = [ + adapter + for _, adapter in getUtilitiesFor(IExternalTranslationService) + if adapter.is_available() + ] + + sorted_adapters = sorted(adapters, key=lambda x: x.order) + + for adapter in sorted_adapters: + available_languages = adapter.available_languages() + if ( + not available_languages + or (source_language, target_language) in available_languages + ): + translation = adapter.translate_content( + original_text, source_language, target_language + ) + + if translation: + return translation + + return None diff --git a/src/plone/app/multilingual/upgrades.zcml b/src/plone/app/multilingual/upgrades.zcml index b5130e65..494089ff 100644 --- a/src/plone/app/multilingual/upgrades.zcml +++ b/src/plone/app/multilingual/upgrades.zcml @@ -91,4 +91,25 @@ handler=".upgrades.fix_indonesian_language" /> + + + + + + + + From 2b8dadeff4e31a9f1a72449ff14fcf60309c548c Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Dec 2025 14:51:22 +0100 Subject: [PATCH 24/45] utlity --- .../browser/javascript/babel_helper.js | 5 ++++- src/plone/app/multilingual/browser/utils.py | 13 ++++++------- .../multilingual/restapi/translation_service.py | 15 +++++++++++++-- src/plone/app/multilingual/translation_utils.py | 16 ++++++++-------- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/plone/app/multilingual/browser/javascript/babel_helper.js b/src/plone/app/multilingual/browser/javascript/babel_helper.js index 91725562..29b80a5d 100644 --- a/src/plone/app/multilingual/browser/javascript/babel_helper.js +++ b/src/plone/app/multilingual/browser/javascript/babel_helper.js @@ -91,7 +91,8 @@ "translation_service_available" ); const target_el = dest_field.querySelector('textarea,input'); - const target_tiny = tinymce.get(target_el.id); + // const target_tiny = tinymce.get(target_el.id); + const target_tiny = null; sync_focus(orig_field, dest_field, target_tiny); sync_heights(orig_field, dest_field); @@ -110,6 +111,7 @@ translator_widget.classList.add("translator-widget"); translator_widget.id = `item_translation_${order}`; + translator_widget.style.display = 'block'; translator_widget.addEventListener("click", async function () { var field = orig_field.getAttribute("rel"); @@ -131,6 +133,7 @@ method: "POST", headers: { "Content-type": "application/json; charset: utf-8", + "Accept": "application/json" }, body: JSON.stringify(postdata), }); diff --git a/src/plone/app/multilingual/browser/utils.py b/src/plone/app/multilingual/browser/utils.py index 46ed768c..fd88c2b8 100644 --- a/src/plone/app/multilingual/browser/utils.py +++ b/src/plone/app/multilingual/browser/utils.py @@ -15,7 +15,7 @@ from plone.registry.interfaces import IRegistry from Products.CMFCore.utils import getToolByName from Products.Five import BrowserView -from zope.component import getAdapters +from zope.component import getUtilitiesFor from zope.component import getMultiAdapter from zope.component import getUtility from zope.component.hooks import getSite @@ -54,13 +54,12 @@ def objToTranslate(self): return self.context def translations_enabled(self): - adapters = [ - adapter - for _, adapter in getAdapters((self.context,), IExternalTranslationService) - if adapter.is_available() + utilities = [ + name + for name, utility in getUtilitiesFor(IExternalTranslationService) + if utility.is_available() ] - - return len(adapters) > 0 + return len(utilities) > 0 def languages(self): """Deprecated""" diff --git a/src/plone/app/multilingual/restapi/translation_service.py b/src/plone/app/multilingual/restapi/translation_service.py index 423a39ed..76ace948 100644 --- a/src/plone/app/multilingual/restapi/translation_service.py +++ b/src/plone/app/multilingual/restapi/translation_service.py @@ -47,9 +47,20 @@ def reply(self): manager = ITranslationManager(self.context) lang_target = ILanguage(self.context).get_language() - lang_source = self.request.form["lang_source"] + lang_source = body.get("lang_source") orig_object = manager.get_translation(lang_source) - field = self.request.form["field"].split(".")[-1] + if orig_object is None: + return { + "error": { + "type": _("Invalid content object"), + "message": _( + "The referenced content object is not available in ${language} language.", + mapping={"language": lang_source}, + ), + } + } + + field = body.get("field", "").split(".")[-1] if hasattr(orig_object, field): question = getattr(orig_object, field, "") or "" if hasattr(question, "raw"): diff --git a/src/plone/app/multilingual/translation_utils.py b/src/plone/app/multilingual/translation_utils.py index 41750e13..6f1d5ec6 100644 --- a/src/plone/app/multilingual/translation_utils.py +++ b/src/plone/app/multilingual/translation_utils.py @@ -12,21 +12,21 @@ def translate_text(original_text, source_language, target_language, service=None if service is not None: # if an specific adapter is requested, use it if available - adapter = getUtility(IExternalTranslationService, name=service) - if not adapter.is_available(): + utility = getUtility(IExternalTranslationService, name=service) + if not utility.is_available(): return None - adapters = [adapter] + utilities = [utility] else: # Get all available adapters - adapters = [ - adapter - for _, adapter in getUtilitiesFor(IExternalTranslationService) - if adapter.is_available() + utilities = [ + utility + for name, utility in getUtilitiesFor(IExternalTranslationService) + if utility.is_available() ] - sorted_adapters = sorted(adapters, key=lambda x: x.order) + sorted_adapters = sorted(utilities, key=lambda x: int(x.order)) for adapter in sorted_adapters: available_languages = adapter.available_languages() From 38fa58896076617fd4ac3c7e51163c4b1a1ba134 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Dec 2025 15:04:56 +0100 Subject: [PATCH 25/45] lint --- src/plone/app/multilingual/browser/translate.py | 2 -- src/plone/app/multilingual/browser/utils.py | 2 +- .../app/multilingual/profiles/upgrades/to_1003/registry.xml | 4 +++- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plone/app/multilingual/browser/translate.py b/src/plone/app/multilingual/browser/translate.py index e13e80d1..754755ce 100644 --- a/src/plone/app/multilingual/browser/translate.py +++ b/src/plone/app/multilingual/browser/translate.py @@ -1,7 +1,5 @@ from Acquisition import aq_inner -from plone.app.multilingual import _ from plone.app.multilingual.interfaces import ITranslationManager - from plone.uuid.interfaces import IUUID from Products.Five import BrowserView diff --git a/src/plone/app/multilingual/browser/utils.py b/src/plone/app/multilingual/browser/utils.py index fd88c2b8..c7748e3d 100644 --- a/src/plone/app/multilingual/browser/utils.py +++ b/src/plone/app/multilingual/browser/utils.py @@ -15,8 +15,8 @@ from plone.registry.interfaces import IRegistry from Products.CMFCore.utils import getToolByName from Products.Five import BrowserView -from zope.component import getUtilitiesFor from zope.component import getMultiAdapter +from zope.component import getUtilitiesFor from zope.component import getUtility from zope.component.hooks import getSite diff --git a/src/plone/app/multilingual/profiles/upgrades/to_1003/registry.xml b/src/plone/app/multilingual/profiles/upgrades/to_1003/registry.xml index 04923e2d..859069db 100644 --- a/src/plone/app/multilingual/profiles/upgrades/to_1003/registry.xml +++ b/src/plone/app/multilingual/profiles/upgrades/to_1003/registry.xml @@ -5,7 +5,9 @@ prefix="plone" purge="false" > - + From 9911ee8b99fbe721cf7053aebbf1ab163f90d3f4 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Dec 2025 15:07:51 +0100 Subject: [PATCH 26/45] mark changes as breaking --- news/467.breaking | 1 + news/467.feature | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) create mode 100644 news/467.breaking delete mode 100644 news/467.feature diff --git a/news/467.breaking b/news/467.breaking new file mode 100644 index 00000000..8f446d76 --- /dev/null +++ b/news/467.breaking @@ -0,0 +1 @@ +Reimplement usage of translators as pluggable utilities and remove Google Translate service @erral diff --git a/news/467.feature b/news/467.feature deleted file mode 100644 index 51b221d5..00000000 --- a/news/467.feature +++ /dev/null @@ -1,2 +0,0 @@ -Reimplement usage of Google Translate API as utility, to be able to provide different translation services -[erral] From 35ceca2e72d980c9eb9b4b9b90a9d0a786511328 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Dec 2025 15:25:59 +0100 Subject: [PATCH 27/45] remove more google traces --- src/plone/app/multilingual/interfaces.py | 1 - .../app/multilingual/tests/test_restapi.py | 18 +----------------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/src/plone/app/multilingual/interfaces.py b/src/plone/app/multilingual/interfaces.py index 53d17e28..1b50a17f 100644 --- a/src/plone/app/multilingual/interfaces.py +++ b/src/plone/app/multilingual/interfaces.py @@ -189,7 +189,6 @@ class IMultiLanguageExtraOptionsSchema(ILanguageSchema): "redirect_babel_view", "bypass_languageindependent_field_permission_check", "buttons_babel_view_up_to_nr_translations", - "google_translation_key", "selector_lookup_translations_policy", ], ) diff --git a/src/plone/app/multilingual/tests/test_restapi.py b/src/plone/app/multilingual/tests/test_restapi.py index 1f8078e5..8b2105db 100644 --- a/src/plone/app/multilingual/tests/test_restapi.py +++ b/src/plone/app/multilingual/tests/test_restapi.py @@ -81,26 +81,10 @@ def test_that_two_are_disabledd(self): disabled_adapters = [ adapter for adapter in results if not adapter["is_available"] ] - # There are 2 disabled adapters: the default provided one, Google Translate, and ours - self.assertEqual(len(disabled_adapters), 2) + self.assertEqual(len(disabled_adapters), 1) disabled_adapter_names = [adapter["name"] for adapter in disabled_adapters] self.assertIn("disabled_translator", disabled_adapter_names) - self.assertIn("google_translate", disabled_adapter_names) - - def test_that_one_is_disabled(self): - """enter a dummy Google Translation key, to have this service enabled""" - api.portal.set_registry_record("plone.google_translation_key", "DUMMY KEY") - transaction.commit() - api_result = self.api_session.get("/@translation-services") - results = api_result.json() - - disabled_adapters = [ - adapter for adapter in results if not adapter["is_available"] - ] - # There are 1 disabled adapters: only ours - self.assertEqual(len(disabled_adapters), 1) - self.assertEqual(disabled_adapters[0]["name"], "disabled_translator") class TestTranslateTextServices(unittest.TestCase): From 931dc60a95ed64f42b72a16360068409c437b9b8 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Dec 2025 15:29:09 +0100 Subject: [PATCH 28/45] lint --- src/plone/app/multilingual/tests/test_restapi.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plone/app/multilingual/tests/test_restapi.py b/src/plone/app/multilingual/tests/test_restapi.py index 8b2105db..3f0fc991 100644 --- a/src/plone/app/multilingual/tests/test_restapi.py +++ b/src/plone/app/multilingual/tests/test_restapi.py @@ -1,4 +1,3 @@ -from plone import api from plone.app.multilingual.interfaces import IExternalTranslationService from plone.app.multilingual.testing import CaEsTranslator from plone.app.multilingual.testing import DisabledTranslator From 5e99984f00f1e4b283d90d37744c27158914e15c Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Dec 2025 15:53:50 +0100 Subject: [PATCH 29/45] fixes --- src/plone/app/multilingual/tests/test_restapi.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plone/app/multilingual/tests/test_restapi.py b/src/plone/app/multilingual/tests/test_restapi.py index 3f0fc991..9b9d3d04 100644 --- a/src/plone/app/multilingual/tests/test_restapi.py +++ b/src/plone/app/multilingual/tests/test_restapi.py @@ -33,7 +33,7 @@ def setUp(self): def test_available_services(self): """test that by default we have just one available service""" api_result = self.api_session.get("/@translation-services") - self.assertEqual(len(api_result.json()), 1) + self.assertEqual(len(api_result.json()), 0) class TestSeveralTranslationServices(unittest.TestCase): @@ -70,9 +70,9 @@ def setUp(self): def test_available_services(self): """test that by default we have just one available service""" api_result = self.api_session.get("/@translation-services") - self.assertEqual(len(api_result.json()), 4) + self.assertEqual(len(api_result.json()), 3) - def test_that_two_are_disabledd(self): + def test_that_one_is_disabled(self): """we have registered an adapter that is disabled, check that we get that information correctly""" api_result = self.api_session.get("/@translation-services") results = api_result.json() From 6db00c18d624c64d26d88d1f226935f98f8b4577 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Dec 2025 17:34:13 +0100 Subject: [PATCH 30/45] remove unneeded --- setup.py | 1 - src/plone/app/multilingual/browser/setup.py | 24 ------------------- .../app/multilingual/tests/test_restapi.py | 7 +++--- 3 files changed, 4 insertions(+), 28 deletions(-) diff --git a/setup.py b/setup.py index 73b70b41..64c9e205 100644 --- a/setup.py +++ b/setup.py @@ -57,7 +57,6 @@ "plone.browserlayer", "plone.rfc822", "plone.testing", - "plone.volto", "robotsuite", "plone.restapi[test]", ], diff --git a/src/plone/app/multilingual/browser/setup.py b/src/plone/app/multilingual/browser/setup.py index 6110b0aa..e1ce0dc9 100644 --- a/src/plone/app/multilingual/browser/setup.py +++ b/src/plone/app/multilingual/browser/setup.py @@ -7,7 +7,6 @@ from plone.app.multilingual.subscriber import set_recursive_language from plone.base.interfaces import ILanguage from plone.base.interfaces import INavigationRoot -from plone.base.utils import get_installer from plone.base.utils import unrestricted_construct_instance from plone.dexterity.interfaces import IDexterityFTI from plone.i18n.locales.languages import _combinedlanguagelist @@ -60,7 +59,6 @@ def setupSite(self, context, forceOneLanguage=False): self.ensure_translatable(self.folder_type) self.ensure_translatable(self.folder_type_language_independent) - self.add_volto_blocks_behavior_to_lrf() language_tool = getToolByName(self.context, "portal_languages") self.languages = languages = language_tool.getSupportedLanguages() @@ -288,25 +286,3 @@ def ensure_translatable(self, type_): if "plone.translatable" not in behaviors: behaviors.append("plone.translatable") fti._updateProperty("behaviors", tuple(behaviors)) - - def add_volto_blocks_behavior_to_lrf(self): - """Add volto.blocks behavior to LRF if plone.volto is installed. - - When plone.volto is installed before plone.app.multilingual, the LRF type - should have the volto.blocks behavior to support Volto's block-based editing. - """ - installer = get_installer(self.context) - - if not installer.is_product_installed("plone.volto"): - return - - types_tool = self.context.portal_types - lrf_fti = types_tool.get("LRF") - if lrf_fti is None: - return - - behaviors = list(getattr(lrf_fti, "behaviors", ())) - if "volto.blocks" not in behaviors: - behaviors.append("volto.blocks") - lrf_fti.behaviors = tuple(behaviors) - logger.info("Added volto.blocks behavior to LRF type") diff --git a/src/plone/app/multilingual/tests/test_restapi.py b/src/plone/app/multilingual/tests/test_restapi.py index 9b9d3d04..bd399a3d 100644 --- a/src/plone/app/multilingual/tests/test_restapi.py +++ b/src/plone/app/multilingual/tests/test_restapi.py @@ -7,6 +7,7 @@ from plone.app.testing import SITE_OWNER_NAME from plone.app.testing import SITE_OWNER_PASSWORD from plone.app.testing import TEST_USER_ID +from plone.restapi.testing import PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING from plone.restapi.testing import RelativeSession from zope.component import provideUtility @@ -17,7 +18,7 @@ class TestDefaultTranslationServices(unittest.TestCase): """Test the default translation services provided by plone.app.multilingual""" - layer = PAM_ROBOT_TESTING + layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING def setUp(self): self.app = self.layer["app"] @@ -41,7 +42,7 @@ class TestSeveralTranslationServices(unittest.TestCase): in the REST API """ - layer = PAM_ROBOT_TESTING + layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING def setUp(self): self.app = self.layer["app"] @@ -87,7 +88,7 @@ def test_that_one_is_disabled(self): class TestTranslateTextServices(unittest.TestCase): - layer = PAM_ROBOT_TESTING + layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING def setUp(self): self.app = self.layer["app"] From 987229e1c3e1164806bc2e06b64b0c81d4aa6ba2 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Dec 2025 17:34:49 +0100 Subject: [PATCH 31/45] remove unneeded --- news/518.feature | 1 - 1 file changed, 1 deletion(-) delete mode 100644 news/518.feature diff --git a/news/518.feature b/news/518.feature deleted file mode 100644 index 6913708d..00000000 --- a/news/518.feature +++ /dev/null @@ -1 +0,0 @@ -Adds the volto.blocks behavior to LRF if `plone.volto` is installed. @wesleybl From 00d149ff678d57f165dfd05277ceeb99f4278b38 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Dec 2025 17:53:11 +0100 Subject: [PATCH 32/45] remove unneeded --- .../app/multilingual/tests/test_setup.py | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/src/plone/app/multilingual/tests/test_setup.py b/src/plone/app/multilingual/tests/test_setup.py index 9b75120b..d4bc788a 100644 --- a/src/plone/app/multilingual/tests/test_setup.py +++ b/src/plone/app/multilingual/tests/test_setup.py @@ -12,15 +12,6 @@ import unittest -# Check if plone.volto is available and import layer if so -try: - from plone.app.multilingual.testing import HAS_PLONE_VOLTO - from plone.app.multilingual.testing import VOLTO_MULTILINGUAL_INTEGRATION_TESTING -except ImportError: - HAS_PLONE_VOLTO = False - VOLTO_MULTILINGUAL_INTEGRATION_TESTING = None - - class TestSetupMultilingualSite(unittest.TestCase): """Testing multilingual site without predefined languages.""" @@ -109,19 +100,6 @@ def test_lrf_has_locking_behavior(self): "LRF type should have plone.locking behavior enabled", ) - def test_lrf_does_not_have_volto_blocks_behavior(self): - """LRF type should NOT have volto.blocks behavior without Volto installed.""" - portal_types = getToolByName(self.portal, "portal_types") - lrf_type = portal_types.get("LRF") - - behaviors = getattr(lrf_type, "behaviors", ()) - - self.assertNotIn( - "volto.blocks", - behaviors, - "LRF type should not have volto.blocks behavior without Volto", - ) - class TestSetupMultilingualPresetSite(unittest.TestCase): """Testing multilingual site with predefined languages.""" From cdc4734854c8731d85d4062bb135f16894398076 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Dec 2025 18:02:49 +0100 Subject: [PATCH 33/45] test new endpoint --- .../test_external_translation_services.py | 36 ++----- .../app/multilingual/tests/test_restapi.py | 94 ++++++++++++++++++- 2 files changed, 98 insertions(+), 32 deletions(-) diff --git a/src/plone/app/multilingual/tests/test_external_translation_services.py b/src/plone/app/multilingual/tests/test_external_translation_services.py index 9bd44d3e..496473dc 100644 --- a/src/plone/app/multilingual/tests/test_external_translation_services.py +++ b/src/plone/app/multilingual/tests/test_external_translation_services.py @@ -4,31 +4,26 @@ from plone.app.multilingual.testing import CaEsTranslator from plone.app.multilingual.testing import DisabledTranslator from plone.app.multilingual.testing import NiTranslator +from plone.app.multilingual.testing import PAM_INTEGRATION_PRESET_TESTING from plone.app.multilingual.testing import PAM_FUNCTIONAL_TESTING -from plone.app.testing import SITE_OWNER_NAME -from plone.app.testing import SITE_OWNER_PASSWORD from plone.dexterity.utils import createContentInContainer -from plone.testing._z2_testbrowser import Browser from zope.component import provideUtility from zope.interface import alsoProvides +from plone.app.multilingual.translation_utils import translate_text import json import transaction import unittest -class TestExternalServices(unittest.TestCase): +class TestExternalServicesUtilities(unittest.TestCase): layer = PAM_FUNCTIONAL_TESTING def setUp(self): self.portal = self.layer["portal"] + self.request = self.layer["request"] alsoProvides(self.layer["request"], IPloneAppMultilingualInstalled) - # Setup test browser - self.browser = Browser(self.layer["app"]) - self.browser.handleErrors = False - self.browser.addHeader( - "Authorization", f"Basic {SITE_OWNER_NAME}:{SITE_OWNER_PASSWORD}" - ) + self.a_ca = createContentInContainer( self.portal["ca"], "Document", title="Test document CA" ) @@ -60,26 +55,13 @@ def test_translation_ca_es(self): and it has the ca-es language pair translation availability """ - self.browser.open( - f"{self.a_es.absolute_url()}/gtranslation_service", - data={"field": "IDublinCore.title", "lang_source": "ca"}, - ) - - result = json.loads(self.browser.contents) - - self.assertEqual(result.get("data"), "text español") + result = translate_text(self.a_ca.Title(), "ca", "es") + self.assertEqual(result, "text español") def test_translation_es_ca(self): """In this case the NiTranslator should be applied because the previous translators have not this language pair available or are disabled """ - - self.browser.open( - f"{self.a_ca.absolute_url()}/gtranslation_service", - data={"field": "IDublinCore.title", "lang_source": "es"}, - ) - - result = json.loads(self.browser.contents) - - self.assertEqual(result.get("data"), "Test document ES NI!") + result = translate_text(self.a_es.Title(), "es", "ca") + self.assertEqual(result, "Test document ES NI!") diff --git a/src/plone/app/multilingual/tests/test_restapi.py b/src/plone/app/multilingual/tests/test_restapi.py index bd399a3d..66c2a14a 100644 --- a/src/plone/app/multilingual/tests/test_restapi.py +++ b/src/plone/app/multilingual/tests/test_restapi.py @@ -7,10 +7,10 @@ from plone.app.testing import SITE_OWNER_NAME from plone.app.testing import SITE_OWNER_PASSWORD from plone.app.testing import TEST_USER_ID -from plone.restapi.testing import PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING from plone.restapi.testing import RelativeSession from zope.component import provideUtility - +from plone.dexterity.utils import createContentInContainer +from plone.app.multilingual.interfaces import ITranslationManager import transaction import unittest @@ -18,7 +18,7 @@ class TestDefaultTranslationServices(unittest.TestCase): """Test the default translation services provided by plone.app.multilingual""" - layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING + layer = PAM_ROBOT_TESTING def setUp(self): self.app = self.layer["app"] @@ -42,7 +42,7 @@ class TestSeveralTranslationServices(unittest.TestCase): in the REST API """ - layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING + layer = PAM_ROBOT_TESTING def setUp(self): self.app = self.layer["app"] @@ -88,7 +88,7 @@ def test_that_one_is_disabled(self): class TestTranslateTextServices(unittest.TestCase): - layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING + layer = PAM_ROBOT_TESTING def setUp(self): self.app = self.layer["app"] @@ -166,3 +166,87 @@ def test_translation_given_service(self): result = api_result.json() self.assertEqual(result.get("data"), "Some other text NI!") + + +class TestTranslationsForBabelEdit(unittest.TestCase): + """Here we test the endpoint that is used in the babel_edit view""" + + layer = PAM_ROBOT_TESTING + + def setUp(self): + # alsoProvides(self.layer["request"], IPloneAppMultilingualInstalled) + + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.request = self.layer["request"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + provideUtility( + NiTranslator(), IExternalTranslationService, name="ni_translator" + ) + provideUtility( + DisabledTranslator(), + IExternalTranslationService, + name="disabled_translator", + ) + provideUtility( + CaEsTranslator(), IExternalTranslationService, name="ca_es_translator" + ) + + self.a_ca = createContentInContainer( + self.portal["ca"], "Document", title="Test document CA" + ) + + self.a_es = createContentInContainer( + self.portal["es"], "Document", title="Test document ES" + ) + + manager = ITranslationManager(self.a_ca) + manager.register_translation("es", self.a_es) + + provideUtility( + NiTranslator(), IExternalTranslationService, name="ni_translator" + ) + provideUtility( + DisabledTranslator(), + IExternalTranslationService, + name="disabled_translator", + ) + provideUtility( + CaEsTranslator(), IExternalTranslationService, name="ca_es_translator" + ) + + transaction.commit() + + def test_translation_ca_es(self): + """In this case the CaEsTranslator should be applied + because it has a smaller number in the order + and it has the ca-es language pair translation + availability + """ + api_result = self.api_session.post( + f"{self.a_es.absolute_url()}/@translation-service", + json={"field": "IDublinCore.title", "lang_source": "ca"}, + ) + self.assertTrue(api_result.ok) + result = api_result.json() + + self.assertEqual(result.get("data"), "text español") + + def test_translation_es_ca(self): + """In this case the NiTranslator should be applied + because the previous translators have not this language + pair available or are disabled + """ + + api_result = self.api_session.post( + f"{self.a_es.absolute_url()}/@translation-service", + json={"field": "IDublinCore.title", "lang_source": "es"}, + ) + self.assertTrue(api_result.ok) + result = api_result.json() + self.assertEqual(result.get("data"), "Test document ES NI!") From 7f5885a502270a04eb19d80bf1a18b953c1d6b3d Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Dec 2025 18:03:09 +0100 Subject: [PATCH 34/45] rename --- ...slation_services.py => test_external_translation_utilities.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/plone/app/multilingual/tests/{test_external_translation_services.py => test_external_translation_utilities.py} (100%) diff --git a/src/plone/app/multilingual/tests/test_external_translation_services.py b/src/plone/app/multilingual/tests/test_external_translation_utilities.py similarity index 100% rename from src/plone/app/multilingual/tests/test_external_translation_services.py rename to src/plone/app/multilingual/tests/test_external_translation_utilities.py From f7f5307d34439a9bd1a3388b863612e3602970ea Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Dec 2025 18:04:21 +0100 Subject: [PATCH 35/45] lint --- .../test_external_translation_utilities.py | 4 +- .../app/multilingual/tests/test_restapi.py | 5 +- .../app/multilingual/tests/test_setup.py | 52 ------------------- 3 files changed, 4 insertions(+), 57 deletions(-) diff --git a/src/plone/app/multilingual/tests/test_external_translation_utilities.py b/src/plone/app/multilingual/tests/test_external_translation_utilities.py index 496473dc..66817dc5 100644 --- a/src/plone/app/multilingual/tests/test_external_translation_utilities.py +++ b/src/plone/app/multilingual/tests/test_external_translation_utilities.py @@ -4,14 +4,12 @@ from plone.app.multilingual.testing import CaEsTranslator from plone.app.multilingual.testing import DisabledTranslator from plone.app.multilingual.testing import NiTranslator -from plone.app.multilingual.testing import PAM_INTEGRATION_PRESET_TESTING from plone.app.multilingual.testing import PAM_FUNCTIONAL_TESTING +from plone.app.multilingual.translation_utils import translate_text from plone.dexterity.utils import createContentInContainer from zope.component import provideUtility from zope.interface import alsoProvides -from plone.app.multilingual.translation_utils import translate_text -import json import transaction import unittest diff --git a/src/plone/app/multilingual/tests/test_restapi.py b/src/plone/app/multilingual/tests/test_restapi.py index 66c2a14a..c608be3b 100644 --- a/src/plone/app/multilingual/tests/test_restapi.py +++ b/src/plone/app/multilingual/tests/test_restapi.py @@ -1,4 +1,5 @@ from plone.app.multilingual.interfaces import IExternalTranslationService +from plone.app.multilingual.interfaces import ITranslationManager from plone.app.multilingual.testing import CaEsTranslator from plone.app.multilingual.testing import DisabledTranslator from plone.app.multilingual.testing import NiTranslator @@ -7,10 +8,10 @@ from plone.app.testing import SITE_OWNER_NAME from plone.app.testing import SITE_OWNER_PASSWORD from plone.app.testing import TEST_USER_ID +from plone.dexterity.utils import createContentInContainer from plone.restapi.testing import RelativeSession from zope.component import provideUtility -from plone.dexterity.utils import createContentInContainer -from plone.app.multilingual.interfaces import ITranslationManager + import transaction import unittest diff --git a/src/plone/app/multilingual/tests/test_setup.py b/src/plone/app/multilingual/tests/test_setup.py index d4bc788a..ca4a9a4d 100644 --- a/src/plone/app/multilingual/tests/test_setup.py +++ b/src/plone/app/multilingual/tests/test_setup.py @@ -127,55 +127,3 @@ def test_type_of_language_folders(self): """The created objects have to be 'Language Root Folder'.""" for lang in self.languages: self.assertEqual(self.portal.get(lang).portal_type, "LRF") - - -# Tests with plone.volto installed first -# Only define if plone.volto is available -if HAS_PLONE_VOLTO: - - class TestSetupWithVolto(unittest.TestCase): - """Testing multilingual site with plone.volto installed first. - - This test verifies that installing plone.app.multilingual:default - after plone.volto:default works correctly. - """ - - layer = VOLTO_MULTILINGUAL_INTEGRATION_TESTING - - def setUp(self): - """Setting up the test.""" - from plone.app.testing import applyProfile - - self.portal = self.layer["portal"] - self.request = self.layer["request"] - alsoProvides(self.layer["request"], IPloneAppMultilingualInstalled) - - # Install plone.app.multilingual after plone.volto is already installed - applyProfile(self.portal, "plone.app.multilingual:default") - - def test_volto_is_installed(self): - """plone.volto should be installed.""" - # Check if volto browserlayer is registered - from plone.browserlayer.utils import registered_layers - from plone.volto.interfaces import IPloneVoltoCoreLayer - - self.assertIn(IPloneVoltoCoreLayer, registered_layers()) - - def test_pam_is_installed(self): - """plone.app.multilingual should be installed after volto.""" - portal_types = getToolByName(self.portal, "portal_types") - # LRF type should exist - self.assertIn("LRF", portal_types.objectIds()) - - def test_lrf_has_volto_blocks_behavior(self): - """LRF type should have volto.blocks behavior when Volto is installed first.""" - portal_types = getToolByName(self.portal, "portal_types") - lrf_type = portal_types.get("LRF") - - behaviors = getattr(lrf_type, "behaviors", ()) - - self.assertIn( - "volto.blocks", - behaviors, - "LRF type should have volto.blocks behavior when Volto is installed first", - ) From 6ef0dac610b2060dd62dadaf2a23552f350b9532 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 12 Dec 2025 18:06:03 +0100 Subject: [PATCH 36/45] add missing test-dependency --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 64c9e205..8d1c5a00 100644 --- a/setup.py +++ b/setup.py @@ -59,6 +59,7 @@ "plone.testing", "robotsuite", "plone.restapi[test]", + "plone.volto", ], }, entry_points=""" From dd69e06317713ebdd483b3b49056547c7a604569 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sun, 14 Dec 2025 09:52:27 +0100 Subject: [PATCH 37/45] load plone.rest to have the plone:service registration available --- src/plone/app/multilingual/restapi/configure.zcml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plone/app/multilingual/restapi/configure.zcml b/src/plone/app/multilingual/restapi/configure.zcml index eea382b4..e35e7bed 100644 --- a/src/plone/app/multilingual/restapi/configure.zcml +++ b/src/plone/app/multilingual/restapi/configure.zcml @@ -4,7 +4,10 @@ i18n_domain="plone" > - + Date: Sun, 14 Dec 2025 09:52:49 +0100 Subject: [PATCH 38/45] register the endpoints only when p.a.multilingual is installed --- src/plone/app/multilingual/restapi/configure.zcml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/plone/app/multilingual/restapi/configure.zcml b/src/plone/app/multilingual/restapi/configure.zcml index e35e7bed..1a82587c 100644 --- a/src/plone/app/multilingual/restapi/configure.zcml +++ b/src/plone/app/multilingual/restapi/configure.zcml @@ -14,6 +14,7 @@ factory=".translation_services.TranslationServices" for="Products.CMFPlone.interfaces.IPloneSiteRoot" permission="cmf.ModifyPortalContent" + layer="..interfaces.IPloneAppMultilingualInstalled" name="@translation-services" /> @@ -22,6 +23,7 @@ factory=".translate_text.TranslateTextService" for="Products.CMFPlone.interfaces.IPloneSiteRoot" permission="cmf.ModifyPortalContent" + layer="..interfaces.IPloneAppMultilingualInstalled" name="@translate-text" /> @@ -30,6 +32,7 @@ factory=".translation_service.TranslationService" for="plone.dexterity.interfaces.IDexterityContent" permission="cmf.ModifyPortalContent" + layer="..interfaces.IPloneAppMultilingualInstalled" name="@translation-service" /> From 8cfb7eadbfcf6442dd50a8aa5cad07ff38377235 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sun, 14 Dec 2025 10:10:31 +0100 Subject: [PATCH 39/45] setup.py --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 8d1c5a00..087f478c 100644 --- a/setup.py +++ b/setup.py @@ -29,6 +29,7 @@ "plone.memoize", "plone.protect", "plone.registry", + "plone.rest", "plone.restapi", "plone.schemaeditor", "plone.supermodel", From 26c4f372c5772becf02d68e06b9f2aa2f686a253 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sun, 14 Dec 2025 10:38:17 +0100 Subject: [PATCH 40/45] remove unneeded --- src/plone/app/multilingual/tests/test_restapi.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/plone/app/multilingual/tests/test_restapi.py b/src/plone/app/multilingual/tests/test_restapi.py index c608be3b..6a8a1a4c 100644 --- a/src/plone/app/multilingual/tests/test_restapi.py +++ b/src/plone/app/multilingual/tests/test_restapi.py @@ -175,8 +175,6 @@ class TestTranslationsForBabelEdit(unittest.TestCase): layer = PAM_ROBOT_TESTING def setUp(self): - # alsoProvides(self.layer["request"], IPloneAppMultilingualInstalled) - self.app = self.layer["app"] self.portal = self.layer["portal"] self.request = self.layer["request"] From 352a48e75c3b65ef10adef8a6c88340669df5418 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Sun, 14 Dec 2025 10:38:22 +0100 Subject: [PATCH 41/45] load plone.restapi for tests --- src/plone/app/multilingual/testing.zcml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plone/app/multilingual/testing.zcml b/src/plone/app/multilingual/testing.zcml index ae4fbf82..bffb452a 100644 --- a/src/plone/app/multilingual/testing.zcml +++ b/src/plone/app/multilingual/testing.zcml @@ -5,5 +5,6 @@ + From c1649f4c6f8bb5ae18070c6ce108a489a43c0a76 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Mon, 15 Dec 2025 15:32:41 +0100 Subject: [PATCH 42/45] fix: load controlpanel permissions --- src/plone/app/multilingual/browser/configure.zcml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plone/app/multilingual/browser/configure.zcml b/src/plone/app/multilingual/browser/configure.zcml index 7f03d086..285f50f5 100644 --- a/src/plone/app/multilingual/browser/configure.zcml +++ b/src/plone/app/multilingual/browser/configure.zcml @@ -5,6 +5,7 @@ > + Date: Mon, 15 Dec 2025 15:36:38 +0100 Subject: [PATCH 43/45] remove gtranslate icon and use plone provided translate icon --- .../app/multilingual/browser/configure.zcml | 4 ---- .../multilingual/browser/images/gtranslate.png | Bin 51950 -> 0 bytes .../browser/stylesheet/multilingual.css | 12 ++++++++++-- 3 files changed, 10 insertions(+), 6 deletions(-) delete mode 100644 src/plone/app/multilingual/browser/images/gtranslate.png diff --git a/src/plone/app/multilingual/browser/configure.zcml b/src/plone/app/multilingual/browser/configure.zcml index 285f50f5..3b858a38 100644 --- a/src/plone/app/multilingual/browser/configure.zcml +++ b/src/plone/app/multilingual/browser/configure.zcml @@ -16,10 +16,6 @@ name="plone.app.multilingual.stylesheet" directory="stylesheet" /> - %E8ka~NVN}gSmL0(z|00603;Lu_%{S{2mp9+0RYD)0Dw>$06^@V)vo>?egoM|NlqF7`}fJ~ zDouos1Sm*LXn8OHdG^YqANI+`5Suf-%xXmISVXFgra%Qzi$hER3TnTKNH7(O=uZ^u z89voN8!B~QCRf`VqdXX$7(G4NkJbmXk0nX#$C%-|fy4pyZ^daHC3Woqo~mi@+!#G{ z0>JKf%9B~?Zj7pv$|AdcIXwAKRmXpQ!P+9467?jO!D1Ept$x*z8uDVeev4N57XMq|PeqJnb2-B-gSzVt%Rx9XnA>cYdnzZ8E-l|l>CxWLIrg2)zVy_jx(2{4b;&01b7wmv3nDQ`X2ac{q4^;$+AcV=uVpu(%F3 zvRF$YE?JiZ*nK*WQ-{}Hg4{>eTRHQRXopsLr0UAM?#8PH>t%C5oEp{Z7E1VNUy}BX z=Tg||VvSgRJ8a|A4+a}8=VX#6F4#xuKtVytgDFiwAzOqvOPMXWT#mgKA|WA zAprKLN93VFmFO1wk^kd$7OYjNbXu7d^abi}8Vr6-jFzN9(ouFGI8v*rcZM1eoYPM?SNI%+uGS6~Wm@~>46)nZw z;bpY3T~x>~W3AC}-D6DcUONKc574CsKK+@S)T@u-E_r<@ZzwB1jtV-R*{zHH)quoSjp9#z~coP|G+3 ztuGua+2F@3{`3U&=8f$m4c9gZ-#;=T8M1+dC~xD!gY zjlV*X2+qAgfgY{*FuuCx#xgN$U|G1~h3ocFMvnh|TkN8~xn<46OCk4l&2$WU(`iS5 zG6MAy*Us5sUFQztPo5y&JGaa}*_Qj>wg+A)E=LGq6@^MhPaQLFQc9-MB9N zl~&~+e~&ZcF7wr5xO67UEExIAX>@XM9&jCU$Ce*f<&)F<9*RBwT4gx)AQM+m&Sx5M zyx;tv{J`fI|5Q}-s(yKxd3fV#!J1l8vbM(tpsCF~c-slfg&>7xs* zeES`K@maBeN@RHk#Dm|CU1Z{g)9%U(pkD25=*8VmhgC*4On&-Ckp9sCi`uZ_=qj-D z00Ay$>4kPc>ZHZ#Ok&)?Szce9w;TsG0mUW4zPZR{DFV8S9d2j(qL)+~y#Et|JUd_> zW;%hbs}O0!)KzXX?FUVtb`GWZSwOg2WtHGBL(dK)EV$wb75D)m+}z)WcHCcPcLz$o zS)=#&mT|~HF1rh}SS&qZC`12opVVCp36i%!0V$-$rqhX9-^mA#C0-#&i*O;W=PzB))u57`rWstlY?Pra z5SO?3U9d>EwAs+Zcg(n>gM9d{7YM!ta1m*g#(=z@##Z9-Z(gGH9?MiS`GQWk^PUm~ z&O^NdBypS=I92V=j2kFLJ|MJWvm|ys6!zR!VGJ9B;A9bL1RTCP#YdSeJ9dA&qx_hK z%%!0n*0V|A?B@~0}oa$R>5&zil+?_J~@9rl6b_gEQFjkkX*LW z9demkS>wlKOMjQjeJ{71o%dXyOrMyQytK{_j z06yO{+-a79Ka9c-SjG(t_h0sdsh91H6toO$GZ8O*NPp)y{51662`L)^))E91ngsS~ zM2vnfV$xg)Q)5xR6$rw&hn4@>5KaW3A>jGzZPk9BC?3Cp01DS1R5^pz2?t($SNfmy zBI^O}rAO{$Mu-fDn35Hyo_HZ4cEx5xKV~{)4d4!pvEUb62K8#d_RZ4euaG}gmb2)9 z!(3Px2I9JW|LHM+hXYWsPhMad&tXMyB*L0kMc}g%IPCPYKfClF6=HJJ2>|LRH}RB) zqyaqbEPq6>#hD`QEM^W~?F-9UR?8Mfvhgqg3jW*|+Ah$iy0wSH?20cNK)1JsnnmyA zB;Fv+So1c}{2PGqWO=olv3?jp)|GovvNns;5^?TNjLIp+gj$p+VUVpL-X_gjL|ixmFz_CGeb- zgFIzznN^Mlh#GN$26Z(!KuWwGmncxnQkY4E*PoSouH|l9mEdqHOc(cn-RGiN$2x*S#ijSRlYc9iY#$8h3 z03{~k+aTaqaL7hAG~>t&y3m+w13)3n-5?jPz2E;Iu_MaSKrwH3ya;x9=-*o8er~Q9 z5G?@`ASYkvz-^8<9Fe@Y26BC--2u><|HO?JM6j@D;eJ*|AgNboXgCYV^PS|Zgu5Oh zc0l;gUk`<(EMWK1dALw)2?<_2V1RE0MDHH|x6*6)5NUUc0q+>n8|KpHyA6dl-yGzx zCc0X$BCc%#v6jfwAQM#SQDT`!sGo-pvA<_z)v{YcKf|s(G^k@OWv4)SNcda#e01hTcR$2G%5PKSqG4fvvDCVht1>&)*l+`GV5taDws#UUfRI{HOn_a#>)WRjSdY0XpnUs9whzV5#uqP4cP%jj zD0j}{5YvOC2V#!Q^9a8mS~&p>oQdxa;h@ZNG+hzM#rHwXAP<#GNeYHnekppS4iIGc zY`Ne-^?1~?DE9Xi91Lu66N1FK!{FmuQE~BjN)q$698YW@aUb?R;+4yEWR1i9oi;oc z4To!|IJaoTcapOQxd|=e>VB4~E|{p|^e`_VZ~qSL{2x;4H>aip*h-7>$b_Q5hp##- zX9#fD&Yoj2JGvC;zKb(%|DX5lH)SaSltg_g&qhsuo4rSQj3#rYkJv%DjbYP~5P0e* zXUG88DPC(JE(7hp!>~=<-)EfQWy>BB|2ZV2`MB>*fGO(|0(L-wwN z)b=aF)dNU0p$@v};)*oANE@>KTwzHjq3v%7uWjUXN7hCmUiLX=xy^vEYYEoA^5qebTV7m>pBSK)0YTJ78#}Rm1^NWo80QQmT>XiwjV!Gl;s-6i8|fI8*_C$&`P-5bQ4(T`8Vc*^jh4feyUyA8g}_MbK2_oB!eo!pINn3nxxZGCH=D&5rPX^@Nh9~$S^UGKeXb_R9F>9uIR1zP;e72jzPFk4|Npj?I!#NTCQE+gt_q62!B;n#T z{=jMLQrsYC0BZLvOLx8(5OA99`UaGDV)u4CGvN0DIk7rRsSTNKO5{#!kp^0k*6vjp zbXo60$!e0Q=X}1!2xdbwpc^3tH^zL^ZDMp*8E`p2asPpFe6lrA{YuL)B)8*{DV2R+ z_xli6Xs)R^DrUd9BJpgau5Ma?v$BEC91`SQl-U0@8D$+;@WL@Ce+%x-nNJ&*FEK#h zDY+7w(R+1-9WQDiu{nO$xrvq&YO z5aSZU^+&{e3kmi-QkwHP`i+j0l4P*02|z-GKV_*5iJTqOWi`0@ zL&7elUbal3KW0Ymc&M96SZzuoSUtA9O!i@p9PP0Ae&u8zJHCjr#WGk;k*3&ec;<&N zR^j8%fIE?wYSUaz42T2|uNdY!FZcTr8KozEVX6^(D-E*}S861&J2tE28PfWnsnip7`Z<^7nQQw}@??S=J zJnjhuQfix_G@s=3SpIM+7H~*W9)dmBnNL2im}bjiKmyabhN+M=J)Yhh{7yN$=0`Un zPcRtVU$jkWWqvMfI;>;W*7q?XLsnFr2jQ7yWam0lXFt3&OU+vkDNt90srC3gUXQ^7 zNeH0fm z7O7tj1!OxEW|5wx7zVyxb(?&SmY~wdfH?l!R2l{0^%SgCmADf5aJ{x@?{daBLoW6CvcHPY&ALcAE&?pC@XJTUh-XuHF-#v5N zf$DSFv^2hx6J}qv;T8CHMv2o{plrt$SWAe1eBHk<7v>@iUVCyX@k3!ce~a*1e_YKS zY6B2bNj|Z_bkFuxhS+2{IK>RjA&sMVE`9sHujw<3|H2v4es|5=>yH09_s@Y%6@frUHY-hSQCJTzl9(ZDs1_ zmMgQQOf3$IDbX%*RmBQRFZ<#vM(6z8!0;ee!}nCJY`ko+63{2dHXGtt^ zT{uTQfHyt2kI#K^0Q-YVzlUjF+&$Y!$qo`AzGXpW^OkEf$w6Baug%zv7xJIag*msm zJSt+J)T$G|0Yn;W()}L^fz?U$niqF0#VNiIFT1M1U%2Wv$3-AH$+@t4gh6!sq-Stu zV3anKcGsk~;hCR0E;@o0+1PJ`0X0$;payze6)b<0$NkgPTm9Xdj&Jt_Jn;Fysou%ep7h= z3T?$|F0WPjqJOhz8cu~+4VC7o24ljv5){SCq}}Jz#kbdt)?8eR*_kVfc)^DC9KRCF zOl=`dFRZs+X=Rc94hVV*bAfu$0UrPnhI*N~NF-(rM$b@le@sk+oJ9%v8qu!G@=nBa+s{(zH-X!dI( z&1+4Qr=gL@`0t(K97z>7(EOaCIdW39o7lr(qd=r6!aiA~i7E^uJ;E$qyv|xS!N8Bs zzAERgM%09#@uNcLd_zqtC&0*lFkrZv*0T*H^Nu|t{0?P@1gl@Xox{$~?$_ibe)Ljn zeoPSMTyh15#%T?oddY&cMD=$rWjeo`xm)et!Zm*#3<&DmN-Q{G2QftsI1$p=N0&e@Ms;9@3cjAz}cFE-v{>8)=>IfRXU52|{P!{4A{ z=W=`RH*S{CB6H?k$)e4;$O!GnVq*a5V?c~p~m@7B)!X3AT)+n*CMaTFX|)m)Dp!_w87XzuyIX(B8trqq~<+G;XE z7UDw4Ee6M~V}xoyF>-`#oHw}paN@`*duq*xt0gnb>g5JltG0#mL%!)?^2d3>R!5u1lr|6Thk*drFadNl06F zh^ZMWiMqtQF;WP`Jd67?ITAY`A+#x%S%LX%WhrEt8)bqp6^kGE2r_8o+Cjhpw$_VR zspOmy@f;X`#T?JG;q>xf-`#uvN1ip|7S}NNmXD9%Yd!t&M;_paof>@ioaa4FvF)>RJD*1)0=H>}&*Rt2z>rT~%e*HuZw&X>m19T}P07#IDJ)bDy zu~}~|1*|PNPa}-=R&m|4cr3H#YgUg^aA<6SiMdBT`FCduDdw}tAbZgI4Oyy}A*;`> zPil>r>E)Ug)`v>el$j_kdm*R?0bCti#`dm#gNa?4t5*^W4dNaip7p90HmZ}%#volL z2>Ek!+C;>}LVA>W2mrp>IIsBVtB&U!A^_cIk~m{#&fspXuoG4E7@5j~4Q0?)n#hBV&%tkhZY|e{^F}q)iMk2NQ;$p;TxxQdIDyxfc0SPz;XP39%=`R`!>S=ThtM2+r#vfWLaE8N@vwFEUx7VUzlO2@n;Ot!< zoJ{|l2xQJG49`iQ$Y@_8kh$xN`rl_1dWVs3$(Y}ZNmBBiwK_;&ZW~}L*#hr(jfjCa zX%Nq#px7mp!IWXF0F0_z-o#)q&hUR!kortA9zoM-bhhN-6bDOna`QQB;uwi3(88^( z{GIwqrEOzFnv1r+9y=VDVwK@x_$;~ei7ccC8Z^?9%g=??H=!Kh;6427>(yCU>yhi* z+|9uzdY#kJ#P5wZdd-JLExvZ&MY~cZ`E)6{4x%A-9GG)(#Jn&bMg)*DVH$gY`n+hq zlbHDk{l(5fhU|KAs`=lcg@`@p4C22*5^gZ! z2vTSB%Hs7UrK!Ulv>n-AO2%CH`|0Fdn%DQPYCs~>2Rd{Fd1t@!*CsL#`*8LbtsVgL ztLeilDe>xE_M?uyyxmksx3>K}3_{&cczKjyrON9*hupi& z-Kby@H_8||^ajh(+o#fvk+!6w13B8Vi1AH65mle?QZkS= zl`rh#pcvM4<$EFgNi_XyKym$9wHi%X>O6jZ|C~xxV=n{4(A6$DIe6oQ%8Nmof~GS7 zJ4FZ4Md^Z7Z2!D2UeKUH^>VwOSlp)Z`v9ri%X4EP+U#Dje*%6R0kiOLjI+bEA7bDW z94DgISkAPPH=y}>p(RB@gY~MIlh0?MQ*YkHXn4MoOSzQF1l7rJ4Sy_n!GQWfm8$Ce zn}CoOg|5*R{+;#Y24OhFK)tq^(R)rJRqQ^UMGWQ}(Nxfq@WYx9San1)rdFxLmLrln z#J^iI{rr)T%rmcI>*nvI@rp1UaL7QW-Q$d0LCXBIx+R}1rxxH*9|uYEEDl#F@otL| z5p*V#;$=rwThKk{v&Y6c*oz&cnQ z9y!-%F&Z~>#rldHg%s+n+d?d*GbbGl-^8Ag^vR2J#P787;Y|rYn$9Y7>H+M7%y_$= zA$jHR>S%S9nFG(9wC_8d+?U&}tt-?w^+=05Z2a~S^avLF8puQ1^ky5F0Ip7WpjxX3u~$Rxm^JcN#Ik_r0w zm8tGEU=q}=Q`gpdT0tj;3o0>0QcW{)F>XCgH?7-wo7)>|!X}d)K>}YI=ka)&x z_pS3eOA;#5^to3EDsCRx$H&NUnirFbK73$8lfmm23B*RX%giHLRBODvbZJW?Kumk; zMwCf6>^fsJZlM(lgNs)u!{BIC@0{DS6oY(HV-`LW!~O8t_sjSV^*aGig;rcgAAo2Dqz-#D;#iohSbZn{z**Gf5S%g-i#5_j*1VeGuX2)K~DyH-I(g z#~)@to>rdLcrhuMM`{jSvc_qt@9j(JK(U9VnmkL&O#n6N$*l-(L^dbXit0_Tb)CI~ z^=-9f+)4*j5$Jnl%q<6rPPkZ~G-1QG=m_n{{2cU1WlMj*CA721>}Rz}Fa@e*E;uoO zesnz((E(o$It%Z`GUEt4$yu^Mvs^BQss7a*pw3Chzaw|7Aa_n<9tGtv_?jC_0_Lbr zN9k%&;F~^{GoM%`8cwETCRkkoBovGKehUpj2OH+AJT?V}1hq&^ZMoT?6Jkh(GM;KT zprQ)jndF*G%Oc*3^=;|xBBQ1<8@%u5buJ!zd~D|g#gtq{G5R&@dU4UvQnPp-y2_?j zBSDt;t)}d~x)nOFgRWXs24Od*5dubg47cTizTMy~d8DOK+8izaKdk&cciI@UkVV`p zix1n+D+=iv&12DR1f}yk1MEw+e`@9vb#5qvnjm5Xic_+Pp|U`-kI12gpv?N0uwWF- z*301QKY|j34s4SpYqyP9z?9o9&M5<|c*g*|Es>*qFcJJq_h)pfmzZ;209F zzLWLK9z5wCBr?Gz(nQm6a|!zz!FYRB3-Qbp}qi9Eo_t+6%&gBtP`A)Lbz! zH;zIo2JaTk0rd76CmVkbFU00>ai(~aas3q)wVwEg z2r508JFjOzCen!N`x65cX)8pvwHGsBUZ1d zN`*XDFmHZ^ojXcB?}nrB^X~V+{dr9igF|BG7czz(T^45 z#<0g{qss`24Z5mn58ikz8cco7v)EHqt9%MY3HldZbH`PeC_H=z+D3YxBOfLwA!^=_ za|4@>ASLVF)<5ZR>^zq^bPX(yETpApwT zxLYkCfnn^}XTGsxzDAqMQ&~MA<+S%zsw=AxF?~S{VW!FlSunwK^9RAstbMGfNi)iLX0DjcbX3p_fuy&HXK`pxe8`<1HK^$FZ^P!zvR5!2N&$^`)zNOa4RtYn>e#n& z%Q^@01rNK8n{MJb5$pGXnHupO$4yMr?xAojAqy=1h~%|;c-i9Q#zcfT_eJgr<+(O( zk3EuS8{I&c6Lq4xe?&Wx=UrbLsM1k9pPor8ZyTMWWt}OLk)BOP=&#HIC%vhhY@HSM z?SvB1ELZAHXOI4E^Ilc^vtEygM&C@9SpY)dl_tNKprvykwl;anYtOZ~XqWrYq*G^q zzVY-ML8WpQhSGPOU~74*x{)Kr$Ty>E)JO$`X%^IiU4`BqA`YIb`ICr*Ti_wC_jq3? z5B!LL+1(|iY+@Y12w0Pgybr~p+0bV#_OHTkdxuJQ732o}}WaVg%>c|SIeOzUhe zvLcr*AU?b$7!9u9iN(|{(P5MdCExaSnoKsD2weTRw#7dT^FK>P*Z?j*estp#4^CW7o*pqEGYw(>)Q-X zPtKc6(gAt#od>=QrUIniuc*z}YaMy_r*~T1?79;&lMg5kCPLVr8e1uyh=MqxR_BWt z$ioxp6IFhiL`SW(mcAlS#f{7LY*oSaz4!>#p>woZBjl@Yzo{L6OjK^qtVNx0Cat;$ z?ap+)Vqr|tXwP1@O;p}*=N zxBuMl@wJhYA=*azeAdJMH-Hm*?~}kebX^N_M_JyB8p|ZV#6)Y$&u5GZ1L8tVbz5ij zY12kN*RqrAG!p|)BZuJ47!C09EoKn)p{nBj#>P>tQ))|{YEl+>^PYGX-RykyU7tw2 z{_0I-9~_~wk_Q)^zI)F86vixMH`DwS1CO7%sR=dNiaSXl8iapEES(P;kUQ$5{zV?H zZDS{#+H4$5qvXM{u4kHW6#KRdws*&ndtP*ihK;xdYs)Ony1^6{TKVAFl(CB@UQVZh zwD@;PMcRG~ZcwAno`b1?H&lT=%{#fAAX_}Z>zgV_DAnHsESWIBqb+sFql*;bZZu$) zt1Bib%;W75W{L$3`75ljp5}M!d2{fS;cLSK5|Bn8>q)S2U}FEcjyE1m9qkV1Q9ha( z$;EpsY4f{kn*V`KqInHG9@^K8x*{fv7)05Et^^F@8dXZn+!pjJY2VM!GHwJe?`8x< zd3BBMQR;NCaj-<4tbY+NHLLizE;Hy>iurP3ywurBD+msq024*L^baVHu#&sP)mXOP zKpO~@EO&OOp+&U!MCUo>`t2pq$R8*LK5@(-k*QPVE1q_ z`J{xjRAlC8bkWiv!Uia-^&q6Ho*~uH4TqwnXdbObbwa>w}4&)RxV`t2>A@1Ak&^JaX&Q4;SfmiRS zF@c;1hyqket1gLB<d z+xQZK&nct9=aD3w-L$#}*C7M}C}yNw0yV8?Hi@v)ME>Fm6E9Eq3Gm0I3SX@JvUYd| zr{SfxtA|F7eap%EUi#__3)2qaDg!v-_sf>*IDa zz$8MMd2#Dle3`=bdNJR7^IeGXLo~_VdmH%vY;7VMuA(&r|wyLaF=q%gCAk{16wS@XYdq zT=6RR?4CPK-<2IR*a@)lNePb(1vQo7Fl^^;w;x;IHxe9t^b&3n5bG+5#vd6mj|x^Y zn{$5YMegTvUKK|a%zuwi^~aHbwB4#Yl2!<{Ru!>U4}#&z6nM#q^6BE$T8tFH+i#im z7YX!2fc@oUjVQgAb(Ps(2xsTX)~sQySnpnk`0yN7T|8fSlk7+xt5mbMaATAR8?H z!}VQP&Tg1NRMOt>`KN{WIomQ8Z#h29nusfHM3I+vI>%8~an{mlAto+emevQq8*}6i z)cq?Tis3Z^l2(gsn}gy=RCxR^vAEUZYgwH8<2GcWqv&l^?5KBhli_tX%n7`_Z>O4J zFm;e~yMujwviS2nH}D!PwwI&DSl$V52%t=)WBr7g52bnZz3bUOmZCg9Px5s@pzfK{ z;4g!OuQ#0^tTVWd`P#bQ>aS3x5XxH9Q|r=_P&jq$z~k;nkRBj(abhb150tWiv_Sc_ z^OQxQjqA|`>=fJ6QgoaPYZ@2Gefthq@701PWlSeQqQmD<<@B`rH&7c$Q$j4Wq(T{4 zkyRmnaz5X8_Jf5B9i*~x>@FXxUfF>XI)|3ewI4n4z5oUvABE;@31@kqDk)0|z8fl)h@e!F!q{}O!wppVI1m#P%$)f4 znu^h}EsKTz-8a>QdWuN}ln=zqsz|@_p?palIV5Fk*L|L!9tMa+E>D$gNecL1O)-I* z%lm;w??npW@|cbbcourHMEuH%UcJidGi#ABI%JIiBUo4SdRujWCCo1R01-HFB3l#q zg?Cl1PfkWtU@}qE{E!0A9kIt%j+>H>EA8ibOue?(IgxA%M75hx=Y3uSR*5bjv-P>d zqp&kYMC*2|l!1xbF(E!sUUDK;XoHgvN1j?xiy=dLCV9Xa6}50_>xmpA_}nl$n>5V9 z!+1W@Cx+J2n&20y6}jLS%{OeRh2KB9T41b{;XFBMPdM+7?l0JV`B>vsF_F0db84FX zy}uRdtSsJiY?84eP}8vci+38{HC~%sNLe&{di}oN^-{l1JqVsBck22v$R0aS!-N$5 zY4EBP>*Z93ZM&J4@~j%0v1EaYgq()fa$+Dw%(Oj=rO4YxL}3+^f3bYBW}yVAx0KiH zc@+DEz}rlXwIXN8Am-%aa4~PFq;h0oH`hU4*V3QBz*lT;=PXQVZ0G=%S%I z=m{=gIiOE9pRx>z|HylF?>ZE~+Z&uizeK%L4-^#|d`s|9F<3Xp#y#ucUcFTdBjUuU zJE~en7Oj|LlAIL<^&ODg{6d+!fAI_op&?JQ<$lFw0ACc3x9cq*`~{nWV{u007Q^FU zM6b}v92)9x8qfz3qB{!i#g)PCTl@uS>0g^=X*@?#FjIReS9gcFo2W46V-Zg$Lvi6x zReCSW&}F79v)WgAgt2r${b59Fl=XM*)yVJ!qaiLL%&=i&Z;qHHioEdjdD4n-vXlD$ z>P(-_LQ75`OZ9V&m>II%V3v*R3xIiSulNR@kw~Ox^Kx@U3EmYOuW@KD_t6%=KRYLb z{5?^-HaLT?AjG9MOIe634DVJ50x4pzy2|4zv5Y!yMEpq$#TpG0>mIZ0!d!CLf2KQ> zYk3n4pT5ZE&%JK==PENdcXsk91OPM!ypr|?TX0|Ym`-A$C&8FEOH+>)nn(DXj7w(6VMHIETb`8Q68k`n=|Pns#v zrs52Eu;Gi~2DKbb!s}4PXWt0M3Q6JX2w}HnZGu;wzQy=3JLv~Fnq_`PC3`KAIP|0T zgvO#R>!;r=U`rhkc4(rc(qwZemL*>1fCC;gZ^j_d$~3lXA*dU?ST4qzcb{O3EK*O_ z-Uy?lCDbx(-2z1>Lq@-Z!;o;5Sh}IDBbslJl#XuQ3h?EmW8)JPX8F%HVjSc@j=_uZ z77B= zNofpRh6Z@&g`Cf0(doIF=ya-h5J?;b)uank=S&ms4x)H6t{B-Db&36Vm=mQdLzt(_@V88(;rnzH<2s&Q3IAM7 zaImNsUPPFqZ}+zvXA`Kl63^wX33Kge40Sm4qBofz_)(ecrS-3oKQ1&BDK+;^m$nRD zD>30$dDhp0j_3WUm_&(`Y|&9~@tQ>xOJbkM;&-l**F_<6AM2A05u|=sp}vJWG*|LR zZm3VdRF)e|Cn2}#FU_N~s(9 zyzg?5|JR~XQ}T|hMPi!I&IOR8?{Hu+c-w*Cz+ycc4id5TZQ2u#MbTC?)hFU!v0ds< z)Y#Hup*Q3I$QHLsJM1MOB2CjdnaVO37*&z30T+8nEV@Cx{aL+e@;J14IuwdNvSZWsD_wRyeI(;$}yb*)HR_V+mg`vCGQa=79wGwIP z`24Dtr*QeNgP5JiD^Z_OPao0Tt>jq5=tvd@flfBA^ZA<#NHZmDN{1}l^GVdX7Q^J( zKp))QjXyOna}?VM%xexhRR`NACxE{XdF}0c$BN+Vw~Du=6-C zp41QO6){|4$wODZ&ZVH3zJ|@!rFf{THRiy9TWWTDEMLNGQIX|!A zZMap9Aqf-6tj+Vnc{p2&`B#`xUbpZ3Piqw!or*;ZR%!#eOOC!$1E*VDg59g#OC1{H zm6?2j`{Vf!`n=hCo0MKF{vlY~y;AcfjEf&o05^D$xf@XPTK5v6H!H?~EFLS-Fc)Uh zABWdFOiqkCtGi#x^$Hktaq&0z|h3G3j?=QKF}iT$33^4fK?io#>% zl0W{S<UU{y~C7xhOQXL7OOcH=OBRBxLHFwVro5Cg)M-fF^; zudCSa3N$lse#0UK+e)2D8FRBEHCp<@L+r)WQ36}v7lc${S3@0r0=m1dVgr}ynhmt4 z{t9D(U=mw)6sO%r%EHNFb_bFZR8CGDKTucu|W$X6;q)JgN#)I3vd39?f|LO*;; z(<~{#WvJ<4N`rJT&dNeZreZ7iI}0rSE2&dM`P`pFWz?~%^+XuNZ~6S4%F_)o@I0M8 z_@v_`DDrjB0@Av5_>ZcTZyJum?ReExu_gG8fW`1A1SRG#OSbeNYRiAM6=*%Rsoc3j z+~=mb42#^qvR|&&2@P~Q9cVcjZ}IOa#0q~D zcjo|=l;4RdUBPx(oRSQFEvl=CRbPUdh-PD|_ZkV_=wPB-;86m(^e4BRzd5gG!SetE zd&<*W0R#eu6Y%bwT%~V~*^fNsze^C`Rg8>$SK&UZ;C3*H7r5GIkCam;{^vuI%39OD z73x8G=@xTqyQY1_TFfap>R*ARN?WDxJG)ug1i}sLPwT@zNAy?X~`hPVfRLj}wNT4;5b&Oai=WR)4L zG7lviN#;Eiku4GQE^^x7AP=ad&W0g!?n-?J>akN|t~E3z1H~J}aaDXxzY9Xf53Gq^ zn^#YNMOD)XUJNXf#CI5C@b-NTJRAVxTv-vw@4egBiuqOqAo`(Hq{$RLjSaIK7JFNC zS(3c3x!K;|Q#us*$&a^}AS%J!)tw=l#=p3cdDKa3?{vS+EP4UiI-3(jVT`DJA`h`_ zRlXtUrREAv&9^7W=A-GP*^Wgo%mk5iM5M77U8eY2%Hbk#-72k=0^ybFL)-A=gxEPV zKJi%g++^y@9-nu{5i)AhEncU0(CkZh6_mp**mOQp@5lQiK@~UNSV%89ZMC2g_3zp- z{y-dVKPIG<@=g+fAxuA^w$tWcotF9gN~|D3$dXLqf_Y<<%Hh0(m#ZV^&*`dz^%Rb*3AIBRP4YfU-BT#C2s3sv5!e0cm<_@ZP(^csy6^RExbUjNw7U*i5FBW z^8D_{$GFDk8|UczAQ#6a!Xm6&5JD4b8t>4%ZTQ~!5g$(FJR%9EcJ__+skDS&4yk+J zcC9Uk_bqZ>0kGuLIOKHdcUkyTrrUKknxE~{iA}EVY~#ANM6@@qtt#EqT$~l9A+KMk z*x`|Ch}Ncj-P({TLO-BV0uB50nojQP*9qQmH=CirHWd4R5pi42wzR0qsB1IMv)zaL zLOsFcI&F=XS%4wGkR~mNw-DY1#g6+!33+$a!3&!bwJ#~e=GFTDadgdLb-sQ4WVdX$ z>SS!WrDeO8Yi%tr+qP|M*~>V~wU%w$?!3?My{@kQe$LJB_oLgUqXjN{757K8DG_N;$on6dy{c63)lcv${eHX&uyg?nDPuwurTWY-o4_;^f#7Qb5hlc`K2ijxYK zEa64(?-+87QF}&j|E|~3*po&LAuq>~eHm6O(_O1yiHZ8r9cXl&(Dig&$f#W7WR;5r zTZ~5aa9RRD+-rwm}*OD_zetC}aWXgv=bR z>E!!dNX0MuO$Orzgw>#N{AIgt+82PwQ0`XA^Bimr2G7 z+67iyB4ntDX@HNE7B6fC=1yb*ME`D1qHvffmzr^T7eI#8k5RV1Pg{bpUuZKB5Bk5` z#dFpA8>Zgc2ol#pT%|v=(i?w;n|F4u&m$frX@OB4Y~EP;&5LhLg6{q9z5>mW5t45Z zF=!a{C=o>RW}`APYE&DiWz4j<|HnoZ2|15OhwZ`Cc&PLP1~`e@sDxRJINj4Y3K+Z{ zeZ~o384JDSmISL4>RR?$(oR zZit?AL^4L8!)zqyVbIXWcx(=dJihvjUBwAzB5|8c4*BvqMtg*4$LrNkU&Y8oT@PK_ zZ=EwmL|QvhV6jzL2nN|;zej^-8wfzl#_6@qFr|(GpRQ4*HfA&=f6f{X$21mfiF=7x zCs_D>>zU8tU`*=lE}SUm?p0pfx#%UuJ1)isv%+ab1|-0RV^&+4Wketo75Fut0;S6M zp?JVGmu7?#?^Ii6;B_Bn$J@P2%$)KE{pI@h>k*4r*{NAhL`bWoXcF1xFD~lpZ(cg| zzig2K5=9GlHZ{J!PYx_;Rv)s` zkKRRRv3wR8NOMbDJfwke#xZ^g$aTYU!Xt{0zsXq*Tn)r+sYFdcs1KM4{ zBqN>bh%zo&1RQoB)a=5pR!f~x zXBc1Ab1u|-IfXneR~t}H?riR35F~6@2T!Cm4lX9I9gMhNTfYB;20}2`>Qso;+l2|! zFp%m=`*{bISlN0b6#XS6_vMcYQS_HHiH)A*^HvOz57if?20;v$6-(Il9`1al#iB(0$YtnGJ`!fpdjNU!@r?(0aZHH>y)q1b5c7D*1yYH#gMt*PG+5apS zvNzV{>En<}Ah4f#`?)|&tdwK0m%0XZvGtzaWknF30c!XLkIzoulW$$)>>;3?P zwc&*I?RS&Jw?ntBco#0t&&jSlw5a`g1Y>>P@kq8U>OgjcNyH^^X zhG2eu@xt#9AfOGcuG)J4ZqN~*?@ zf|7Ad*m0Ofwp0&M)2twDMfm48HiP={(mti?=xOV1|B%zPrE^t|m1|p*_4TRRHamEV3i=W~5MUA)7~tw0I_+mCr6v8K>edgj>(HokHB==Nd)I`US>TH1 zYT`TOcBuSp)TB&vvbpaH;;?~S@55J0)>+6gsNIqZNNVo|J=Jw<{(3o9E*Q9M8HJL6Mr8q_$*4t^@-K5|fg`ihJG2*C9YbiJC-cmNVd zE30EhEQ+5avR}o5LeU{JYbOzPIEGG0*Aq@Dj#7B2mrb6(%i=ca{CD&X^g8no`9ji9 z&=?!*(czsx7!%sxs0@Q1*NRZ8jWC%-pc6iIAPe)05Psg^JphrjnO_$YBcve?zB?r) zDew5^)?!G*xurtvUmF1&)NFh&^|AsKy#g^|oY|SwSZZqFMnP6=)Mmwpe5-cA zoXqX*@QKUUeAB4I{iaelXn0-Wr(p9wv-BVPi(D1JRJ047y{g>a3q9)bwia!z<-qsT zI4G6p)?2H_iOc?+!h}>2w(~)z!Yb0L+-{#6^YK zTkqm_vbEbg5=cAxGk6BOHEC)6W@$PO9fotVBl>E2U3-jL@dM43Nwb*4LLQ-YAEpS` zdDGrUt$C~nA|dPe&l*3qc~I6fTWBgb`r0UmV7xb zpZ2U*jMU(o$42+!VP)L(eK(;T)lQYduTshqSt@J2x9Z>?A~wTZ8mc~@pFg8${I@{B zT%fT`FOR;yZJQPWqvCcwh%})NNSHAzC8rv;Nxrh|{#~e^r=;?8bnlHdplf^ZlJ)ue zu^%dnHt`ENhmUTEo<{8&o3qeJ9bL1x&P6SnT(lKJMgA@a?HxU=#5gtY-C1z%>x63~ zcaw@du7>hG>B7=`SQ3w?UVah*Xs3@rQlA-=G5Y5Kdzy8Eq zJ1NJMBlPX!U&PcDre8m=W-NnS4y7p6!=SQL?>d{IfsBz z_PCdk%#L!uiuOmtOs!HQW~0>L5AYocg!)a|SL!XT8uFdW`A+ixLXz=&KeHp(hjSXG zBCp~FeuSuKl6mqfSsVS}_A5Bm@JpbE6JKpsqydqHJba$xy?-Hkot#JY5&402P zEX7z_@NO%%$Rh^&E(;BPck;i_C}^7JD8 z(obFkeX9zM+yEZBr@3Q?)95ii6}N|C6YPhT^I1J+gLnP#;s`Dxv2qmxr$EywERV|9 zM*lFJslVnHJ^F-|qS{Q0jPv@&8Cc){kOGzLCO;z0$5VR2s^*VX!SCz8r6EF4flWfE zL$RcV1ynCf5IDDW3q$z|%7ZO`gfA z$Z~|AlsOp*f{Nco*YuUJ?Zs`jB3cx?#b2Ho2|sm8gYbn;UZx~9+^Of#_){BR?*VH& z!$&&`-nJu}aEqw#itc{Y#@`fw8pm$AifHWmzUK1m&mjnMSr)mVNBZPa0x%`)7{jLP z+IgG;V~RANS^?+-jO(W#K+Vwm82Q_rOT#fKu-;T-8>yU4B;czq@+Q9pwNUBVljmyW z->a^R^O--0Ui%attDgKq6`}V*b(sL0=HO+Gjic;mvFyTva?6Na)R^Pu zM)n9`Ld^X$U?oz(acK`bqsFN_7D07fsR$Y?)^~j5?pprv$()$) z$~aYIQ;>VJ6oVi?rz0-xua+Wgzw6aMKu{8N^>!IUUFtr z*q)CV4y2Mf?r-l)OlG9C5UF9AVn(Zj`R~qjjQLxX%DDz?RG+Dk-!kF%-+ADRu7eTU z8zRnyY#jD+>B~>t3Oa)??!@&B@b%sEci#Cn_gS$e3HeS$dc`iG`ilud8;%3iG%qIC zj9lL!M+c+iEVs`uU+EzcA|xEBjn_z27VUt`!rlQ;Dmd{JPuoOIVD3ee8fjNt5^G^t z{YHqs9kh10($HzY>2?+rd|WA$S>P$1V7u;1Zz$Tj40&=eDBY(E zfL#D5w_S7?oMcU^*-NeTXP5%oQBcZZ_%hTdSJvWloKh46jMT-Cs$a+xdTL%LrIlVg z-$PS@8Z!C^3+{>em-UjXr(NJq1FnAP83XgngOvfQyO;&~gc0M=cf42%?#DZI`gSQg z#HQ;S6Vim)i1E!`7JK!%l#N+z#r{FD-y1#obEOv@o=w$M8sqMfOZ$QINk5#1s(kH) zW1#D-lA^DPsz$mgQG;%G5OtRQxp40Hod?SBG9rFge;+-=22F8sjd@Xkqm2R8?s0u0 zD)sWz_oe53ipE#xX7L^eC1X^Gh0XeI-28gzSQ_EWwn1jDuCGc&_#M=Ng>HAHyk za<*L)2O1Q%zF}M06&T0B{^uZh#AB~w2+you=dkOdkedy?3Pkqpt0|A;z^W>NC6TXQ z5JJU@V*2>D>gMIkS>Wn1RZSpBj6k%GFZ1VX$ImC0LFG;b0c^XkvUW6i8rAi__m~sw zZA6Q0P+6e2;3?w~u@R}E!VUG3y+B&8>kT&Z>S(JCzJSHi)`CZJ0l(D#d84-NW{6rSAjikeftn>@DT9E2-L7L3xmK;-#F~za>s%omxPlJp}^1<#t4J;K$ zA%MEZ6R-yctn0&;!+Qsoe+WUhn@<-qJ@ldI@L%AL&!6QFHN304r@|Xf!J<}Ee_ssw z-q*D(k(uk^#(oiUe}T6;CSB0m>o_xnPh!GnTlA%HOvjrIn}8~to>Kw@_tKPIu*T1| zjxA4V^tUa;ND0dhbr==B-2n$f6)@_Wci|}It97uEcjRY*Jw>#g(c8&8gWUduOSnqX zi>(Ob`>d^&^;1BX_Im^)wRL9>{_&sNHfd0Ro809h5-Ww<1ku}y@4!+N6&HKy}*d$a%%CM4c?j`K%Y$a1zlH*3rs%8p=ADFsi z+|3sC8S5M2+w5^+i{xH~j@TQm2?|`j-w00%b)!qJbInS8h&Re|(fa)W&$@D#x&10a zf)%D*iuNB$Kl6t|7@*G8$9X$D#XRr&vwIzz=5@`OxSxtpo@Oe1h`{Z#oQM7!21j%@ zc(x!T~(06N8k8|pX>)vx4!GosOmbn53n;$ z(N1piv6YxOne_6|h`NnJ*}8vHMmm0(*rlg!Uwh9PhYecg;ikbq8D{cv>)U`nJ4dd+ zi6#e~-kKH0QPK$Ivhf&CW^I|2BZ)p8_MQwpT4I^6L z7mN^doHaiW8Z+ ztoxJ*4NJFOUWB7SNQvD76g31y4CJ3or0&g&KO+WB(ndXfTCl~-YnxvKBR3PkWS($J zj7DI_N;Fm`n%Kz{Ceb7+T=X3_2;Hur)R&vRyO;39-r!5npqbDlJRMACQ!AJG^ejfM zz@;vNz@c=<1{Qv;9j0r?7rFF6GFOyf;#Ja--a|h zN{#@~;Z_(IZ%D2UvV`dw6QfS&@t{*;_ElC@{lS%xbB7A3ZM3Rf1-kDiMg-&Ow;MSC zP&$AZX`dytu(^Ht#}g3{J^Lr>SXGosCqq5U3$h{NJx@cf{z)<0T0ZD36@{bH9}p~g zu|Y)REkJldYQEb=sPdWl+odKHTCyL@GWN-?zeyuq}=-qj~3X}BSVlF*! z^Wva}rPn3__v|u3s0*WQ$Jn=T-x@s;oWzZl7X8(LyG%!+|8)NMiVy3r8Z@P9)R-<7 zH+#tQ>u%OY7CUkUn+Dm>Vuqi1&Z@?!l4G!=0YM1YvcoY^TOIBYcSpeJ1d$Yc-(HU; z&-gQ6ALrL@$5+W;6(HYXBeJ*)yY7?I_F7+i6z?mLS7xp^_p;4krv;6u;@bkvym;u77~dmax)H6$d4B6nld`@ z(nRk&d3bPf*fP()gFA)y9bdWIuSr^-2#^U8-srb#fyUh8HuRcbJBWse(ST`6^63(^ z>--?nZG4^UqwBEgUbvdtr59v(FNJ`LKRJ&6!xndmU_F#H^P$Z!J6G{p;b9gaTP{ zt>IGs)p{zKAg`x~CZd-8?Zzp(s>k~MPwz2tv?T{K+%1{P?<8xhnlj^M-jn6dY&OqD z(B@Z57&cwQZwoi|=F#gmbih`m6DHXDujouGut$_f@@-m3iR$&yO&oJJw!(%N_3wmL zlgk||P`)b$c(lzUGQuXQEu=`+>F@P2#F`SBMovZ8URsasUh6v!OX_z(h2|oQhg#4Q zN5=MHh!E6z*W4k~r3l@(Ur0+)<$;M zvP0WQgz|0mGY=g~MQ*$B0DHV5W5Dq=qJPJNwcWn^D``E(49z>z*bigBmyh@y4}rEr zr1t}R)(Pi>-LZqVK`Jx*f;#(P^UKM!j|6Ef-E#@j#V^pIt*7bnqA@sCnbO|5CZP6?88A5Ku{-&ms* z?w2Z$vcceq8gs@^v2Zx0Bg8He1z2V!cm*^8B~HLSR;wUsFNuE8-R z4@h9eE)s>PPFhgI7Z4nrdkkwj>{Ty3xN5jDalYA*2I&-qO9*AdB02fHX~l0{c{PFVCWk&}yFci4&7 zgdGFY-YqcLwx0{kmZ(UVlXd~)X}<%f(m$87RxR&S5!BDfa+;yybrNIEo$vkLo}1{r zyO;pC$N6HQ^eh%lBbVrG0&>L9-zEr)PB_cWWm*Q%+r47g=w~FiTaE{h(_#i2BE%94 zhso{9xOH%yr@d|7ybMgTW%4mwdswhjhPKwR6;v`vqUR%^k4Zz8f(6VxE04c=*P-&6 z(C2>4c9$WUl8$V;qAM*6zl~gD*X4AMIm z_4sH-qp{{8{0Ofjy?ZrhI?iPyHlkmL+Nk)y#tEBX{1s84FHT}I-J$H_+mda!G`t4R zC?bzvYZ%xgWy+gRdeNpd-W495wX_1MYkMCoa=b6`-hJ{83U;#Gm@m^PueGv|(Kd3x zCM_N(g+}SYD->@0or-2nW5S9~Nt}p#xu0Bq7`YGcT6M(Psh`Ek3HX+o#z-)!*4o=H`*Z=aKt{+LVhLZ2EA{ zbzGbPZHL;`4SAH$Ja|k&8I$8=d@cq|VmjP1+G5BLepV}#PW)MTYu#mhO^DD9cAKuZGTHBd@Z(^k zM-@A)4k8I*W=8advRijOCIKD{%Y6}D&yYMX{J2Jgn3`{2*dTR55+M|9hwP2rml2V} zB?&|!RNwUmY%*JIY@Wgw3GA6~+M$bSHqYyvuf8^0E>W#EiHdZL+Ld1Cb)_JV%#=xP zYYQ^?S4mLKeePB{q2bnMX)ISR#mS0@=&bY5`>!i*yO37f?Iw%wWHiPkp1`pnY8Hoj zHzuZu`gXW){kCC6&|C%7Nf@dl8NG#g12$ikF&ZiaC-m-g?{C*C9!dQqg5y(kA>uME59^1l$s)It zAxEG~gQ#E`FbtAT{uKcdnULXKg>T>?4#8=kHrBeaei87r9u>gpH~UgPMsGDM8N|Y) zBLZ;ww}~mJS(TNv6Vwm3u{>Q}(Y}6DXK06>l&1 z@cNZy>3k=KDU5h@gMi};F;OKi`#gx8u8uX*ioFdh_(BRnH^Q!Nx4S}9$$IzIMvq=| zaJO1Ix6j_ddfGbM37P%V<({0SL9+?JXfa;oBD#pslTfUXr}aiW6clp|K$V#_UFf4+ z_WCV_=h1TT3ReFe4PoeFcbtr!;|`cCOlKZ*8|QCdqU;{jVIzII{0=+GrKPcyn;Of& z`~kkt?wP)?bfnWNC&PDb6CzuL;0QR}LQ6B;l+n5-7Uo@|lsJqpFbKe=c6bclcihY| zFvNWNt0rcdiDaX=`r0yqT^lERIZ#LF_A9m6$;4(VNy{w{`|+*Lv{|zF=%NbACv02J z+N$;ow4)7*!6sN6t8L%~V-*}-P1Z(%asVi;|48ud{y^%oF9kZysix`sdLdvOTEabGXeZY~MXx%@6Uak8O>%*aj`sfX-fjxN2Tsi3z(@*ZH^b9}dZ$kN0mtwsw47a=%@uhGEwu zKode+*LVIus=}5D6Fpn{r5GXfqKxdkFaHW!G)H|On;|u?#p9dP0cfd0CbOy6v3#l@ z?PoB4G2)SGCIX%#mO{-KpI(o;v00>dKi|&C@gjEF%-8$Xr4`pT#9|G&a+NMmA{pAC z!~J95)Ru(?3%eaq0c_+~HQ~b^TD&W%`UW<`{&EB;7 zngIu+dFq*;)U)}qU(cAxaIW`U_yyiv`??6{a^mRg(7m2KSZAmecLTxzF$JWkX9{wBT~K3Qa$)Sh*E~Ek97B>#T;_|KZnMk#x4u2J z`%FX3X9P(eL+HsbEO_M!1``vpc=ld~v*w3lk=V;RJH4ce0ARbea+7wxfip;wL%6?t zOoJXHg6VX}7d+iz484cNCf0WcVVw?TBq}MYD%js8$;F2a0#)I|-fJw3@t5z)lhz`) zBx|tzI(PR6oBOTj;XpHk>9a`ba}NF6^{L~oLVnl=4@tNT$WW3iF8B)DY)p?JDu_hM zE!}NHd~sL4-ckk#B6_=@3dU=^Y5o|key#hx$=?eZ^W&)v=RmDh&=~_%d-wo~;_Fk& z5b>5)Ua*??d>TUtZg?9GX1kvo1dw7D(xFr!NFAsw?Bk>aQKOqK``O9|ZOU0#!) zWy%q|R@wcM5-giWG7Y`)BONW|l1F+bVRDe;)Ld9$3mkG|0?L=re}q))eNIO(^zuu( zII#dg6dkWS>ckeq!>hefy3})ZW5xq)?U#;yqVwiuPal7;K!5SYmQm4Y6f4;j{$!=oPx!_S`rc#O) z?x(|2;)-ou06dv<%$5OfGaeH)KkCi@&ey9HVon)A%I4sH{Vjo%E$Kw}rUQtI1Pg8q z4dcM2z=RX&pdjEFdPx3+780xrngXGVA-q@dY9~fntb>%Aj)gM|JbI>?35CpGfP^G< znfHj%f)6Mo_@U%z#I{{WT*9~6p7CfSX7TqAvhJD>d5GdnY_W zZr7P?+V;=Pe&~>wj+jhl^???9(8wdbW*thD?o`hcB`l+VSoPd}`cyewqTW$o&vUc+ zXe%L_bT+Tbp-BtM_}!2xFUpe-7QCn`Rp@$68qPOJIDQAV6#%z=?>3w7fW80+XF%%;RK4pho3L`3&)HTd%P%nQstaB-(>$e zxZ8gkFBeaMTg2ee5B!N`6bD2|fgMgEgjc|#BlA7{i1xUG!tH)Bi?y(40>mnssq{`LGbFKV7o(r`mjRRGN?@<3F z**6O|!HeTk6~1a-4~J*P`Gnf9coC2Bi2F7Su=JZb(bHt0Z%EP?P7xc zg|Z)7r@(=}K82PCOZu%hoeZGWcG?^R_9sztKCuEn9v~VN`bifTk~PyzMLWI2su{9H z6AS0!Y`;a*;Yn*Z;8oIxs0{u21hVqoMU|}X_#vWADbTYWn)}tBZCVqYcg675l5u_) zvNHSI#k*`xyK+kC$05 z|9L}qlzAf$Z06&_<5%7pLsD$4CeLO)k0v8DKjP1uxAkzG8%)63T|f!+j3pUh0lb4Q zMflGIK;A6yzk{lu+yW94MT3vDe8Gjcl)@XWLdtFv6RhTU<5Lln$JBX^&s?jE_X`g_ z1ua0`3douL@6S@PwjRbxk{y`|SGy@5o%ZAg=?f#Sl!juA?&sgx+_KA?K=}4_@O#e> zbP?6N{BI?Ef`hkL2pyYC%zQ6>vP`o~b-I|)zeQe&>bMUDris*<(l8)&>Y?|zUfW2q z_PdhL{H=Dp%#XMJ{G+-hB2c$x8xvxBU+?+XEQ9enD|;XCe64+D3@9 zs+58bJn)P}{(yi2DlqXJs5J~ZwbUle1nP$90PjGkjUa8v3Fcq>>m_hTM=S^FFu8Bz zE82=s00^u;uFf*A8tr%0$BL9P2>3`mOaalTc+3H}TmZPlQ=w8Vp3ef->TG*!Mc|aj zZZJOY+e6;*7cRkl7?WPyDdR54*7*wXk7`+kR$nk#@xZ@(e`*XhG51`U!eE{)_bX}} z_PJ8}1ds!@*+z|`Sr;m0d8g#snE!rQLO9?4+||o%_WiY$Ye%GnkW{(81s;qZW%c&c zmyz!e6Q4!4-WV`F3rRi`JrTlc&7*Z3ryRF!1zhvDr;tAXei4OFk}D1;gn3=4LhvgI z={xQjU^!>Ny|-m4@44NE(c;-i?+(uD z>-7`_o`6j2G5<6|FI{zcGD8A~C2nuVJ(@Y;OAHrmiQUJJB}2!U!o2cy#O!v}hP#7G zTBIKSgd*9`PQFz8g%4b;lO=yW;K=as=QZ}yf7uo&_yn@{EW0K0+`BxfGxs1L%pYG? z-^GP3>y+>(rA9zhv?{Ok-@FfRGj7d>F+_;`^ z>$6RsQDeNymxEK7ydIkM7TUkYe;DmIx|_%FM=sN<95Ag52i`N7pGzNuFS}I9&n-6> zM9@204?(?(Vc=D^8yWS5J_GICKowH?Gho_JNw=n~itjUE7@e zh(HvgN-ZM=NGD^|VV_p{Cx9f?e$2IqSNT!-l}Esref(!I^s&kltX)}zyQv!dR%7uK zp8CHrAvN+wm^XBw9&PKbG9!YoI$M{;feG|@YXqgKeV;oFMHBItSiJ#Pr&9!R1A#Es zoD-K#=#0y&ch_pNa=WN@U2m3mXK4&ZEZF*;fB4_Vm5Q9DdhQldWXGBxPF%YtBTFai z(`4s)=$GmZ`z9#uN_#bg{+OS?2umwvedo+PJmAaTPBn6ye(&kZa#cOQDq4O*RZOsTp#)Tz;G^$8#&839NAUQld+qik_Yl&64 z7P*KvC{r8jrd?l|vA+C0KqPx@k**gW$^09Z7(JdkBSA49)K6)r^o%$czM)OotA|tO zB9r*ks-{-K*YWJk`*ZCd>|?ZP5!s>3z5R@24^{4GRuDZ=u*(w$K4-7Q>WhWT8;r&B zW}AM7HqX{TRFxD=fSJIWMh`}+(U^0p;lL7><1%xxPR8WL%q8y2t-*bwtlK3h+WY0n zynhz9`A_KkMJu?_u@U>a1V@3-A5!{|Q9I$sy3s18nPcOtO4 zN4I7wBK%hSF?CPx-KUA{SmEKA;8V{_ImY^7_+ZoY?uVmnya>Ab@|~Xf=RgrAH5RU1 z+3Q~3)gai4m=Cqr=mT?XBjl3U90%sAXntXZo{y|)2_tDiG-DChNF2d^@d5p#U-W}0 z7uQYGKHap7#xdh9wpfKP+j($9`L>Rv zHwUA$Oq9?o*Du3_IzvUTiftrTgByShq3aH4A=F^)3b=P%Cp8mK%JItT7+oeNFNZ!10w?tuU1HQ%JpjZNIba_RPo2EH zWcMZX)1cu@a`y$zQ$G2g9~<>U_H%h>u;X&9$yM40>0m=1|N9aNyrs?ifh5hjBD`;N zK(x?O3YmdogMO4rOD5t%hY=R69Wb?>M-D*mtwxp@mIc%_$dIJv)}BM=0T|lf%jCjp z!^-^`cpIAa-!Odc+ui+tun^fo1KjAxhwGY^;Rg1c6WYn{rLF&B_kF$wk~@AmYkhS# ze5{<%z<6z;AwnSvMkcy*EJqMc1vfTO@~P{DC#q$Ek)ESRndHGE)x7}U{v8}Sd3P7K z^j)l-VBdY6)V434O|x{7(UwB+pw%#=6eo!$JM1fJuaVEGEg(t5GlISqdO@WpYaLB& zGp}8g-4536_2s^WzUzAYes{ix-IfnE;gZAVP`GjFu?aIw%S3B#&V3x8@Oq{EgV(V?WxW;WawoMSL(+(+tO}tFEIWlU zTXum8)Ae)i8z3>Q|CkCPyLh|9NXPPTuyS8}%CK^quB_S7XQO0f;EV7SWPuH-OTGY) za3s=#Dgr6T!90~E zjSJ%P&?M6&hpAtA7ByR7vODM_r(7cd#oKu3`ilVjy??{&8_M)htrV=(t(Q_X=wP5+ zxqLkxy`=Suq5Ov``BmtT*?bG82u&a^ikRiIjU|}W<*yZwz8^JuL@`&mPSm^ZTP?Lq zzF^bxUNNWNmAMs^F2|FMk}J)13XYL^-L_;&6|XJS%SHfymF{&zZfC}pXy#prSH1=< zBW^?{+Pz+E0v-IBin)@;RV{gdRn(m-IC`KCxGn2Y^($m1){-YknHs^cvuVN#n7wtz zJYDYhH1ee_zH<2j+b<9Ed2n6$?q@4Lw&B(O?_Y$GFAtveM{j$8<6}WVApykXXtv0&r(z6 zY8zns9E`N=)y-`HPu`q%-@Ti`>^3+)Uzfpr^KHG66B(Q1XkP;jG`^AMP%2U`IWy7C zTgdXc&ajEEBcYdI{1LRodo~`^e$<-8r0Me#U9$@)?{p5SbPXB()n4~;PUJ;{CJx4* zYaDxXvb}a}Y6k5MMrMyB&Te=W`e=-C{viUG!(lA(!D(nDzpR9N7bT5uuA&q~F}0tW z*a07HG;Ud!JO-mokpx*RUk8}sOWc<394DFy5Bq2L>pv}|(^#>Xs`9s_EjB)P*>=DU zPp`cYmgN{Z(n%2p8npk71G8Ogga%TyQz(!=DJ)Br(je(?zKdS=!;F^GWn{&JN>58o;9aN=}^>}|jY-5oG*0M@3*X;1Bc89i(e`N#Jtm9t#JQ+xLW zxp+X7DY0K{$1BFka?^lZwIA|1sCBo_qjFeD3MM1)P3sE5g`MTX@!aP_vxO603x{Qa z&wA)-)RvPU0VqS@!@}}ALT{XKj~YrEec(0u@1zk;2`wafO`spbCwGr94#5}%`&=(K zg0{324MTR;Yah#6Mwbdje>l87nxZU!e0X&24!(97Ye-->mKd^kzr|e?w8A|#h5cgy zFhJk4iCvyHx0GqNZG{Bt?E$aL5XY#BPk<2tuuvr1dbvEwa+ z`QdM-Eq#mX3cd5CTUwBL9p-J)~Jr^3>7#X3$dSD6CknAFRj>ccj#(7Y+6>e4YQ1o~Ka` zTqc|69p{69RDKvgBHrmO@q)}r9#Gfj3IL(12~(KetkY_&iT^evmquWS%`+2r3&!Jj z(Pi~`;AgoF>Dd=5o6~b)8IN6yME%%70rxnZq}ohpo?FPI#i%LY9tN$IU|2}%Z@39+ zBa<2w--UhC#T(T;^M0`)o2SE%4h{UAxgnjq&%lz#7=~ zi~azD2bNgL2~nE34L>TCrW=4@RF|t&G@lvCNeU6rCMsMNds6ItSbr3j^S2?UD(a?= zDvSz>!#j~B=CIv3S?8Xn&F_SEz71n7%-lHX%TQq$4^$k>;IRAXmR-WxsO zWdsT0(+k1x85KX&NZMue_~Ezl;+g?YxP-5Pg5f)NENqaZ=%;ogD%hkOGN02Yrm#U4 zu3e_;2V ziJh5eXP?mt-jxN?ra%7g6+3vwhJl9?<-kx6Jq0OPc~@-U`#nzE12lJuiFN@LM z@1DN=>(%y9;dl&9EQvui{@KiG2=IE7)MRNC-F8_Joo)v*+R1)pcUx3_8Vp`_`k1df z3I&>`w0MN2lC3$qfiYLZ3X;84C&0$_Bq#-T`nfSL1pmf|0tD)K5x%#<0$PTmYKe?2 zmM1SowXsit(FQ+$s;}#y`EwAW!bG(&RMm(*Do%Ukm+I9xHi8i4Shoi^j5e}g+3d2e zh9SBL61qS{&fvT>h5X*S!&7ux*~&YHqUiZt?Ar+$qVqrQu9PSRt;QVtP1J0Q7?^~= zEP>VyjD>lkc*oLqHK#Qes~gtme85~RE3Gpx<`+ws+O)U+4!O7vL0Vi&EN-8)+3pTo zo@{a7$PAfFDh(w@$eu_Alkg=k1aRdWJ{2~vGCuOURM;HZjl87(H<1%+M;w9(6~Trh zr7W4U^#~fzwj-%8_G?!!h$6AW!l=K2x^{kotk8wwdgf2@Za|we#?hWiSFO=ep@>^bgKrOJHmdnke_x3N?lxcy zx+ujOoDT=31Gyp8ywJ*Ohz%w&voN{|C!CPev zA4BV=LEJQ!E72rE2f3E&+Zbned9S`fpzkMgE|B+dpesSmW|2}|&8ZWGp!){*o0A>} zBJR8I(2gD?5+YC1LtJu=vFD*K|184w1IyarjqDH-Hgn^|(sAVV$F%=S>c!|l3AK^+ znhP#yx--dcOVfE;2dMIKnh0%#pWCUAxb#T0XR|v>eDhN*L*oY-s{`E<@0Jelz?+?n zz~0lSh*xDyZ%Bw^cMOigIut&SQFB4$7wt;Wuu+>?MC!TOf*hEe(MKH>xOW5eI5| zwMqMVuoXHRCO0bknF$Sr0(Lkt8WoDs?CRL8st~9cE}Grb=fc91n4IFcxV@*IZHz0J zn*LJBR9?Us?@yx^7s)1`pG-%?lRJ`&ET*+lx@#&eLd#odU<-OgI*3azXR$&IhFcU4 z+%5$b^Ra|uCmuriPlbr@cd=*f1>DDy1srNV>Z!k3qy*^E+VA05dg^E64N#JB;99CL zdTj+|Qke;rXOLD^v*Ud{$bmf)`~6C!SvwB>F(7<)5+OkN+U{(C@L3#NLbkvHyNs+f zNf&o5C%aNf(w%Q5wT6UoTyfS~U`8n5TN~I5(T<%rh`wBKn^(3zCO0iZyMsXluL`QE z76!T?is)nIef<&8f)zC9JqyqraN^8nO`4bG$sAsSfq4yZT4sL}I_iei0gpgfMFnS{ zhAGo3==l;5^4k;t^;-w-K6_PrTxG#GXhFN{99e^fm9C|to)y_Fa50??EXVM}P1H_% z!?kHcVq)qA1RCk1FW9EjT@RRV`dycOx-A)vCS>#pQ#hXON`Q{g{8US zO@?7Ud-OmJWQ&nqzr};c+7Kqf!UE3#-YpA&cPsV{ZU)et6FvXm!F9~4QJ>QYt*hq=PWZJ?svYMMsZcvdiT z0X-c5(N+|a9{S4`=M@19Bpcg-zORB2+PeUr7B*Z1+qb>$s>WZrYo@E@ja%wdw#k33 zg+7Ib{&}y^i%xE`^)5w>G>&jzcb=QC6$DO#b4)(r4~Jj)QodK&N%VFFU}cKT)@@ zV6$TCujqTR+*%^6{721hPtDwW4P%42N%FH*P$jc)=f2CPb&2w(@6-exVI`eYS+#w7 z#nN7eojJA3@#~Tc6~L!2dwxp{vlhV;qhABEfD4>wa>(3n7b-@M5{R#xUcb z_p&5%-z}a52mv6}v)vi5#)<7Er^K9!W^@ShT5OdKS_QNh|5_x^q%@_-SCqN1{t6(g z&ZXopUy^se??}Akv4SE6$z!4L@q$lFR_?V3*E=8@+d33x` zPG>R_uuH9>=Ef=1>LslmjF*}?hk55=Bc9hI*-(DMP3^DQ?;t{Ps*gKFA0lkjH zaQ6si{F%6?^t0RctDbZX=HAbMPL@XfnN%e9oC3NqMRGHC39q0FkI80~nUUr|Kv5;n z$FCPO?H`afup54GMmPiO=V+pL^VxQl4k-C3wUZUo-QiLpnU&7edNwU08Uwi6F8ppT zj0%+V(ojnsc*`?R0P@a1Te@mQ=}x-#GygN%x~cUo7ra?4-8mFm`bvYux|SYiubR|U zTgzY0ha`DB2e`n_3%--;afy2cT$TP^5E9)VofigRX8eM$AzvLGlG%?-2_I3SbPPEl~}}ScfEzQya2@*aVZD<4U)ys_2HO8#G zIevTE$9#5Gz47Vy^am(AYI%EF9(eSqQN4}=g}~00%gV2AGXXslcr18+6b0@917K*( zyl7S-C5j%^SKTCKIy?rm>%$fkB|nI&Q9kpRiw>Yy$TWo4c!lQst>zvAtT8DBtfKV#~BUeD8qn z^w+>}wDybBq}$sJsK!$LA2u>m^xOCvh6z;bzhiB%vMfr#-Ti}ET~3)a$$rH?w|PZ| z^&%Y@rV)Ek0iS3smy@Q*2Og=2hUe?@82DJo-d~HIFO>4HFF6s;UZEnKK3>;hKNyX$ zv7r`npt?03JP%oyY1nK|T~hu-h614SsBA8~dkw!A<q8HLCqATNxiV{HzKS|_qcaR0pWr$bE){*h3CHu`E&=88Aiz} zr)*qZe{V!smvYRnw4kjc_pLf$^Z7dI@^!+)T)f}hq3wKg;p#z3SPuM{D!qvhDq3N3{g@;$(R2NnTZ{FH=YM3F;lbDN`NCaRb+3X~83hEE zl)qzH1Jv5qu&J&VQm~~ZSdwn#EUDt|W59@$xj7P*w+0Q_Uz`h&wB`ZcYOn*yEd3;a zjw+nQM6V7N2n^?7ibI@Fe(Io&{nMahJxVYgwK_Kl1yXW&&X-E3`v9?RTsaOAlFHQ0 zbik0QR{W$A4^z3x1)UwEqh8iv-|}0*DC=!gwX~qv(fVdZz+4?Y2YWf5;`uY3cRyL& zE=z{Us$*+Y#s9p-Zs;jydw5}OU9tGX7L|$E(t>x z>z2cB3OBtaW2j8w3Q~8Rg2QcaQ<|)`Kxb5Z#*7#RqhupmF;5cZZYPMxC^Vj%Lhi0! z%)?*}X0iPTeVWUt^29tMq2JlR{MAd%jtbzNu(PN&OokW>CHNKeAUiz96Ap@{W}yeW<)C0 z5t0nR-x7@Je@g&UTIWP5+{uC^5AIdvSIAIK@QkqH-%5fsyDybG3}9!wP(cb7CZ`L; zn`TPTSIJ3BdT0LrnZ#hp%!n0P%}yB!6pZND4=chQ5sla73oD?`N@!ZDlpFN(ths0M z_ENxRB~-U=D@5iqbThhB)Yly#-mNDkRP11Kek~I3!dVnVA}}f=7nCh`4-*r_|JZc- zE*4VKV9!c}GL~pMnP}p1Uss*D2%+JWPtzo?j&-*8+@NAvA{f1UkU>JPW@gW_xe2SP zjZVLS#r2YD&2d-KeQNjT-cC9vgoeJz1&N%dK+@GnKcD|V+iALq(z)o*L$XI^nv+zz zGVBdQgll?!UtT+JKQ&EsG{qy9A$yAJ?hbnM+J!u>v3x7Bp*@kVTzm@)c9rf8HRJ=G zh%Y`xKLCbmHr>@Yw`2RSngx^S(n7Eu{@pcjMLA;o@{V|(5asP8A@1p@6*SYZqtAhX zeL$X(BH#?8h|-iqJiOoIVd%boWTV^f(Cs!K>&{pZTV zUUYO8`D

W|LvSftKSgmTr24VB=O`21wI;u5%XaLocOnUtW+fnmuLUgMgrHO--04 zNKd5ZtlsZgngyZdHKwKacG2&hkC*Llb8K98+38k~e2@GO?Q7tdFT@2Z(|J?@BJHQ~ zN1rwK@RzMbiaJh}I&Sj()8+1$#6X!zCD@sTzB&NfFP~}ngUDd#mZOjfSl$p~(N%K3 zagS<$PMPj;xF7u4YW>%+ps5S(EAM=+*X-0@6Sa4iI@`KeFYo%DEg4SZPFfgdkAh;X z>y+C@ge7TptH+%Pfi}d<41?8C!=D{Grm&Tp+xnM_UsV2n zs)^q{u`7r0a70XXmYED8FgUxYbNw?W$ws41M2gbFpZMGyg8t>P@|? z3hMpn+dW2=4^kY*mS1)eKXSL_z}JVI5{MLt+Is|0tJu+X5z57iovI#&IY@oW#T8~& zoS)Ku5|?)L9~1DiMAv}@DnnOJ=${wiGk10Ge-4|WmlkIzHTcXNOdbVZ0%Q3^tk7Gb zZP?Ul7er>X@#xa0Q#G*N(GY^ChVOu05F9|4Ib(#-l%MZmWUul$a~AelNrfzcXVTKp zbaqE6pV{{5k`H362NntK?v^-}+chgYGR&>hD0L}7Syr-(LtT48ltccw?&TZAnzz+U z_1-RO#SY*R^b0&tLXz)&PL_b&{D*Js;U%+Rv833i7cH$a4%5NAkUoTHxE2_^6W+Ig|^WPu*(ozli~`k&aD?{c@EO*|Y7Mh`>USI!nuYcP5`Lt}-vX8(0 zq^$9HZ4$GA+mZ?ONx%*@_;kJ^K@(lG`2q_7>oJc`8|=G+wx%e#{_b2~?Dgw@ZZziOPr$mX}`0=syx9tEbmhC)k!O1Sxpy=UqqE0+Jp#rdx-N?+&X9*75|`Fo!eKAGSv;j!A~-oC4_o0JBc9 zPrRto0e}9riLZ$MeqlyiKX9p=TwYGIYx9Xr_E$LDvbEj|!EL`)bV@hUA%@EWJDS+5 zU%l(oE;5YrM4~=IIg&6@o0g6ak{HN+hpHC3^!<=G4Dkc1Fzxx8eoa;0jEw!a>#-%M zQJuh~XIL6QN)vG|yfA$6-ps6lnKulf z;4?V@(GK}!eO<}eS-9=Rk_YfAD@}Ombt3CE`!*Mg6j(uv)N;SAp>EVM z*t$nnl>RJhRrYziH*#2qRO8|v+82=>4ZBTP)bUeX5?WFx@4ZHk%s>%xpiQhv%J|wdH-DtSAf%Wf z$5f+fFyB{^iVctZ*csZ+q^Ky~hD1llOKlDe{EZ*F=}s;G8-03uI&_Y_*d5I)Fv{rs z5On`(3n2r0-tp>M%fxLv9iO^egH$E z9UWW@-2$|e&mCp7Achepa7`ZGkH2`*hQwKI#^ z`xgC+FUxW3FAP?uF*x;3hPwFMLG&lBh|x+{FLLhE!}drGxoA1!PLTNJWJRlpfCxyb z_BlZZh!c`LuFc96o&TxPPcB$wK}_zwg4$pE>3z97VS`hBPi#8<<|?-2dF7L#oqH@$ zaRtiM$n*nWXRUWQxMG2GkW`2`!&Vl}sN@8SKGK5xnK z25m{fWc!s=E_WSKV3TKR#If>`b@wWTbf7f0Xpf1E)#z}!_xit93E+Orted)ktc@<_ zL|PL?Qrxh`L2>s&b^)P|EX73Tug>g72F+EFZuh&_xu@d9QNh;N=wEKy$xuL6;Bytu zGI#63F*bE5UJo<_`v}D!VO#;Xa}6mPdLBVd8~4)^L?Q!a8aKP#c36e2;XHjdebN&I zrJ7F*D^ka-Ajji;XRBpG7ZNzyA$O$4gTyt~yYYetCLtJXYtQbElsha!av^-gNXrY# zC?eFL?biLqK#(ctW#A zTk`@2p(R^87cLHa-OX+7k0On}Y_C|sD0U*YX6c>O9PautUs;~|%!-f$@kSqZoqCH# z(tsuldoV^yhehzIGWP;l~K zsI6P`lw*=Zl9_oT<^9UYnZ8fne0O&X5C2rCvbTef`J`W2nbN7zi+WCTaqq<1SRu$Y zyYoOGgGU>~8^5OTx)(aXm*@I+_0S1@0JW&jIyBVo416jXyk!-u1^ZZts?Cyu56Q@z ze;7GmpJuf8l9-|(WZr=N5)fFXV|qs&^D(-RS^oQpLo~2q|6cJOFeDK~7%E2*rTfW1 zRWMkwyjMw39-Br%5EU`CtFOuDcw{xsmO}Kp{J)RwIqZsvCm2<+aqw!i8a3Lu=sz&Z5!h5_GCK`f{a3&$GQR8=c4pKVacMD*)zIz;E82R4s<8O-(K7m)ypJ7}anzZWE_gEJbUQ9T! zgUXfA7j}>6VnQyxEzn>~=vMx@Hfg)Yhu7<;=;lYqIH#mYCRU{7=ow?$0450JdAo{$ zLn+y-i52qseoTR8wB<;u950jYrDREGnO3CUvmfcDuATdpTB>W@-}FbkP{mb=MY|^l z*(|Xj6zB>TYhX>m0hSx1nJtSglLcU*5M@vdSi1HtXiW@xxm=+}@j>=89R#HNS-l1!29PpT~glYu|xDScQJzyZi2!laBHv$r!qy4Ot<3 z1f0pEQMnG(Z|hsS*^( znmA`BKrVC)6Ce`{n8nH}r)xfeYA`c4%1_4={Ij{+S(jK&&d$Wv10Lhpxc~Bs|1s3`0ps^+dxFgjKBHfvE@r{Y!qz6ICa=|=qNj@ z1M3cW(RGwQV5%khO7^&MQ@P80>^qkH_MLn1NimEKZS$E2rYi1BLwzZ-Y{I<|hcw2p zCoXJPo~d<9qBt9jJEbfjeA}mwR5?4Sl*dx^>?W=RkZmyMoPz`09mR9G5}S zC=&T{v9jrTS?|chS1~aFReCE3qC^~JXRN@+g_7{Kcj>ZtY&!v6rb8&cOEGmO0ZEk+ zsXgKo@gA0<^B;55$8@}ck3t~LUc}pS;-%IusL-&F=}hoMP9p3atb?jWy=;yAR%bh0UYO9c}Ig3C8NE2 zAj?4kfplbomg_V-OYii$v32tUP!D27Ri^hjj?q+4%?hLFbZ(yWrNQRpUtyc5=nkA;pMeVeGZ`I(NvE`4kQ&fF#)W^Z)5XLfL07jo;~9X`xz;ZtPgfj{kNz{Bxbj#a>!D5 z^!=A`g?+qjQKr11{tp!KkD>%)ny{;zuOcLUC@NYXqoH&!SdOLCsLxJr#^uH^0fofM zwcqz<=B25x2>D7OdZnwEa9a7H=4+LS@?KQn(f-Pn21&sRv`HPWdWP* z6jAMuB3v3ke$-tX%7jhA`5YK;(`D#}+(KBjAusTp(55m&m3h14z+yOo#Hz6F$w%EI zZutYj$U!LlWOwRs(W)KR1s@7Ggw6ZaHu*0v+|;3b2|OOm7uD@y<4jeniud`UW`R8; z+GuP)t=xWQwb}p{yrxu8yqWSSFJy zv=>l2wSZ#9iB0fH*R}iLY6+psK}xM`)7NP&@6f9NT>cRXkCAfHsdz5b-=49!tPR`GF`yEM0f62wH^fnT-D zQ(x|CPz9b&eRX3gzlS{h7&*&mAP)~ztD=;WOkZei<|xrw`TZ;1`5C>8QEvfNYXrHm z?x&+gc=j@4foN&}{APh`V3^ezyb8(gg%f){4soHJmFr0RK5|@y7_jEy@@Q1sVk`HTHFN(O>wVv=Or&#dD&~w|d-#cCB z614_v8|H1Yjzvxcp)r5albiS?v{N9aHG$ z<5Gv$Wtgh^;c^QzxSa#`%%mtb3BE9|;ysbebVBzRt#?R!^PBTjyavyE>(gz&hH;iJ z2eN2@@SDFK;ot2qKXl}?t$NVu*5h}jEX2Z837vc!eH=k+9k04+ps`s5H0r=lf0ZFeGf+lIbVMuTlhN}_MX-`Jlej>Yd2zp)R8g#^I;DHxY^5i& z^}f>{*}=y!5OeyqjyS~_4SSME%`}=>fzbi=B4ANkW>aQ%gu44aKCIV;_G+?v&0O9{ z(MD)wijyUAIhp;vGesluLiD*A z9)FK{++_Wz*zjM>JpYC2%WS`(oQNn+H=$onKw0~;(pXF(;#fG;8$nv8D`MgVvr;)x+<~agM=?tJ6a)A z8mBSVvXNmR%AddM%Lfp>xXsk~e9hY~i;Z8{{nH>$&{-X!ubw-87*5Lvf#qG^(e zySbTNWCQdV=3EX+R7G|QHlrQCa#!nhI@=r_w^n@zKupn3?EBxr|Ln)mJ9)9;fkL>l z=GteC+-JJL*8>3@*EW0w=j7e`CNKB~Pa^{B*9&d11jP6qorW|sVw9_e?LJsh`6&h7 zVI4K${Ak|S-7RGP$C=q9C;1LUteE6|R7VA0pIeTg;-Yh^Z#-uAc8injbJQ z4s920rkX$mPsv|88is!7^mjl~{*$mJuDM9%a#QG-BhaNrb+ka7(2bvLC%W^wTu*t_RQ(TDPLUpW$VhSll|rY2U)V%m~fQ48<$1=dyl- z!hGV+)KR-mPGgKhJ8KrR1_Fa}Gh$i~arZd&Zm-ZKDqI-}V(yAxVuD^=qDU1NarE~; zFVT>HuV>)*&A=O%?erkiGw>59{2)|GYfFROLJoee1iYX(+6JMN|Hv51pJQ)2hVgq_?2) zE*poZOtoYRy|KQmLSVE;n9nwyfP$6;p8kiyoj)pYEYHl^`hkx3Ahn?H;Q9_W560twSTp;xifpDS<+ z13u?&u3Dy_RgUh?ZVI$VTqQU`;yv$)5V@QrK>(7Ao-{Y_UWjRY?eB32zu1zCI#VFI+{vdPS&eEO@QoKMa8wtS(X_}`W z(yz#BqFwP;ctW&V$M8kycbct7`Rrh&RCun5ns8#~lOZoqftohQ8QL~mtJmpFyonA>zI5h$;gf1KNTrsyT>_6w+4x$+3g;RI~fy#Lqjt|2#nW7wYgbYskM&mi1c&6NnniXqIB?=~}y4{o3!zS{Oo9 z49Zun?2%j9v@;wEHhQn;pt)CR_dq*m!9E<(X1LCAb`tV<;>m7ygrfZ&Up)KToEDoO z5Rg%&cJbiy>6mO0FJb)lKC)w>ISf9lv)<($DJW$XMQqn;pYDT1uq&elnacbQAeVNL1sa2db)adFVu)h(+>E@WX|^gmO3E?9j( zs&YJ#F6v~la_0NtYKX$HVUHr2Ei~dOPF`aW{_GKhif2(`bD;xkkl(mF&nTIP=epFW zk3TW3Z7k$lKv$i#)Ej(MQsk|p-{OBJHqajY;3U;hbux3v@S2n=JW=U3N(;Bpf^}+@ z0wdEb&0{{IyYa|?jI&I=zGp74$bXjs z_bYi9EG?%87q>&Hmnt%=0lz#xjOH|^$2ZA_;IV9fO>BhDYZgRHgV4u+6waHit^!7& zSLWBuPK!eZX1)|5IrxGA&K{waoPKzN)(buKzoqIgVP9F@8m#35(ZNDG;Q{SveW>xr zWyi>YQSFUYN%L5ad}w#zUVsZ-#Q)m zYL-0g`8$oGP=Dq_z~QKqgz_cy`YoRUVX!D>b=u`2v{G+i7 zek$O`GH@g`iOD7|=`ECZRo|N|&mfL{N``8Wiq|WdIG5gMwIq@-PLkMb`=_=k$>iUX z(y~>(s{uwr5Q^l|`(i7TvIKOeb8hXmOa5Xhx-DX*#vY%)4ObCb;JMTgWTGJmyR{UZRezWQ}_Z?@~>~i2eQdOAD{;U5f zekfNa&?s?XB&1O<#>+=>S}mV&?!kU!+6OUD-_M#iAGYadFWA8JL36OC7)porg}_#J zHe0-8-X~XweK!Q~edce8(ZF*nuCF+(@XtNhk~Y zlSWrOZ8?dgtPg)!1M@!o$rTH6Mg||w_wBmZy(a~`OD_Ls-Fe=}G*Y?rBeRNS7Nsbp zoG?h>?hs97eR;mVfyar~q&Rq#*ly>k;(k9}U+Af%o6KyJ^jJ{pX%^B?Lz*0M*IjS* z1EV}=KeC<~4Z?K)B`N6LXjh8aS%G>PL4;p>5}uVU(fV6_An2)=K6iuql4g9B3!BHl ziJjolfll97;a2QF;X4L1Tbr>{>)71Kfa$xY2sUPh-)lTPDgZrkahT|NdKl?X8!wuT zo3Ro`U5s`kDo#F)Mz<63@+KGj3k_1X`RF|=zS-Ac`w<&0eWcfu+Q4fKBpvJ{)0w;6 z@{}y##pt0mI*T7X#I01?rX3;SxN>9WFk2cxX>);RoNmnlsDn>&>arc)yc;nG3m^Xp z_P!`JLyA5_Uq6^RAcfkws+PPr``-ugwOtPRC1q0TxW8hhO!ip6AqRtEzq8-p4K#J% z4z;WOF6Vx|8qJ3oMxSZwgWidDERdlGta1xk&ou>9-23G;`p_-U{r5>IAn~2OsTWKX z?FFRw$mWWV7iG<(HMpP}JRczHqTH+q$7HirHWxEh`dM}x3TqvMTg0(&C(vpO70(jp zzTOaAXZaR1`j9Oz{Qq(%L%cfV)rM0=kg!zEVdK&#)>DI-lIL=Fx6)4 z_P}d;Qh3K+e#_DW6+ubPoAF~OMxKY1%}v3N4vGpo9Z$-I-;W%@=C5LPqCQ)%V}iwXcl9=6ab{v; zM@Nt895I2mbSz`|X(r|=1;VW!U(vdA!T_eNQ+#%6U;oyLB_TpN1UaL-d)w9vJt`a9 zy6c|$u^3TCr>a1g0$70JN1@R#L2sFiBiQB%--E4Y((;1^cjY~m`gB7#*WqR!TZ#BP zRLd6+QI1a@u3L{;eb-eHo7Xj-^#Xs-HQC9nB&`Z~$q=@4=&2LRkCrgL_IFVBwx3}V z)^M-_!Y5uR-3&ZcUbKI`M@NV$p64B@Z^HC>qY-({>pyyimdXEjZD|g|@h{HXMk4RN z;?y(kHmQp@GBk?$%^mMpno8vmM2)0gJGD@h1#kdtyneZ8l?+Qv88Aujx5DHoXzt}G=;M8OLzYHVsS6d<05ReFxl z*d*;v_zVCD`UqKwg;Zrbb7d8?M}NDg`E6*Ya_s6YwpjJ-;r6wxbtAH@xAaM5B?~#5 zh{r^D85vtu!lNd;x;`6+?1@R$SIU*SvsJvr;}4N9W3SXt_v|(-Xx!*}1Eey^+e{jF z3c%1(f932rA9DrQv=l|XLv%G)j>pgN@Yv$>)Kyf&Mb9Uj1nB`Bs3@sym5n@`2T6%* zpE8oZH?kD)p~U0#H@VG<1&&JJtWpmLVj@j$aIA-{UJcnkQx`sSx6@@aoD$7pYapWm zp}!Cp-;IyypKQf6IexE|O*So4wtJHY1ibybmyP?`f@wy!ubjJ>XO6#@=b}&-C*FLR zId{=n`$Rj}L@J+|ioNf;Ru_=}fzBU`xcMYf^#bXe%7XwsF5Fmol8u-9^@;I{!ysj+ z;JErOH@ZLz=X$Hm$Ht3$oL5}9qFkt7N(;cy1piunb2xqZ{(Fm3E_zG!P1EsL`JEsV z Bi=P^`u!{4bEVt`bF$$TEDZN5dnFL4O?c&&w2OPkh5Eg;3e9{&mJv?+(<8z*Y% zp@P{lR!4353NP`f>x0$JK4??1f)`wtg;I9Xl7OJVp8n?{9nyI1J9<~42>bnj1#g+f z+aP*8+QPA@zO-C0sSs2z$$YgaW#G5@wTQf3mXP*%Ab)O>__>-7VfvQgjn8Tn3Apso zih9F++-zOHJf)=rPL03r{{0Gg;4v4k-R)qGPCsb3>KICu`_LTGA*=JvZ!oC% zpzxoOe%i0s&)t0(VGd0=!M^9||oAk}^rYh63YwqhfVQ zWM(uXUjrxM{9_9|&~=I~(vlJkd9;e4Im!0(LY4`V3PM=|V!!ty_CTg8U&rNf^Mk|l znC4@qS#sTQ0{&1;z|?A;o1m&T_+hk*7YlOQIAQ_ILOdaJ5EWT7s=sJj>VbZqMuoqu zh?9FvUx;PnlV0UM-)s^V2+Js^6XU0^u!+ zR9e1uliB@nT&B6fm~3gf$%y`VIyn3Vve>QtKj#LiX|b=v41yM>#}A%`PywsAIiSy; zF!Y|s#+zDDf$hK2UtA7lZB{t0fEP`Xs&3wcq}?8@;9b5jx6bf&KkR>EH()s&DB+_Y z-#!;%ma(KBs+T4h8zG@Y%jfL)7Zy&GCltO|!O*|Z+@ajte!W8cI&Q$xS6=AMZ|I~Y z78>{@*r%;+u*NK0^tPn^PD1#ANFADRKm4Dc@$b7HuF^65HvF!Q=Az8Ooga%d`LXLc zn((P)8UrX^3iXFrhTICw;>=c4a%Te)8*W=>2c2p1FIddr(pN1_w{#k??{$8139tu~ z{~gcR9ifZv6`ESFpd8%mE%-0v7iPzw7sn!>acp@YT@BVW^IsX&@_->34go5E&48Q9 zIOqip9ZHCibC+pm&Z1n{ZQwMsztnZo?ai7{RLEnYABNr2>}Ruz&5hh{ZH8uwfqlD3RW`k?KMUrr73+Rlx|0MWIZ7b(9{fiUjW|cb7S`o+EbxuBpY2 zpaej?^i+L;L!%mNOxci|Pp4?h{0O}__{nUZ;{SXeBQ#0oS<1al(-Mf>Or?3AfqS<@ z-Q-0!+RdtH#HYkRZ+u@=^Imzg|EqE-!@Ky9zWU_^FE9jrsJh|;Lni_EmsmBL+eog$ zGtHJi1{1kGFC7CtT$g9`L}J_eMjnDej<8-F#T>LAxHrB1#Dpk&E&yr` zF3~ms+@v-m$Zq>glf7Y|f!hWM>$+Tnx0gUzhXG-oUp?6tc@&rv2lasV)m9~4nZU^~ zOo*^&p9NF|h{_#@ST$5X7+(6Mvctv?xhpGmd*|(;yHsx~*d7mh-BzRwE0#*6Q^~ZL zj(IH;2M^z1<9#(r(8jqQc?MAGi<={_55wYN!*Zn=*g3aSXt&v)Z79LhD4nQD_aEz{ zZmJLz!Cwnwz}!B3kGTxmKu*%`K|F{BxzJT@vmIPbU`|?<-d0TK9+Vi zju+{%DN?{$!BsLH{t~u4bA&|#ewp^Q<+90jW79D43PLGf5+nCgx=ZK&SW6$k}wgZ)AziKYy7uJpUn~%?BB*;q;8wok3!+} zyLWv-P*5$K%E-UqvKTqdK`erCq83ZJ0SUM>1DoFMuuszcyqZ2kk^WJXUis5UiHv=D zpLRJnl_}pgJPC?NsktB>@ppfc*Y<%vmZ!3rJ%9JWF}sS$D8>gJGaEq>OF7k_=_q0K z9=MM6XkvE?K``wm-*S*B>9K%)f0Fn2DN_guX(TB%A}uvJ3w@v~{hC zUO6Xt1jwriKRsPEx9zB2E>J|B=08V@=?q=k%=#^H5**}#$-|>2{?-hWXhkqI<=ZP( zD3qo`0%^yJRF2|Swex zrlicZ6pnLeaEwbwo>MT`jRiR{2= zmZ~upG@d6m!T*UTDI;Au{&W!m{U{&-f#lAip}$?RUW!romsmOAGew$-~8`J2^#AS&udOt*7Y!i6v`Y5b-*)=5_t;Iz4CEFV=zw z_peR#UX-l}q{1wsLvGj>A1BTp|~ z@}8e^Fs^8V4!o*v^1pTeq=fe4ex&!>vX-ZK1ivwUiG&8aXLmK(QwlV2iX`qY9*fRQ zI71hA@2~1$zTYZ;T-%xoggy+c$~@^v>FBTL%ri;9uamszK$CaJVrnV~sX5Kb`#~X& zb$&-50zaIUYj9r$=$6*@xRr?&HG-UwH^|fD-$Cac@|V1h+!Udos{za`Ve>#k1=#O} zQ{io$>tLK+>OuUay?|tMerL!Msq37}mU}ZtYcWpPj~kJ^$&qIef!zk@0A`0Ky&kB= zC{_K4X9h32fP@5OcGdj0H&T<(4MoN9w9;J$DzQL|v_Ps3k%!btizl!K-MAi98%$0)d)#_x73d9AW`ddK~-w z^vWYibc|+ug~sfsZ$7~lm*4%o@nE+(2>Ff;ruJ$gH!|Yd*oE?00)5cJ7(gAU5ucbO zlRk(VODbFQl?M$^i6)T$NvBVsN|m{O?`rg|*wV2!R@+&5fUMv0_^8Z8UZ?X`1tCId z(@(>`-1xVr`%dOfQu}Xi358j=1>VA=?-q0dIIagy@7Us2kXoN5S^^|Ui6Zb#Dr54^#_M9n+LV@@y;0m9`qptepbntVni)<}l(h=tAnm#2^cdCH9F@0gG!Zw$$|h-u_u z7;pIQeMp7=vSN$R8IkgZ^SyIW`W7eBSLnGkB*RJ;wkh8GzqFltfa=tros|jox2l~c z1=2u!XdSWm1N0a#R6TP>Oe=94a!OZOU(v!|YY>UvB2Z-gPy8oM&a`AUMrREi z_kx=u8#h2Ri`$et4W0xGyzW986%iH-y;|{gK-Z-97!i<8>np7oL3%iTIQ!xYB*udv zoUrH^mu#8z;mW9YG>KY);#jfV&oS|yNnd6?`?rwWH`IzgLXA#FY2qV3hm})v{W1cZZlIhDQ*iqM^}jVNTp8?XJKVD2%@ZyGxy9NUsvUAhsCn zcZVaSGzQNJFM#Bz!%GcivxDj+yD#ep{@Ur6ce%y0h;gBP!3;fd7CfjamYc*eF_`yF zGGzD^awPqKyIx&&|9YM8j5iH;U(E93@|?{iz~r&ql8t?HL`1~ebyLFMTfV=u`QOI7 zZ(Tsmfg{HX8tv2m%`IH8WtqbyR=fJsBH#At_upi_`RY>m_ZNYJ1@D@*S!NjMp8b61 z!13BE`MslKfT~$#_XKq1G9Q4yFSOwbpe0q_nsushRmgj_VKD-4!Tc`M_8g$+SrC?PWfe zlAqQ0@Bg=2bU(32akg)>;esujex0hfvHw4P`<wU@hj~@dT^b7S~?#lLi@!IEIw)%?Kcf5bRo&6)?GXq!m>is{v7_)EI z-FQ9M(RXjCb3I{H;7|%X=6<>V zte(gBso|pFzE9~<4YBDxfBvz3OU>_{&oDj3{_OPYk>@}2d??=W`|z31yRYBg?Keee zM!^1}oiTiKm?xxF&^WWDG`}gf-b2gv2 z!C8<`)MX5lF!N|bSOxM6r*T^!& zz{1MZ$ja1M+rYrez@VXJ!BZ3sx%nxXX_Z(s7(q0IuM>U?)WG2B>gTe~DWM4fAi=D- diff --git a/src/plone/app/multilingual/browser/stylesheet/multilingual.css b/src/plone/app/multilingual/browser/stylesheet/multilingual.css index a2e59134..c83dfa27 100644 --- a/src/plone/app/multilingual/browser/stylesheet/multilingual.css +++ b/src/plone/app/multilingual/browser/stylesheet/multilingual.css @@ -70,11 +70,19 @@ .translator-widget { position: relative; float: right; - background: url("++resource++plone.app.multilingual.images/gtranslate.png") no-repeat; - background-size: contain; + mask-image: url("++plone++bootstrap-icons/translate.svg"); + mask-position: center; + mask-repeat: no-repeat; + mask-size: calc(var(--bs-body-font-size) * 2); + background-color: var(--plone-link-color); min-height: 50px; min-width: 50px; display: none; + cursor: pointer; +} + +.translator-widget:hover { + background-color: var(--plone-link-color-on-grey); } .currentLanguage { From 2e4dbeb7601ca40fded02102667f821458885360 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Mon, 15 Dec 2025 15:43:00 +0100 Subject: [PATCH 44/45] restore tinymce detection --- src/plone/app/multilingual/browser/javascript/babel_helper.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/plone/app/multilingual/browser/javascript/babel_helper.js b/src/plone/app/multilingual/browser/javascript/babel_helper.js index 29b80a5d..9307a325 100644 --- a/src/plone/app/multilingual/browser/javascript/babel_helper.js +++ b/src/plone/app/multilingual/browser/javascript/babel_helper.js @@ -91,8 +91,7 @@ "translation_service_available" ); const target_el = dest_field.querySelector('textarea,input'); - // const target_tiny = tinymce.get(target_el.id); - const target_tiny = null; + const target_tiny = tinymce.get(target_el.id); sync_focus(orig_field, dest_field, target_tiny); sync_heights(orig_field, dest_field); From 2251b0701f6d5fbd1cd9519ecb7d61e8826383fb Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Mon, 15 Dec 2025 16:11:10 +0100 Subject: [PATCH 45/45] zpretty --- src/plone/app/multilingual/browser/configure.zcml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plone/app/multilingual/browser/configure.zcml b/src/plone/app/multilingual/browser/configure.zcml index 3b858a38..e7f580e5 100644 --- a/src/plone/app/multilingual/browser/configure.zcml +++ b/src/plone/app/multilingual/browser/configure.zcml @@ -5,7 +5,10 @@ > - +