From 71000180fc8aae759054503286b0a67c5788231e Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 6 Jan 2020 16:04:22 +0100 Subject: [PATCH 01/68] Refactor cloud sync out of Toolbox.py CURA-6983 --- plugins/Toolbox/__init__.py | 6 +- plugins/Toolbox/src/CloudApiModel.py | 18 +++ plugins/Toolbox/src/SubscriptionChecker.py | 124 +++++++++++++++++++++ plugins/Toolbox/src/Toolbox.py | 75 ++----------- 4 files changed, 156 insertions(+), 67 deletions(-) create mode 100644 plugins/Toolbox/src/CloudApiModel.py create mode 100644 plugins/Toolbox/src/SubscriptionChecker.py diff --git a/plugins/Toolbox/__init__.py b/plugins/Toolbox/__init__.py index 70c00ed07c..5bdb1c5dce 100644 --- a/plugins/Toolbox/__init__.py +++ b/plugins/Toolbox/__init__.py @@ -2,6 +2,7 @@ # Toolbox is released under the terms of the LGPLv3 or higher. from .src import Toolbox +from .src.SubscriptionChecker import SubscriptionChecker def getMetaData(): @@ -9,4 +10,7 @@ def getMetaData(): def register(app): - return {"extension": Toolbox.Toolbox(app)} + return { + "extension": Toolbox.Toolbox(app), + "subscription_checker": SubscriptionChecker(app) + } diff --git a/plugins/Toolbox/src/CloudApiModel.py b/plugins/Toolbox/src/CloudApiModel.py new file mode 100644 index 0000000000..082a28a24c --- /dev/null +++ b/plugins/Toolbox/src/CloudApiModel.py @@ -0,0 +1,18 @@ +from cura import ApplicationMetadata, UltimakerCloudAuthentication + + +class CloudApiModel: + sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int] + cloud_api_version = UltimakerCloudAuthentication.CuraCloudAPIVersion # type: str + cloud_api_root = UltimakerCloudAuthentication.CuraCloudAPIRoot # type: str + api_url = "{cloud_api_root}/cura-packages/v{cloud_api_version}/cura/v{sdk_version}".format( + cloud_api_root = cloud_api_root, + cloud_api_version = cloud_api_version, + sdk_version = sdk_version + ) # type: str + + # https://api.ultimaker.com/cura-packages/v1/user/packages + api_url_user_packages = "{cloud_api_root}/cura-packages/v{cloud_api_version}/user/packages".format( + cloud_api_root=cloud_api_root, + cloud_api_version=cloud_api_version, + ) diff --git a/plugins/Toolbox/src/SubscriptionChecker.py b/plugins/Toolbox/src/SubscriptionChecker.py new file mode 100644 index 0000000000..caac84566a --- /dev/null +++ b/plugins/Toolbox/src/SubscriptionChecker.py @@ -0,0 +1,124 @@ +import json +import os +import platform +from typing import Dict, Optional + +from PyQt5.QtCore import QObject +from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest + +from UM.Extension import Extension +from UM.Logger import Logger +from UM.Message import Message +from UM.PluginRegistry import PluginRegistry +from cura.CuraApplication import CuraApplication +from plugins.Toolbox.src.CloudApiModel import CloudApiModel +from plugins.Toolbox.src.SubscribedPackagesModel import SubscribedPackagesModel +from plugins.Toolbox.src.Toolbox import i18n_catalog + + +class SubscriptionChecker(QObject, Extension): + + def __init__(self, application: CuraApplication) -> None: + super().__init__() + + self._application = application # type: CuraApplication + self._model = SubscribedPackagesModel() + + self._application.initializationFinished.connect(self._onAppInitialized) + self._application.getCuraAPI().account.accessTokenChanged.connect(self._updateRequestHeader) + + # This is a plugin, so most of the components required are not ready when + # this is initialized. Therefore, we wait until the application is ready. + def _onAppInitialized(self) -> None: + self._package_manager = self._application.getPackageManager() + + # initial check + self._fetchUserSubscribedPackages() + # check again whenever the login state changes + self._application.getCuraAPI().account.loginStateChanged.connect(self._fetchUserSubscribedPackages) + + def _fetchUserSubscribedPackages(self): + if self._application.getCuraAPI().account.isLoggedIn: + self._getUserPackages("subscribed_packages") + + def _handleCompatibilityData(self, json_data) -> None: + user_subscribed_packages = [plugin["package_id"] for plugin in json_data] + user_installed_packages = self._package_manager.getUserInstalledPackages() + + # We check if there are packages installed in Cloud Marketplace but not in Cura marketplace (discrepancy) + package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages)) + + self._model.setMetadata(json_data) + self._model.addValue(package_discrepancy) + self._model.update() + + if package_discrepancy: + self._handlePackageDiscrepancies(package_discrepancy) + + def _handlePackageDiscrepancies(self, package_discrepancy): + Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") + sync_message = Message(i18n_catalog.i18nc( + "@info:generic", + "\nDo you want to sync material and software packages with your account?"), + lifetime=0, + title=i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) + sync_message.addAction("sync", + name=i18n_catalog.i18nc("@action:button", "Sync"), + icon="", + description="Sync your Cloud subscribed packages to your local environment.", + button_align=Message.ActionButtonAlignment.ALIGN_RIGHT) + sync_message.actionTriggered.connect(self._onSyncButtonClicked) + sync_message.show() + + def _onSyncButtonClicked(self, sync_message: Message, sync_message_action: str) -> None: + sync_message.hide() + compatibility_dialog_path = "resources/qml/dialogs/CompatibilityDialog.qml" + plugin_path_prefix = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) + if plugin_path_prefix: + path = os.path.join(plugin_path_prefix, compatibility_dialog_path) + self.compatibility_dialog_view = self._application.getInstance().createQmlComponent(path, {"toolbox": self}) + + def _getUserPackages(self, request_type: str) -> None: + Logger.log("d", "Requesting [%s] metadata from server.", request_type) + self._updateRequestHeader() + url = CloudApiModel.api_url_user_packages + + self._application.getHttpRequestManager().get(url, + headers_dict = self._request_headers, + callback = self._onUserPackagesRequestFinished, + error_callback = self._onUserPackagesRequestFinished) + + def _onUserPackagesRequestFinished(self, + reply: "QNetworkReply", + error: Optional["QNetworkReply.NetworkError"] = None) -> None: + if error is not None or reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200: + Logger.log("w", + "Requesting user packages failed, response code %s while trying to connect to %s", + reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url()) + return + + try: + json_data = json.loads(bytes(reply.readAll()).decode("utf-8")) + + # Check for errors: + if "errors" in json_data: + for error in json_data["errors"]: + Logger.log("e", "%s", error["title"]) + return + + self._handleCompatibilityData(json_data["data"]) + except json.decoder.JSONDecodeError: + Logger.log("w", "Received invalid JSON for user packages") + + def _updateRequestHeader(self): + # todo DRY, copied from Toolbox. To RequestManager? + self._request_headers = { + "User-Agent": "%s/%s (%s %s)" % (self._application.getApplicationName(), + self._application.getVersion(), + platform.system(), + platform.machine()) + } + access_token = self._application.getCuraAPI().account.accessToken + if access_token: + self._request_headers["Authorization"] = "Bearer {}".format(access_token) + diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index f28178b99e..624ba34094 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -15,12 +15,12 @@ from UM.PluginRegistry import PluginRegistry from UM.Extension import Extension from UM.i18n import i18nCatalog from UM.Version import Version -from UM.Message import Message from cura import ApplicationMetadata from cura import UltimakerCloudAuthentication from cura.CuraApplication import CuraApplication from cura.Machines.ContainerTree import ContainerTree +from plugins.Toolbox.src.CloudApiModel import CloudApiModel from .AuthorsModel import AuthorsModel from .PackagesModel import PackagesModel @@ -32,8 +32,7 @@ if TYPE_CHECKING: i18n_catalog = i18nCatalog("cura") - -## The Toolbox class is responsible of communicating with the server through the API +## Provides a marketplace for users to download plugins an materials class Toolbox(QObject, Extension): def __init__(self, application: CuraApplication) -> None: super().__init__() @@ -41,9 +40,6 @@ class Toolbox(QObject, Extension): self._application = application # type: CuraApplication self._sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int] - self._cloud_api_version = UltimakerCloudAuthentication.CuraCloudAPIVersion # type: str - self._cloud_api_root = UltimakerCloudAuthentication.CuraCloudAPIRoot # type: str - self._api_url = None # type: Optional[str] # Network: self._download_request_data = None # type: Optional[HttpRequestData] @@ -61,17 +57,15 @@ class Toolbox(QObject, Extension): self._server_response_data = { "authors": [], "packages": [], - "updates": [], - "subscribed_packages": [], + "updates": [] } # type: Dict[str, List[Any]] # Models: self._models = { "authors": AuthorsModel(self), "packages": PackagesModel(self), - "updates": PackagesModel(self), - "subscribed_packages": SubscribedPackagesModel(self), - } # type: Dict[str, Union[AuthorsModel, PackagesModel, SubscribedPackagesModel]] + "updates": PackagesModel(self) + } # type: Dict[str, Union[AuthorsModel, PackagesModel]] self._plugins_showcase_model = PackagesModel(self) self._plugins_available_model = PackagesModel(self) @@ -159,7 +153,7 @@ class Toolbox(QObject, Extension): @pyqtSlot(str, int) def ratePackage(self, package_id: str, rating: int) -> None: - url = "{base_url}/packages/{package_id}/ratings".format(base_url = self._api_url, package_id = package_id) + url = "{base_url}/packages/{package_id}/ratings".format(base_url = CloudApiModel.api_url, package_id = package_id) data = "{\"data\": {\"cura_version\": \"%s\", \"rating\": %i}}" % (Version(self._application.getVersion()), rating) self._application.getHttpRequestManager().put(url, headers_dict = self._request_headers, @@ -196,16 +190,6 @@ class Toolbox(QObject, Extension): def _onAppInitialized(self) -> None: self._plugin_registry = self._application.getPluginRegistry() self._package_manager = self._application.getPackageManager() - self._api_url = "{cloud_api_root}/cura-packages/v{cloud_api_version}/cura/v{sdk_version}".format( - cloud_api_root = self._cloud_api_root, - cloud_api_version = self._cloud_api_version, - sdk_version = self._sdk_version - ) - # https://api.ultimaker.com/cura-packages/v1/user/packages - self._api_url_user_packages = "{cloud_api_root}/cura-packages/v{cloud_api_version}/user/packages".format( - cloud_api_root = self._cloud_api_root, - cloud_api_version = self._cloud_api_version, - ) # We need to construct a query like installed_packages=ID:VERSION&installed_packages=ID:VERSION, etc. installed_package_ids_with_versions = [":".join(items) for items in @@ -213,27 +197,20 @@ class Toolbox(QObject, Extension): installed_packages_query = "&installed_packages=".join(installed_package_ids_with_versions) self._request_urls = { - "authors": "{base_url}/authors".format(base_url = self._api_url), - "packages": "{base_url}/packages".format(base_url = self._api_url), + "authors": "{base_url}/authors".format(base_url = CloudApiModel.api_url), + "packages": "{base_url}/packages".format(base_url = CloudApiModel.api_url), "updates": "{base_url}/packages/package-updates?installed_packages={query}".format( - base_url = self._api_url, query = installed_packages_query), - "subscribed_packages": self._api_url_user_packages, + base_url = CloudApiModel.api_url, query = installed_packages_query) } self._application.getCuraAPI().account.loginStateChanged.connect(self._restart) - self._application.getCuraAPI().account.loginStateChanged.connect(self._fetchUserSubscribedPackages) # On boot we check which packages have updates. if CuraApplication.getInstance().getPreferences().getValue("info/automatic_update_check") and len(installed_package_ids_with_versions) > 0: # Request the latest and greatest! self._makeRequestByType("updates") - self._fetchUserSubscribedPackages() - def _fetchUserSubscribedPackages(self): - if self._application.getCuraAPI().account.isLoggedIn: - self._makeRequestByType("subscribed_packages") - def _fetchPackageData(self) -> None: self._makeRequestByType("packages") self._makeRequestByType("authors") @@ -652,46 +629,12 @@ class Toolbox(QObject, Extension): # Tell the package manager that there's a new set of updates available. packages = set([pkg["package_id"] for pkg in self._server_response_data[request_type]]) self._package_manager.setPackagesWithUpdate(packages) - elif request_type == "subscribed_packages": - self._checkCompatibilities(json_data["data"]) self.metadataChanged.emit() if self.isLoadingComplete(): self.setViewPage("overview") - def _checkCompatibilities(self, json_data) -> None: - user_subscribed_packages = [plugin["package_id"] for plugin in json_data] - user_installed_packages = self._package_manager.getUserInstalledPackages() - - # We check if there are packages installed in Cloud Marketplace but not in Cura marketplace (discrepancy) - package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages)) - if package_discrepancy: - self._models["subscribed_packages"].addValue(package_discrepancy) - self._models["subscribed_packages"].update() - Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") - sync_message = Message(i18n_catalog.i18nc( - "@info:generic", - "\nDo you want to sync material and software packages with your account?"), - lifetime=0, - title=i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) - sync_message.addAction("sync", - name=i18n_catalog.i18nc("@action:button", "Sync"), - icon="", - description="Sync your Cloud subscribed packages to your local environment.", - button_align=Message.ActionButtonAlignment.ALIGN_RIGHT) - - sync_message.actionTriggered.connect(self._onSyncButtonClicked) - sync_message.show() - - def _onSyncButtonClicked(self, sync_message: Message, sync_message_action: str) -> None: - sync_message.hide() - compatibility_dialog_path = "resources/qml/dialogs/CompatibilityDialog.qml" - plugin_path_prefix = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) - if plugin_path_prefix: - path = os.path.join(plugin_path_prefix, compatibility_dialog_path) - self.compatibility_dialog_view = self._application.getInstance().createQmlComponent(path, {"toolbox": self}) - # This function goes through all known remote versions of a package and notifies the package manager of this change def _notifyPackageManager(self): for package in self._server_response_data["packages"]: From 6eab5e2492323b43898c0c48977ba0fa8f5f9bbd Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Tue, 7 Jan 2020 10:53:26 +0100 Subject: [PATCH 02/68] Add HttpRequestScope and fix the CompatibilityDialog CURA-6983 --- .../qml/dialogs/CompatibilityDialog.qml | 4 ++-- plugins/Toolbox/src/SubscriptionChecker.py | 24 ++++--------------- plugins/Toolbox/src/Toolbox.py | 3 +-- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml index a6ce7fc865..85664898a8 100644 --- a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml @@ -54,7 +54,7 @@ UM.Dialog{ } Repeater { - model: toolbox.subscribedPackagesModel + model: subscribedPackagesModel Component { Item @@ -97,7 +97,7 @@ UM.Dialog{ } Repeater { - model: toolbox.subscribedPackagesModel + model: subscribedPackagesModel Component { Item diff --git a/plugins/Toolbox/src/SubscriptionChecker.py b/plugins/Toolbox/src/SubscriptionChecker.py index caac84566a..b096f5a9c0 100644 --- a/plugins/Toolbox/src/SubscriptionChecker.py +++ b/plugins/Toolbox/src/SubscriptionChecker.py @@ -1,6 +1,5 @@ import json import os -import platform from typing import Dict, Optional from PyQt5.QtCore import QObject @@ -10,6 +9,7 @@ from UM.Extension import Extension from UM.Logger import Logger from UM.Message import Message from UM.PluginRegistry import PluginRegistry +from UM.TaskManagement.HttpRequestScope import UltimakerCloudScope from cura.CuraApplication import CuraApplication from plugins.Toolbox.src.CloudApiModel import CloudApiModel from plugins.Toolbox.src.SubscribedPackagesModel import SubscribedPackagesModel @@ -22,10 +22,10 @@ class SubscriptionChecker(QObject, Extension): super().__init__() self._application = application # type: CuraApplication + self._scope = UltimakerCloudScope(application) self._model = SubscribedPackagesModel() self._application.initializationFinished.connect(self._onAppInitialized) - self._application.getCuraAPI().account.accessTokenChanged.connect(self._updateRequestHeader) # This is a plugin, so most of the components required are not ready when # this is initialized. Therefore, we wait until the application is ready. @@ -76,17 +76,16 @@ class SubscriptionChecker(QObject, Extension): plugin_path_prefix = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) if plugin_path_prefix: path = os.path.join(plugin_path_prefix, compatibility_dialog_path) - self.compatibility_dialog_view = self._application.getInstance().createQmlComponent(path, {"toolbox": self}) + self.compatibility_dialog_view = self._application.createQmlComponent(path, {"subscribedPackagesModel": self._model}) def _getUserPackages(self, request_type: str) -> None: Logger.log("d", "Requesting [%s] metadata from server.", request_type) - self._updateRequestHeader() url = CloudApiModel.api_url_user_packages self._application.getHttpRequestManager().get(url, - headers_dict = self._request_headers, callback = self._onUserPackagesRequestFinished, - error_callback = self._onUserPackagesRequestFinished) + error_callback = self._onUserPackagesRequestFinished, + scope = self._scope) def _onUserPackagesRequestFinished(self, reply: "QNetworkReply", @@ -109,16 +108,3 @@ class SubscriptionChecker(QObject, Extension): self._handleCompatibilityData(json_data["data"]) except json.decoder.JSONDecodeError: Logger.log("w", "Received invalid JSON for user packages") - - def _updateRequestHeader(self): - # todo DRY, copied from Toolbox. To RequestManager? - self._request_headers = { - "User-Agent": "%s/%s (%s %s)" % (self._application.getApplicationName(), - self._application.getVersion(), - platform.system(), - platform.machine()) - } - access_token = self._application.getCuraAPI().account.accessToken - if access_token: - self._request_headers["Authorization"] = "Bearer {}".format(access_token) - diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 624ba34094..5cc6353c63 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -17,7 +17,6 @@ from UM.i18n import i18nCatalog from UM.Version import Version from cura import ApplicationMetadata -from cura import UltimakerCloudAuthentication from cura.CuraApplication import CuraApplication from cura.Machines.ContainerTree import ContainerTree from plugins.Toolbox.src.CloudApiModel import CloudApiModel @@ -45,7 +44,7 @@ class Toolbox(QObject, Extension): self._download_request_data = None # type: Optional[HttpRequestData] self._download_progress = 0 # type: float self._is_downloading = False # type: bool - self._request_headers = dict() # type: Dict[str, str] + self._request_headers = dict() # type: Dict[str, str] # todo DRY headers, use scope self._updateRequestHeader() self._request_urls = {} # type: Dict[str, str] From 1a816ad01020f431b5a9fd0d651c70987e9960ad Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Tue, 7 Jan 2020 14:34:15 +0100 Subject: [PATCH 03/68] Introduce the SyncOrchestrator CURA-6983 --- plugins/Toolbox/__init__.py | 6 ++-- .../qml/dialogs/CompatibilityDialog.qml | 1 + .../CloudPackageChecker.py} | 27 ++++++--------- .../src/CloudSync/DiscrepanciesPresenter.py | 33 ++++++++++++++++++ .../SubscribedPackagesModel.py | 2 +- .../Toolbox/src/CloudSync/SyncOrchestrator.py | 34 +++++++++++++++++++ plugins/Toolbox/src/CloudSync/__init__.py | 0 plugins/Toolbox/src/Toolbox.py | 2 +- 8 files changed, 84 insertions(+), 21 deletions(-) rename plugins/Toolbox/src/{SubscriptionChecker.py => CloudSync/CloudPackageChecker.py} (81%) create mode 100644 plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py rename plugins/Toolbox/src/{ => CloudSync}/SubscribedPackagesModel.py (94%) create mode 100644 plugins/Toolbox/src/CloudSync/SyncOrchestrator.py create mode 100644 plugins/Toolbox/src/CloudSync/__init__.py diff --git a/plugins/Toolbox/__init__.py b/plugins/Toolbox/__init__.py index 5bdb1c5dce..212e70fd36 100644 --- a/plugins/Toolbox/__init__.py +++ b/plugins/Toolbox/__init__.py @@ -2,7 +2,8 @@ # Toolbox is released under the terms of the LGPLv3 or higher. from .src import Toolbox -from .src.SubscriptionChecker import SubscriptionChecker +from plugins.Toolbox.src.CloudSync.CloudPackageChecker import CloudPackageChecker +from .src.CloudSync.SyncOrchestrator import SyncOrchestrator def getMetaData(): @@ -11,6 +12,5 @@ def getMetaData(): def register(app): return { - "extension": Toolbox.Toolbox(app), - "subscription_checker": SubscriptionChecker(app) + "extension": [Toolbox.Toolbox(app), SyncOrchestrator(app)] } diff --git a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml index 85664898a8..b7eed1b2e3 100644 --- a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml @@ -139,6 +139,7 @@ UM.Dialog{ anchors.right: parent.right anchors.margins: UM.Theme.getSize("default_margin").height text: catalog.i18nc("@button", "Next") + onClicked: accept() } } } diff --git a/plugins/Toolbox/src/SubscriptionChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py similarity index 81% rename from plugins/Toolbox/src/SubscriptionChecker.py rename to plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index b096f5a9c0..0f31b90959 100644 --- a/plugins/Toolbox/src/SubscriptionChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -1,26 +1,25 @@ import json -import os -from typing import Dict, Optional +from typing import Optional from PyQt5.QtCore import QObject from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest -from UM.Extension import Extension from UM.Logger import Logger from UM.Message import Message -from UM.PluginRegistry import PluginRegistry +from UM.Signal import Signal from UM.TaskManagement.HttpRequestScope import UltimakerCloudScope from cura.CuraApplication import CuraApplication from plugins.Toolbox.src.CloudApiModel import CloudApiModel -from plugins.Toolbox.src.SubscribedPackagesModel import SubscribedPackagesModel +from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel from plugins.Toolbox.src.Toolbox import i18n_catalog -class SubscriptionChecker(QObject, Extension): +class CloudPackageChecker(QObject): def __init__(self, application: CuraApplication) -> None: super().__init__() + self.discrepancies = Signal() # Emits SubscribedPackagesModel self._application = application # type: CuraApplication self._scope = UltimakerCloudScope(application) self._model = SubscribedPackagesModel() @@ -39,7 +38,7 @@ class SubscriptionChecker(QObject, Extension): def _fetchUserSubscribedPackages(self): if self._application.getCuraAPI().account.isLoggedIn: - self._getUserPackages("subscribed_packages") + self._getUserPackages() def _handleCompatibilityData(self, json_data) -> None: user_subscribed_packages = [plugin["package_id"] for plugin in json_data] @@ -53,9 +52,9 @@ class SubscriptionChecker(QObject, Extension): self._model.update() if package_discrepancy: - self._handlePackageDiscrepancies(package_discrepancy) + self._handlePackageDiscrepancies() - def _handlePackageDiscrepancies(self, package_discrepancy): + def _handlePackageDiscrepancies(self): Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") sync_message = Message(i18n_catalog.i18nc( "@info:generic", @@ -72,14 +71,10 @@ class SubscriptionChecker(QObject, Extension): def _onSyncButtonClicked(self, sync_message: Message, sync_message_action: str) -> None: sync_message.hide() - compatibility_dialog_path = "resources/qml/dialogs/CompatibilityDialog.qml" - plugin_path_prefix = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) - if plugin_path_prefix: - path = os.path.join(plugin_path_prefix, compatibility_dialog_path) - self.compatibility_dialog_view = self._application.createQmlComponent(path, {"subscribedPackagesModel": self._model}) + self.discrepancies.emit(self._model) - def _getUserPackages(self, request_type: str) -> None: - Logger.log("d", "Requesting [%s] metadata from server.", request_type) + def _getUserPackages(self) -> None: + Logger.log("d", "Requesting subscribed packages metadata from server.") url = CloudApiModel.api_url_user_packages self._application.getHttpRequestManager().get(url, diff --git a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py new file mode 100644 index 0000000000..e0a6cce558 --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py @@ -0,0 +1,33 @@ +import os +from typing import Optional + +from PyQt5.QtCore import QObject + +from UM.Qt.QtApplication import QtApplication +from UM.Signal import Signal +from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel + + +## Shows a list of packages to be added or removed. The user can select which packages to (un)install. The user's +# choices are emitted on the `packageMutations` Signal. +class DiscrepanciesPresenter(QObject): + + def __init__(self, app: QtApplication): + super().__init__(app) + + self.packageMutations = Signal() # {"SettingsGuide" : "install", "PrinterSettings" : "uninstall"} + + self._app = app + self._dialog = None # type: Optional[QObject] + self._compatibility_dialog_path = "resources/qml/dialogs/CompatibilityDialog.qml" + + def present(self, plugin_path: str, model: SubscribedPackagesModel): + path = os.path.join(plugin_path, self._compatibility_dialog_path) + self._dialog = self._app.createQmlComponent(path, {"subscribedPackagesModel": model}) + self._dialog.accepted.connect(lambda: self._onConfirmClicked(model)) + + def _onConfirmClicked(self, model: SubscribedPackagesModel): + # For now, all packages presented to the user should be installed. + # Later, we will support uninstall ?or ignoring? of a certain package + choices = {item["package_id"]: "install" for item in model.items} + self.packageMutations.emit(choices) diff --git a/plugins/Toolbox/src/SubscribedPackagesModel.py b/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py similarity index 94% rename from plugins/Toolbox/src/SubscribedPackagesModel.py rename to plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py index cf0d07c153..7ab6d30fe0 100644 --- a/plugins/Toolbox/src/SubscribedPackagesModel.py +++ b/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py @@ -33,7 +33,7 @@ class SubscribedPackagesModel(ListModel): for item in self._metadata: if item["package_id"] not in self._discrepancies: continue - package = {"name": item["display_name"], "sdk_versions": item["sdk_versions"]} + package = {"package_id": item["package_id"], "name": item["display_name"], "sdk_versions": item["sdk_versions"]} if self._sdk_version not in item["sdk_versions"]: package.update({"is_compatible": False}) else: diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py new file mode 100644 index 0000000000..aca20f98f7 --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -0,0 +1,34 @@ +from UM.Extension import Extension +from UM.PluginRegistry import PluginRegistry +from cura.CuraApplication import CuraApplication +from plugins.Toolbox import CloudPackageChecker +from plugins.Toolbox.src.CloudSync.DiscrepanciesPresenter import DiscrepanciesPresenter +from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel + + +## Orchestrates the synchronizing of packages from the user account to the installed packages +# Example flow: +# - CloudPackageChecker compares a list of packages the user `subscribed` to in their account +# If there are `discrepancies` between the account and locally installed packages, they are emitted +# - DiscrepanciesPresenter shows a list of packages to be added or removed to the user. It emits the `packageMutations` +# the user selected to be performed +# - The SyncOrchestrator uses PackageManager to remove local packages the users wants to see removed +# - The DownloadPresenter shows a download progress dialog +# - The LicencePresenter extracts licences from the downloaded packages and presents a licence for each package to +# - be installed. It emits the `licenceAnswers` {'packageId' : bool} for accept or declines +# - The CloudPackageManager removes the declined packages from the account +# - The SyncOrchestrator uses PackageManager to install the downloaded packages. +# - Bliss / profit / done +class SyncOrchestrator(Extension): + + def __init__(self, app: CuraApplication): + super().__init__() + + self._checker = CloudPackageChecker(app) + self._checker.discrepancies.connect(self._onDiscrepancies) + + self._discrepanciesPresenter = DiscrepanciesPresenter(app) + + def _onDiscrepancies(self, model: SubscribedPackagesModel): + plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) + self._discrepanciesPresenter.present(plugin_path, model) diff --git a/plugins/Toolbox/src/CloudSync/__init__.py b/plugins/Toolbox/src/CloudSync/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 5cc6353c63..7ef4a27ceb 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -23,7 +23,7 @@ from plugins.Toolbox.src.CloudApiModel import CloudApiModel from .AuthorsModel import AuthorsModel from .PackagesModel import PackagesModel -from .SubscribedPackagesModel import SubscribedPackagesModel +from .CloudSync.SubscribedPackagesModel import SubscribedPackagesModel if TYPE_CHECKING: from UM.TaskManagement.HttpRequestData import HttpRequestData From 883243b5330feb7b5eb3a120b0b580a43256afbf Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Wed, 8 Jan 2020 11:26:14 +0100 Subject: [PATCH 04/68] Move UltimaterCloudScope to Cura project CURA-6983 --- .../src/CloudSync/CloudPackageChecker.py | 2 +- plugins/Toolbox/src/UltimakerCloudScope.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 plugins/Toolbox/src/UltimakerCloudScope.py diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index 0f31b90959..b81d4a579e 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -7,7 +7,7 @@ from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from UM.Logger import Logger from UM.Message import Message from UM.Signal import Signal -from UM.TaskManagement.HttpRequestScope import UltimakerCloudScope +from plugins.Toolbox.src.UltimakerCloudScope import UltimakerCloudScope from cura.CuraApplication import CuraApplication from plugins.Toolbox.src.CloudApiModel import CloudApiModel from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel diff --git a/plugins/Toolbox/src/UltimakerCloudScope.py b/plugins/Toolbox/src/UltimakerCloudScope.py new file mode 100644 index 0000000000..b5f2983ee8 --- /dev/null +++ b/plugins/Toolbox/src/UltimakerCloudScope.py @@ -0,0 +1,20 @@ +from PyQt5.QtNetwork import QNetworkRequest + +from UM.TaskManagement.HttpRequestScope import DefaultUserAgentScope +from cura.API import Account +from cura.CuraApplication import CuraApplication + + +class UltimakerCloudScope(DefaultUserAgentScope): + def __init__(self, application: CuraApplication): + super().__init__(application) + api = application.getCuraAPI() + self._account = api.account # type: Account + + def request_hook(self, request: QNetworkRequest): + super().request_hook(request) + token = self._account.accessToken + header_dict = { + "Authorization": "Bearer {}".format(token) + } + self.add_headers(request, header_dict) From 84e676c1f895e3504a516f18f07860d55ec4fab4 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Wed, 8 Jan 2020 11:27:56 +0100 Subject: [PATCH 05/68] Download packages to be installed and show a message with progress. CURA-6983 --- .../src/CloudSync/DiscrepanciesPresenter.py | 9 +- .../src/CloudSync/DownloadPresenter.py | 114 ++++++++++++++++++ .../src/CloudSync/SubscribedPackagesModel.py | 8 +- .../Toolbox/src/CloudSync/SyncOrchestrator.py | 9 +- 4 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 plugins/Toolbox/src/CloudSync/DownloadPresenter.py diff --git a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py index e0a6cce558..9e7ad518ad 100644 --- a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py @@ -1,5 +1,5 @@ import os -from typing import Optional +from typing import Optional, Dict from PyQt5.QtCore import QObject @@ -15,7 +15,7 @@ class DiscrepanciesPresenter(QObject): def __init__(self, app: QtApplication): super().__init__(app) - self.packageMutations = Signal() # {"SettingsGuide" : "install", "PrinterSettings" : "uninstall"} + self.packageMutations = Signal() # Emits SubscribedPackagesModel self._app = app self._dialog = None # type: Optional[QObject] @@ -28,6 +28,5 @@ class DiscrepanciesPresenter(QObject): def _onConfirmClicked(self, model: SubscribedPackagesModel): # For now, all packages presented to the user should be installed. - # Later, we will support uninstall ?or ignoring? of a certain package - choices = {item["package_id"]: "install" for item in model.items} - self.packageMutations.emit(choices) + # Later, we might remove items for which the user unselected the package + self.packageMutations.emit(model) diff --git a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py new file mode 100644 index 0000000000..f64a96b9cf --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py @@ -0,0 +1,114 @@ +import os +import tempfile +from functools import reduce +from typing import Dict, List, Optional + +from PyQt5.QtNetwork import QNetworkReply + +from UM import i18n_catalog +from UM.Logger import Logger +from UM.Message import Message +from UM.Signal import Signal +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from cura.CuraApplication import CuraApplication +from plugins.Toolbox.src.UltimakerCloudScope import UltimakerCloudScope +from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel + + +## Downloads a set of packages from the Ultimaker Cloud Marketplace +# use download() exactly once: should not be used for multiple sets of downloads since this class contains state +class DownloadPresenter: + + def __init__(self, app: CuraApplication): + # Emits (Dict[str, str], List[str]) # (success_items, error_items) + # Dict{success_package_id, temp_file_path} + # List[errored_package_id] + self.done = Signal() + + self._app = app + self._scope = UltimakerCloudScope(app) + + self._started = False + self._progress_message = None # type: Optional[Message] + self._progress = {} # type: Dict[str, Dict[str, int]] # package_id, Dict + self._error = [] # type: List[str] # package_id + + def download(self, model: SubscribedPackagesModel): + if self._started: + Logger.error("Download already started. Create a new %s instead", self.__class__.__name__) + return + + manager = HttpRequestManager.getInstance() + for item in model.items: + package_id = item["package_id"] + self._progress[package_id] = { + "received": 0, + "total": 1 # make sure this is not considered done yet. Also divByZero-safe + } + + manager.get( + item["download_url"], + callback = lambda reply: self._onFinished(package_id, reply), + download_progress_callback = lambda rx, rt: self._onProgress(package_id, rx, rt), + error_callback = lambda rx, rt: self._onProgress(package_id, rx, rt), + scope = self._scope) + + self._started = True + self._showProgressMessage() + + def _showProgressMessage(self): + self._progress_message = Message(i18n_catalog.i18nc( + "@info:generic", + "\nSyncing..."), + lifetime = 0, + progress = 0.0, + title = i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) + self._progress_message.show() + + def _onFinished(self, package_id: str, reply: QNetworkReply): + self._progress[package_id]["received"] = self._progress[package_id]["total"] + + file_path = self._getTempFile(package_id) + try: + with open(file_path) as temp_file: + # todo buffer this + temp_file.write(reply.readAll()) + except IOError: + self._onError(package_id) + + self._checkDone() + + def _onProgress(self, package_id: str, rx: int, rt: int): + self._progress[package_id]["received"] = rx + self._progress[package_id]["total"] = rt + + received = 0 + total = 0 + for item in self._progress.values(): + received += item["received"] + total += item["total"] + + self._progress_message.setProgress(100.0 * (received / total)) # [0 .. 100] % + + self._checkDone() + + def _onError(self, package_id: str): + self._progress.pop(package_id) + self._error.append(package_id) + self._checkDone() + + def _checkDone(self) -> bool: + for item in self._progress.values(): + if item["received"] != item["total"] or item["total"] == -1: + return False + + success_items = {package_id : self._getTempFile(package_id) for package_id in self._progress.keys()} + error_items = [package_id for package_id in self._error] + + self._progress_message.hide() + self.done.emit(success_items, error_items) + + def _getTempFile(self, package_id: str) -> str: + temp_dir = tempfile.gettempdir() + return os.path.join(temp_dir, package_id) + diff --git a/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py b/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py index 7ab6d30fe0..d98db0ec4d 100644 --- a/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py +++ b/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py @@ -33,7 +33,13 @@ class SubscribedPackagesModel(ListModel): for item in self._metadata: if item["package_id"] not in self._discrepancies: continue - package = {"package_id": item["package_id"], "name": item["display_name"], "sdk_versions": item["sdk_versions"]} + package = { + "package_id": item["package_id"], + "name": item["display_name"], + "sdk_versions": item["sdk_versions"], + "download_url": item["download_url"], + "md5_hash": item["md5_hash"], + } if self._sdk_version not in item["sdk_versions"]: package.update({"is_compatible": False}) else: diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index aca20f98f7..05436c1db3 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -3,6 +3,7 @@ from UM.PluginRegistry import PluginRegistry from cura.CuraApplication import CuraApplication from plugins.Toolbox import CloudPackageChecker from plugins.Toolbox.src.CloudSync.DiscrepanciesPresenter import DiscrepanciesPresenter +from plugins.Toolbox.src.CloudSync.DownloadPresenter import DownloadPresenter from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel @@ -13,7 +14,7 @@ from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPack # - DiscrepanciesPresenter shows a list of packages to be added or removed to the user. It emits the `packageMutations` # the user selected to be performed # - The SyncOrchestrator uses PackageManager to remove local packages the users wants to see removed -# - The DownloadPresenter shows a download progress dialog +# - The DownloadPresenter shows a download progress dialog. It emits A tuple of succeeded and failed downloads # - The LicencePresenter extracts licences from the downloaded packages and presents a licence for each package to # - be installed. It emits the `licenceAnswers` {'packageId' : bool} for accept or declines # - The CloudPackageManager removes the declined packages from the account @@ -28,7 +29,13 @@ class SyncOrchestrator(Extension): self._checker.discrepancies.connect(self._onDiscrepancies) self._discrepanciesPresenter = DiscrepanciesPresenter(app) + self._discrepanciesPresenter.packageMutations.connect(self._onPackageMutations) + + self._downloadPresenter = DownloadPresenter(app) def _onDiscrepancies(self, model: SubscribedPackagesModel): plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) self._discrepanciesPresenter.present(plugin_path, model) + + def _onPackageMutations(self, mutations: SubscribedPackagesModel): + self._downloadPresenter.download(mutations) From 028aece644e67cc0494d8cbed509ee1153e3855f Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Wed, 8 Jan 2020 16:28:27 +0100 Subject: [PATCH 06/68] Fix extensions not being unique in ExtensionModel CURA-6983 --- resources/qml/MainWindow/ApplicationMenu.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/qml/MainWindow/ApplicationMenu.qml b/resources/qml/MainWindow/ApplicationMenu.qml index 30e44d7d3b..33cbf75c8b 100644 --- a/resources/qml/MainWindow/ApplicationMenu.qml +++ b/resources/qml/MainWindow/ApplicationMenu.qml @@ -154,7 +154,7 @@ Item } } - // show the plugin browser dialog + // show the Toolbox Connections { target: Cura.Actions.browsePackages From dda3d0b4eb8a359efe45f0ceb17a83928f7b2149 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 9 Jan 2020 16:56:53 +0100 Subject: [PATCH 07/68] Fix downloadPresenter and initial LicensePresenter.py code CURA-6983 --- .../qml/dialogs/ToolboxLicenseDialog.qml | 22 +++-- .../src/CloudSync/DownloadPresenter.py | 64 ++++++++----- plugins/Toolbox/src/CloudSync/LicenseModel.py | 30 +++++++ .../Toolbox/src/CloudSync/LicensePresenter.py | 89 +++++++++++++++++++ .../Toolbox/src/CloudSync/SyncOrchestrator.py | 34 +++++-- 5 files changed, 196 insertions(+), 43 deletions(-) create mode 100644 plugins/Toolbox/src/CloudSync/LicenseModel.py create mode 100644 plugins/Toolbox/src/CloudSync/LicensePresenter.py diff --git a/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml b/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml index 3e8d686741..4b15d167e8 100644 --- a/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml @@ -13,6 +13,7 @@ import UM 1.1 as UM UM.Dialog { + id: licenseDialog title: catalog.i18nc("@title:window", "Plugin License Agreement") minimumWidth: UM.Theme.getSize("license_window_minimum").width minimumHeight: UM.Theme.getSize("license_window_minimum").height @@ -21,16 +22,21 @@ UM.Dialog property var pluginName; property var licenseContent; property var pluginFileLocation; + Item { anchors.fill: parent + + UM.I18nCatalog{id: catalog; name: "cura"} + + Label { id: licenseTitle anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right - text: licenseDialog.pluginName + ": " + catalog.i18nc("@label", "This plugin contains a license.\nYou need to accept this license to install this plugin.\nDo you agree with the terms below?") + text: licenseModel.title wrapMode: Text.Wrap renderType: Text.NativeRendering } @@ -43,7 +49,7 @@ UM.Dialog anchors.right: parent.right anchors.topMargin: UM.Theme.getSize("default_margin").height readOnly: true - text: licenseDialog.licenseContent || "" + text: licenseModel.licenseText } } rightButtons: @@ -53,22 +59,14 @@ UM.Dialog id: acceptButton anchors.margins: UM.Theme.getSize("default_margin").width text: catalog.i18nc("@action:button", "Accept") - onClicked: - { - licenseDialog.close(); - toolbox.install(licenseDialog.pluginFileLocation); - toolbox.subscribe(licenseDialog.pluginName); - } + onClicked: handler.onLicenseAccepted }, Button { id: declineButton anchors.margins: UM.Theme.getSize("default_margin").width text: catalog.i18nc("@action:button", "Decline") - onClicked: - { - licenseDialog.close(); - } + onClicked: handler.onLicenseDeclined } ] } diff --git a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py index f64a96b9cf..b70eac98df 100644 --- a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py @@ -1,7 +1,7 @@ import os import tempfile from functools import reduce -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Any from PyQt5.QtNetwork import QNetworkReply @@ -30,7 +30,7 @@ class DownloadPresenter: self._started = False self._progress_message = None # type: Optional[Message] - self._progress = {} # type: Dict[str, Dict[str, int]] # package_id, Dict + self._progress = {} # type: Dict[str, Dict[str, Any]] # package_id, Dict self._error = [] # type: List[str] # package_id def download(self, model: SubscribedPackagesModel): @@ -41,26 +41,41 @@ class DownloadPresenter: manager = HttpRequestManager.getInstance() for item in model.items: package_id = item["package_id"] + + request_data = manager.get( + item["download_url"], + callback = lambda reply, pid = package_id: self._onFinished(pid, reply), + download_progress_callback = lambda rx, rt, pid = package_id: self._onProgress(pid, rx, rt), + error_callback = lambda rx, rt, pid = package_id: self._onProgress(pid, rx, rt), + scope = self._scope) + self._progress[package_id] = { "received": 0, - "total": 1 # make sure this is not considered done yet. Also divByZero-safe + "total": 1, # make sure this is not considered done yet. Also divByZero-safe + "file_written": None, + "request_data": request_data } - manager.get( - item["download_url"], - callback = lambda reply: self._onFinished(package_id, reply), - download_progress_callback = lambda rx, rt: self._onProgress(package_id, rx, rt), - error_callback = lambda rx, rt: self._onProgress(package_id, rx, rt), - scope = self._scope) - self._started = True self._showProgressMessage() + def abort(self): + manager = HttpRequestManager.getInstance() + for item in self._progress.values(): + manager.abortRequest(item["request_data"]) + + # Aborts all current operations and returns a copy with the same settings such as app and scope + def resetCopy(self): + self.abort() + self.done.disconnectAll() + return DownloadPresenter(self._app) + def _showProgressMessage(self): self._progress_message = Message(i18n_catalog.i18nc( "@info:generic", "\nSyncing..."), lifetime = 0, + use_inactivity_timer=False, progress = 0.0, title = i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) self._progress_message.show() @@ -68,13 +83,21 @@ class DownloadPresenter: def _onFinished(self, package_id: str, reply: QNetworkReply): self._progress[package_id]["received"] = self._progress[package_id]["total"] - file_path = self._getTempFile(package_id) + file_fd, file_path = tempfile.mkstemp() + os.close(file_fd) # close the file so we can open it from python + try: - with open(file_path) as temp_file: - # todo buffer this - temp_file.write(reply.readAll()) - except IOError: + with open(file_path, "wb+") as temp_file: + bytes_read = reply.read(256 * 1024) + while bytes_read: + temp_file.write(bytes_read) + bytes_read = reply.read(256 * 1024) + self._app.processEvents() + self._progress[package_id]["file_written"] = file_path + except IOError as e: + Logger.logException("e", "Failed to write downloaded package to temp file", e) self._onError(package_id) + temp_file.close() self._checkDone() @@ -90,8 +113,6 @@ class DownloadPresenter: self._progress_message.setProgress(100.0 * (received / total)) # [0 .. 100] % - self._checkDone() - def _onError(self, package_id: str): self._progress.pop(package_id) self._error.append(package_id) @@ -99,16 +120,11 @@ class DownloadPresenter: def _checkDone(self) -> bool: for item in self._progress.values(): - if item["received"] != item["total"] or item["total"] == -1: + if not item["file_written"]: return False - success_items = {package_id : self._getTempFile(package_id) for package_id in self._progress.keys()} + success_items = {package_id : value["file_written"] for package_id, value in self._progress.items()} error_items = [package_id for package_id in self._error] self._progress_message.hide() self.done.emit(success_items, error_items) - - def _getTempFile(self, package_id: str) -> str: - temp_dir = tempfile.gettempdir() - return os.path.join(temp_dir, package_id) - diff --git a/plugins/Toolbox/src/CloudSync/LicenseModel.py b/plugins/Toolbox/src/CloudSync/LicenseModel.py new file mode 100644 index 0000000000..2706306361 --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/LicenseModel.py @@ -0,0 +1,30 @@ +from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal + + +# Model for the ToolboxLicenseDialog +class LicenseModel(QObject): + titleChanged = pyqtSignal() + licenseTextChanged = pyqtSignal() + + def __init__(self, title: str = "", license_text: str = ""): + super().__init__() + self._title = title + self._license_text = license_text + + @pyqtProperty(str, notify=titleChanged) + def title(self) -> str: + return self._title + + def setTitle(self, title: str) -> None: + if self._title != title: + self._title = title + self.titleChanged.emit() + + @pyqtProperty(str, notify=licenseTextChanged) + def licenseText(self) -> str: + return self._license_text + + def setLicenseText(self, license_text: str) -> None: + if self._license_text != license_text: + self._license_text = license_text + self.licenseTextChanged.emit() diff --git a/plugins/Toolbox/src/CloudSync/LicensePresenter.py b/plugins/Toolbox/src/CloudSync/LicensePresenter.py new file mode 100644 index 0000000000..be288ef787 --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/LicensePresenter.py @@ -0,0 +1,89 @@ +import os +from typing import Dict, Optional + +from PyQt5.QtCore import QObject, pyqtSlot + +from UM.PackageManager import PackageManager +from UM.Signal import Signal +from cura.CuraApplication import CuraApplication +from UM.i18n import i18nCatalog + + +from plugins.Toolbox.src.CloudSync.LicenseModel import LicenseModel + + +class LicensePresenter(QObject): + + def __init__(self, app: CuraApplication): + super().__init__() + self._dialog = None #type: Optional[QObject] + self._package_manager = app.getPackageManager() # type: PackageManager + # Emits # todo + self.license_answers = Signal() + + self._current_package_idx = 0 + self._package_models = None # type: Optional[Dict] + + self._app = app + + self._compatibility_dialog_path = "resources/qml/dialogs/ToolboxLicenseDialog.qml" + + ## Show a license dialog for multiple packages where users can read a license and accept or decline them + # \param packages: Dict[package id, file path] + def present(self, plugin_path: str, packages: Dict[str, str]): + path = os.path.join(plugin_path, self._compatibility_dialog_path) + + self._initState(packages) + + if self._dialog is None: + + context_properties = { + "catalog": i18nCatalog("cura"), + "licenseModel": LicenseModel("initial title", "initial text"), + "handler": self + } + self._dialog = self._app.createQmlComponent(path, context_properties) + + self._present_current_package() + + @pyqtSlot() + def onLicenseAccepted(self): + self._package_models[self._current_package_idx]["accepted"] = True + self._check_next_page() + + @pyqtSlot() + def onLicenseDeclined(self): + self._package_models[self._current_package_idx]["accepted"] = False + self._check_next_page() + + def _initState(self, packages: Dict[str, str]): + self._package_models = [ + { + "package_id" : package_id, + "package_path" : package_path, + "accepted" : None #: None: no answer yet + } + for package_id, package_path in packages.items() + ] + + def _present_current_package(self): + package_model = self._package_models[self._current_package_idx] + license_content = self._package_manager.getPackageLicense(package_model["package_path"]) + if license_content is None: + # implicitly accept when there is no license + self.onLicenseAccepted() + return + + self._dialog.setProperty("licenseModel", LicenseModel("testTitle", "hoi")) + self._dialog.open() # does nothing if already open + + def _check_next_page(self): + if self._current_package_idx + 1 < len(self._package_models): + self._current_package_idx += 1 + self._present_current_package() + else: + self._dialog.close() + self.license_answers.emit(self._package_models) + + + diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index 05436c1db3..704e7c3e3a 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -1,9 +1,12 @@ +from typing import List, Dict + from UM.Extension import Extension from UM.PluginRegistry import PluginRegistry from cura.CuraApplication import CuraApplication from plugins.Toolbox import CloudPackageChecker from plugins.Toolbox.src.CloudSync.DiscrepanciesPresenter import DiscrepanciesPresenter from plugins.Toolbox.src.CloudSync.DownloadPresenter import DownloadPresenter +from plugins.Toolbox.src.CloudSync.LicensePresenter import LicensePresenter from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel @@ -15,8 +18,8 @@ from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPack # the user selected to be performed # - The SyncOrchestrator uses PackageManager to remove local packages the users wants to see removed # - The DownloadPresenter shows a download progress dialog. It emits A tuple of succeeded and failed downloads -# - The LicencePresenter extracts licences from the downloaded packages and presents a licence for each package to -# - be installed. It emits the `licenceAnswers` {'packageId' : bool} for accept or declines +# - The LicensePresenter extracts licenses from the downloaded packages and presents a license for each package to +# - be installed. It emits the `licenseAnswers` {'packageId' : bool} for accept or declines # - The CloudPackageManager removes the declined packages from the account # - The SyncOrchestrator uses PackageManager to install the downloaded packages. # - Bliss / profit / done @@ -24,18 +27,35 @@ class SyncOrchestrator(Extension): def __init__(self, app: CuraApplication): super().__init__() + self._name = "SyncOrchestrator" # Critical to differentiate This PluginObject from the Toolbox - self._checker = CloudPackageChecker(app) + self._checker = CloudPackageChecker(app) # type: CloudPackageChecker self._checker.discrepancies.connect(self._onDiscrepancies) - self._discrepanciesPresenter = DiscrepanciesPresenter(app) + self._discrepanciesPresenter = DiscrepanciesPresenter(app) # type: DiscrepanciesPresenter self._discrepanciesPresenter.packageMutations.connect(self._onPackageMutations) - self._downloadPresenter = DownloadPresenter(app) + self._downloadPresenter = DownloadPresenter(app) # type: DownloadPresenter + + self._licensePresenter = LicensePresenter(app) # type: LicensePresenter def _onDiscrepancies(self, model: SubscribedPackagesModel): - plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) - self._discrepanciesPresenter.present(plugin_path, model) + # todo revert + self._onDownloadFinished({"SupportEraser" : "/home/nvanhooff/Downloads/ThingiBrowser-v7.0.0-2019-12-12T18_24_40Z.curapackage"}, []) + # plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) + # self._discrepanciesPresenter.present(plugin_path, model) def _onPackageMutations(self, mutations: SubscribedPackagesModel): + self._downloadPresenter = self._downloadPresenter.resetCopy() + self._downloadPresenter.done.connect(self._onDownloadFinished) self._downloadPresenter.download(mutations) + + ## When a set of packages have finished downloading + # \param success_items: Dict[package_id, file_path] + # \param error_items: List[package_id] + def _onDownloadFinished(self, success_items: Dict[str, str], error_items: List[str]): + # todo handle error items + plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) + self._licensePresenter.present(plugin_path, success_items) + + From 89994b92b5db53f1503d5d1ebe5a1447d8d7e7cd Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 9 Jan 2020 17:44:31 +0100 Subject: [PATCH 08/68] Finished LicensePresenter CURA-6983 --- .../qml/dialogs/ToolboxLicenseDialog.qml | 12 ++--- plugins/Toolbox/src/CloudSync/LicenseModel.py | 46 ++++++++++++++----- .../Toolbox/src/CloudSync/LicensePresenter.py | 10 ++-- .../Toolbox/src/CloudSync/SyncOrchestrator.py | 14 ++++-- 4 files changed, 57 insertions(+), 25 deletions(-) diff --git a/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml b/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml index 4b15d167e8..4887ce922a 100644 --- a/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml @@ -14,7 +14,7 @@ import UM 1.1 as UM UM.Dialog { id: licenseDialog - title: catalog.i18nc("@title:window", "Plugin License Agreement") + title: licenseModel.dialogTitle minimumWidth: UM.Theme.getSize("license_window_minimum").width minimumHeight: UM.Theme.getSize("license_window_minimum").height width: minimumWidth @@ -32,18 +32,18 @@ UM.Dialog Label { - id: licenseTitle + id: licenseHeader anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right - text: licenseModel.title + text: licenseModel.headerText wrapMode: Text.Wrap renderType: Text.NativeRendering } TextArea { id: licenseText - anchors.top: licenseTitle.bottom + anchors.top: licenseHeader.bottom anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right @@ -59,14 +59,14 @@ UM.Dialog id: acceptButton anchors.margins: UM.Theme.getSize("default_margin").width text: catalog.i18nc("@action:button", "Accept") - onClicked: handler.onLicenseAccepted + onClicked: { handler.onLicenseAccepted() } }, Button { id: declineButton anchors.margins: UM.Theme.getSize("default_margin").width text: catalog.i18nc("@action:button", "Decline") - onClicked: handler.onLicenseDeclined + onClicked: { handler.onLicenseDeclined() } } ] } diff --git a/plugins/Toolbox/src/CloudSync/LicenseModel.py b/plugins/Toolbox/src/CloudSync/LicenseModel.py index 2706306361..70a1264e0a 100644 --- a/plugins/Toolbox/src/CloudSync/LicenseModel.py +++ b/plugins/Toolbox/src/CloudSync/LicenseModel.py @@ -1,24 +1,35 @@ from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal +from UM.i18n import i18nCatalog +catalog = i18nCatalog("cura") # Model for the ToolboxLicenseDialog class LicenseModel(QObject): - titleChanged = pyqtSignal() + dialogTitleChanged = pyqtSignal() + headerChanged = pyqtSignal() licenseTextChanged = pyqtSignal() - def __init__(self, title: str = "", license_text: str = ""): + def __init__(self): super().__init__() - self._title = title - self._license_text = license_text - @pyqtProperty(str, notify=titleChanged) - def title(self) -> str: - return self._title + self._current_page_idx = 0 + self._page_count = 1 + self._dialogTitle = "" + self._header_text = "" + self._license_text = "" + self._package_name = "" - def setTitle(self, title: str) -> None: - if self._title != title: - self._title = title - self.titleChanged.emit() + @pyqtProperty(str, notify=dialogTitleChanged) + def dialogTitle(self) -> str: + return self._dialogTitle + + @pyqtProperty(str, notify=headerChanged) + def headerText(self) -> str: + return self._header_text + + def setPackageName(self, name: str) -> None: + self._header_text = name + ": " + catalog.i18nc("@label", "This plugin contains a license.\nYou need to accept this license to install this plugin.\nDo you agree with the terms below?") + self.headerChanged.emit() @pyqtProperty(str, notify=licenseTextChanged) def licenseText(self) -> str: @@ -28,3 +39,16 @@ class LicenseModel(QObject): if self._license_text != license_text: self._license_text = license_text self.licenseTextChanged.emit() + + def setCurrentPageNumber(self, idx: int) -> None: + self._current_page_idx = idx + self._updateDialogTitle() + + def setPageCount(self, count: int): + self._page_count = count + self._updateDialogTitle() + + def _updateDialogTitle(self): + self._dialogTitle = catalog.i18nc("@title:window", "Plugin License Agreement ({}/{})" + .format(self._current_page_idx + 1, self._page_count)) + self.dialogTitleChanged.emit() diff --git a/plugins/Toolbox/src/CloudSync/LicensePresenter.py b/plugins/Toolbox/src/CloudSync/LicensePresenter.py index be288ef787..f8aaa5c758 100644 --- a/plugins/Toolbox/src/CloudSync/LicensePresenter.py +++ b/plugins/Toolbox/src/CloudSync/LicensePresenter.py @@ -23,6 +23,7 @@ class LicensePresenter(QObject): self._current_package_idx = 0 self._package_models = None # type: Optional[Dict] + self._license_model = LicenseModel() # type: LicenseModel self._app = app @@ -39,11 +40,11 @@ class LicensePresenter(QObject): context_properties = { "catalog": i18nCatalog("cura"), - "licenseModel": LicenseModel("initial title", "initial text"), + "licenseModel": self._license_model, "handler": self } self._dialog = self._app.createQmlComponent(path, context_properties) - + self._license_model.setPageCount(len(self._package_models)) self._present_current_package() @pyqtSlot() @@ -74,7 +75,10 @@ class LicensePresenter(QObject): self.onLicenseAccepted() return - self._dialog.setProperty("licenseModel", LicenseModel("testTitle", "hoi")) + self._license_model.setCurrentPageNumber(self._current_package_idx) + self._license_model.setPackageName(package_model["package_id"]) + self._license_model.setLicenseText(license_content) + self._dialog.open() # does nothing if already open def _check_next_page(self): diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index 704e7c3e3a..44437fdaa5 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -1,6 +1,7 @@ from typing import List, Dict from UM.Extension import Extension +from UM.Logger import Logger from UM.PluginRegistry import PluginRegistry from cura.CuraApplication import CuraApplication from plugins.Toolbox import CloudPackageChecker @@ -38,19 +39,18 @@ class SyncOrchestrator(Extension): self._downloadPresenter = DownloadPresenter(app) # type: DownloadPresenter self._licensePresenter = LicensePresenter(app) # type: LicensePresenter + self._licensePresenter.license_answers.connect(self._onLicenseAnswers) def _onDiscrepancies(self, model: SubscribedPackagesModel): - # todo revert - self._onDownloadFinished({"SupportEraser" : "/home/nvanhooff/Downloads/ThingiBrowser-v7.0.0-2019-12-12T18_24_40Z.curapackage"}, []) - # plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) - # self._discrepanciesPresenter.present(plugin_path, model) + plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) + self._discrepanciesPresenter.present(plugin_path, model) def _onPackageMutations(self, mutations: SubscribedPackagesModel): self._downloadPresenter = self._downloadPresenter.resetCopy() self._downloadPresenter.done.connect(self._onDownloadFinished) self._downloadPresenter.download(mutations) - ## When a set of packages have finished downloading + ## Called when a set of packages have finished downloading # \param success_items: Dict[package_id, file_path] # \param error_items: List[package_id] def _onDownloadFinished(self, success_items: Dict[str, str], error_items: List[str]): @@ -58,4 +58,8 @@ class SyncOrchestrator(Extension): plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) self._licensePresenter.present(plugin_path, success_items) + # Called when user has accepted / declined all licenses for the downloaded packages + def _onLicenseAnswers(self, answers: Dict[str, bool]): + Logger.debug("Got license answers: {}", answers) + From 6069096141abf79f472d78656c4777b5a27e64be Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 9 Jan 2020 18:05:49 +0100 Subject: [PATCH 09/68] CloudSync: documentation and cleanup CURA-6983 --- plugins/Toolbox/src/CloudSync/LicensePresenter.py | 13 ++++++++----- plugins/Toolbox/src/CloudSync/SyncOrchestrator.py | 8 +++++--- plugins/Toolbox/src/Toolbox.py | 2 ++ 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/plugins/Toolbox/src/CloudSync/LicensePresenter.py b/plugins/Toolbox/src/CloudSync/LicensePresenter.py index f8aaa5c758..bd991a665a 100644 --- a/plugins/Toolbox/src/CloudSync/LicensePresenter.py +++ b/plugins/Toolbox/src/CloudSync/LicensePresenter.py @@ -8,18 +8,20 @@ from UM.Signal import Signal from cura.CuraApplication import CuraApplication from UM.i18n import i18nCatalog - from plugins.Toolbox.src.CloudSync.LicenseModel import LicenseModel +## Call present() to show a licenseDialog for a set of packages +# licenseAnswers emits a list of Dicts containing answers when the user has made a choice for all provided packages class LicensePresenter(QObject): def __init__(self, app: CuraApplication): super().__init__() - self._dialog = None #type: Optional[QObject] + self._dialog = None # type: Optional[QObject] self._package_manager = app.getPackageManager() # type: PackageManager - # Emits # todo - self.license_answers = Signal() + # Emits List[Dict[str, str]] containing for example + # [{ "package_id": "BarbarianPlugin", "package_path" : "/tmp/dg345as", "accepted" : True }] + self.licenseAnswers = Signal() self._current_package_idx = 0 self._package_models = None # type: Optional[Dict] @@ -30,6 +32,7 @@ class LicensePresenter(QObject): self._compatibility_dialog_path = "resources/qml/dialogs/ToolboxLicenseDialog.qml" ## Show a license dialog for multiple packages where users can read a license and accept or decline them + # \param plugin_path: Root directory of the Toolbox plugin # \param packages: Dict[package id, file path] def present(self, plugin_path: str, packages: Dict[str, str]): path = os.path.join(plugin_path, self._compatibility_dialog_path) @@ -87,7 +90,7 @@ class LicensePresenter(QObject): self._present_current_package() else: self._dialog.close() - self.license_answers.emit(self._package_models) + self.licenseAnswers.emit(self._package_models) diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index 44437fdaa5..ebbc71d0e0 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -20,7 +20,7 @@ from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPack # - The SyncOrchestrator uses PackageManager to remove local packages the users wants to see removed # - The DownloadPresenter shows a download progress dialog. It emits A tuple of succeeded and failed downloads # - The LicensePresenter extracts licenses from the downloaded packages and presents a license for each package to -# - be installed. It emits the `licenseAnswers` {'packageId' : bool} for accept or declines +# be installed. It emits the `licenseAnswers` {'packageId' : bool} for accept or declines # - The CloudPackageManager removes the declined packages from the account # - The SyncOrchestrator uses PackageManager to install the downloaded packages. # - Bliss / profit / done @@ -28,7 +28,9 @@ class SyncOrchestrator(Extension): def __init__(self, app: CuraApplication): super().__init__() - self._name = "SyncOrchestrator" # Critical to differentiate This PluginObject from the Toolbox + # Differentiate This PluginObject from the Toolbox. self.getId() includes _name. + # getPluginId() will return the same value for The toolbox extension and this one + self._name = "SyncOrchestrator" self._checker = CloudPackageChecker(app) # type: CloudPackageChecker self._checker.discrepancies.connect(self._onDiscrepancies) @@ -39,7 +41,7 @@ class SyncOrchestrator(Extension): self._downloadPresenter = DownloadPresenter(app) # type: DownloadPresenter self._licensePresenter = LicensePresenter(app) # type: LicensePresenter - self._licensePresenter.license_answers.connect(self._onLicenseAnswers) + self._licensePresenter.licenseAnswers.connect(self._onLicenseAnswers) def _onDiscrepancies(self, model: SubscribedPackagesModel): plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 7ef4a27ceb..c2d21b349c 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -31,6 +31,8 @@ if TYPE_CHECKING: i18n_catalog = i18nCatalog("cura") +# todo Remove license and download dialog, use SyncOrchestrator instead + ## Provides a marketplace for users to download plugins an materials class Toolbox(QObject, Extension): def __init__(self, application: CuraApplication) -> None: From f79949a150de9714e1d1769e29940c50f8139c7d Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 10 Jan 2020 08:57:02 +0100 Subject: [PATCH 10/68] Use scope instead of manual request header updates in Toolbox.py CURA-6983 --- plugins/Toolbox/src/Toolbox.py | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index c2d21b349c..1375ace29c 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -24,6 +24,7 @@ from plugins.Toolbox.src.CloudApiModel import CloudApiModel from .AuthorsModel import AuthorsModel from .PackagesModel import PackagesModel from .CloudSync.SubscribedPackagesModel import SubscribedPackagesModel +from .UltimakerCloudScope import UltimakerCloudScope if TYPE_CHECKING: from UM.TaskManagement.HttpRequestData import HttpRequestData @@ -46,8 +47,7 @@ class Toolbox(QObject, Extension): self._download_request_data = None # type: Optional[HttpRequestData] self._download_progress = 0 # type: float self._is_downloading = False # type: bool - self._request_headers = dict() # type: Dict[str, str] # todo DRY headers, use scope - self._updateRequestHeader() + self._scope = UltimakerCloudScope(application) self._request_urls = {} # type: Dict[str, str] self._to_update = [] # type: List[str] # Package_ids that are waiting to be updated @@ -105,7 +105,6 @@ class Toolbox(QObject, Extension): self._restart_dialog_message = "" # type: str self._application.initializationFinished.connect(self._onAppInitialized) - self._application.getCuraAPI().account.accessTokenChanged.connect(self._updateRequestHeader) # Signals: # -------------------------------------------------------------------------- @@ -127,7 +126,6 @@ class Toolbox(QObject, Extension): ## Go back to the start state (welcome screen or loading if no login required) def _restart(self): - self._updateRequestHeader() # For an Essentials build, login is mandatory if not self._application.getCuraAPI().account.isLoggedIn and ApplicationMetadata.IsEnterpriseVersion: self.setViewPage("welcome") @@ -135,17 +133,6 @@ class Toolbox(QObject, Extension): self.setViewPage("loading") self._fetchPackageData() - def _updateRequestHeader(self): - self._request_headers = { - "User-Agent": "%s/%s (%s %s)" % (self._application.getApplicationName(), - self._application.getVersion(), - platform.system(), - platform.machine()) - } - access_token = self._application.getCuraAPI().account.accessToken - if access_token: - self._request_headers["Authorization"] = "Bearer {}".format(access_token) - def _resetUninstallVariables(self) -> None: self._package_id_to_uninstall = None # type: Optional[str] self._package_name_to_uninstall = "" @@ -157,8 +144,8 @@ class Toolbox(QObject, Extension): url = "{base_url}/packages/{package_id}/ratings".format(base_url = CloudApiModel.api_url, package_id = package_id) data = "{\"data\": {\"cura_version\": \"%s\", \"rating\": %i}}" % (Version(self._application.getVersion()), rating) - self._application.getHttpRequestManager().put(url, headers_dict = self._request_headers, - data = data.encode()) + self._application.getHttpRequestManager().put(url, data = data.encode(), scope = self._scope) + @pyqtSlot(str) def subscribe(self, package_id: str) -> None: if self._application.getCuraAPI().account.isLoggedIn: @@ -538,15 +525,14 @@ class Toolbox(QObject, Extension): # -------------------------------------------------------------------------- def _makeRequestByType(self, request_type: str) -> None: Logger.log("d", "Requesting [%s] metadata from server.", request_type) - self._updateRequestHeader() url = self._request_urls[request_type] callback = lambda r, rt = request_type: self._onDataRequestFinished(rt, r) error_callback = lambda r, e, rt = request_type: self._onDataRequestError(rt, r, e) self._application.getHttpRequestManager().get(url, - headers_dict = self._request_headers, callback = callback, - error_callback = error_callback) + error_callback = error_callback, + scope=self._scope) @pyqtSlot(str) def startDownload(self, url: str) -> None: @@ -555,10 +541,12 @@ class Toolbox(QObject, Extension): callback = lambda r: self._onDownloadFinished(r) error_callback = lambda r, e: self._onDownloadFailed(r, e) download_progress_callback = self._onDownloadProgress - request_data = self._application.getHttpRequestManager().get(url, headers_dict = self._request_headers, + request_data = self._application.getHttpRequestManager().get(url, callback = callback, error_callback = error_callback, - download_progress_callback = download_progress_callback) + download_progress_callback = download_progress_callback, + scope=self._scope + ) self._download_request_data = request_data self.setDownloadProgress(0) From 317061029c6f5bfb12ff1baa551d68ab83f7f51b Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 10 Jan 2020 11:18:50 +0100 Subject: [PATCH 11/68] Use more high-level temporary file api CURA-6983 --- plugins/Toolbox/src/CloudSync/DownloadPresenter.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py index b70eac98df..492ccdf186 100644 --- a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py @@ -83,17 +83,14 @@ class DownloadPresenter: def _onFinished(self, package_id: str, reply: QNetworkReply): self._progress[package_id]["received"] = self._progress[package_id]["total"] - file_fd, file_path = tempfile.mkstemp() - os.close(file_fd) # close the file so we can open it from python - try: - with open(file_path, "wb+") as temp_file: + with tempfile.NamedTemporaryFile(mode ="wb+", suffix =".curapackage", delete = False) as temp_file: bytes_read = reply.read(256 * 1024) while bytes_read: temp_file.write(bytes_read) bytes_read = reply.read(256 * 1024) self._app.processEvents() - self._progress[package_id]["file_written"] = file_path + self._progress[package_id]["file_written"] = temp_file.name except IOError as e: Logger.logException("e", "Failed to write downloaded package to temp file", e) self._onError(package_id) From 88d210d12d715f972cc1d21590d09c9c1721164c Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 10 Jan 2020 11:40:57 +0100 Subject: [PATCH 12/68] Fix The toolbox license dialog CURA-6983 --- plugins/Toolbox/resources/qml/Toolbox.qml | 11 ++---- .../qml/dialogs/ToolboxLicenseDialog.qml | 3 -- .../src/CloudSync/CloudPackageChecker.py | 9 +++-- plugins/Toolbox/src/CloudSync/LicenseModel.py | 2 +- .../Toolbox/src/CloudSync/LicensePresenter.py | 2 +- .../Toolbox/src/CloudSync/SyncOrchestrator.py | 2 +- plugins/Toolbox/src/Toolbox.py | 38 ++++++++++++------- 7 files changed, 35 insertions(+), 32 deletions(-) diff --git a/plugins/Toolbox/resources/qml/Toolbox.qml b/plugins/Toolbox/resources/qml/Toolbox.qml index d6d862b5f6..bb487e86b1 100644 --- a/plugins/Toolbox/resources/qml/Toolbox.qml +++ b/plugins/Toolbox/resources/qml/Toolbox.qml @@ -96,17 +96,12 @@ Window visible: toolbox.restartRequired height: visible ? UM.Theme.getSize("toolbox_footer").height : 0 } - // TODO: Clean this up: + Connections { target: toolbox - onShowLicenseDialog: - { - licenseDialog.pluginName = toolbox.getLicenseDialogPluginName(); - licenseDialog.licenseContent = toolbox.getLicenseDialogLicenseContent(); - licenseDialog.pluginFileLocation = toolbox.getLicenseDialogPluginFileLocation(); - licenseDialog.show(); - } + onShowLicenseDialog: { licenseDialog.show() } + onCloseLicenseDialog: { licenseDialog.close() } } ToolboxLicenseDialog diff --git a/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml b/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml index 4887ce922a..2c88ac6d5f 100644 --- a/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml @@ -19,9 +19,6 @@ UM.Dialog minimumHeight: UM.Theme.getSize("license_window_minimum").height width: minimumWidth height: minimumHeight - property var pluginName; - property var licenseContent; - property var pluginFileLocation; Item { diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index b81d4a579e..cc5654437e 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -4,6 +4,7 @@ from typing import Optional from PyQt5.QtCore import QObject from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest +from UM import i18nCatalog from UM.Logger import Logger from UM.Message import Message from UM.Signal import Signal @@ -11,7 +12,6 @@ from plugins.Toolbox.src.UltimakerCloudScope import UltimakerCloudScope from cura.CuraApplication import CuraApplication from plugins.Toolbox.src.CloudApiModel import CloudApiModel from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel -from plugins.Toolbox.src.Toolbox import i18n_catalog class CloudPackageChecker(QObject): @@ -25,6 +25,7 @@ class CloudPackageChecker(QObject): self._model = SubscribedPackagesModel() self._application.initializationFinished.connect(self._onAppInitialized) + self._i18n_catalog = i18nCatalog("cura") # This is a plugin, so most of the components required are not ready when # this is initialized. Therefore, we wait until the application is ready. @@ -56,13 +57,13 @@ class CloudPackageChecker(QObject): def _handlePackageDiscrepancies(self): Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") - sync_message = Message(i18n_catalog.i18nc( + sync_message = Message(self._i18n_catalog.i18nc( "@info:generic", "\nDo you want to sync material and software packages with your account?"), lifetime=0, - title=i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) + title=self._i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) sync_message.addAction("sync", - name=i18n_catalog.i18nc("@action:button", "Sync"), + name=self._i18n_catalog.i18nc("@action:button", "Sync"), icon="", description="Sync your Cloud subscribed packages to your local environment.", button_align=Message.ActionButtonAlignment.ALIGN_RIGHT) diff --git a/plugins/Toolbox/src/CloudSync/LicenseModel.py b/plugins/Toolbox/src/CloudSync/LicenseModel.py index 70a1264e0a..1328383d76 100644 --- a/plugins/Toolbox/src/CloudSync/LicenseModel.py +++ b/plugins/Toolbox/src/CloudSync/LicenseModel.py @@ -40,7 +40,7 @@ class LicenseModel(QObject): self._license_text = license_text self.licenseTextChanged.emit() - def setCurrentPageNumber(self, idx: int) -> None: + def setCurrentPageIdx(self, idx: int) -> None: self._current_page_idx = idx self._updateDialogTitle() diff --git a/plugins/Toolbox/src/CloudSync/LicensePresenter.py b/plugins/Toolbox/src/CloudSync/LicensePresenter.py index bd991a665a..57b9bc5a43 100644 --- a/plugins/Toolbox/src/CloudSync/LicensePresenter.py +++ b/plugins/Toolbox/src/CloudSync/LicensePresenter.py @@ -78,7 +78,7 @@ class LicensePresenter(QObject): self.onLicenseAccepted() return - self._license_model.setCurrentPageNumber(self._current_package_idx) + self._license_model.setCurrentPageIdx(self._current_package_idx) self._license_model.setPackageName(package_model["package_id"]) self._license_model.setLicenseText(license_content) diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index ebbc71d0e0..952f3eeb54 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -4,7 +4,7 @@ from UM.Extension import Extension from UM.Logger import Logger from UM.PluginRegistry import PluginRegistry from cura.CuraApplication import CuraApplication -from plugins.Toolbox import CloudPackageChecker +from plugins.Toolbox.src.CloudSync.CloudPackageChecker import CloudPackageChecker from plugins.Toolbox.src.CloudSync.DiscrepanciesPresenter import DiscrepanciesPresenter from plugins.Toolbox.src.CloudSync.DownloadPresenter import DownloadPresenter from plugins.Toolbox.src.CloudSync.LicensePresenter import LicensePresenter diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 1375ace29c..45ea8d816a 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -22,6 +22,7 @@ from cura.Machines.ContainerTree import ContainerTree from plugins.Toolbox.src.CloudApiModel import CloudApiModel from .AuthorsModel import AuthorsModel +from .CloudSync.LicenseModel import LicenseModel from .PackagesModel import PackagesModel from .CloudSync.SubscribedPackagesModel import SubscribedPackagesModel from .UltimakerCloudScope import UltimakerCloudScope @@ -77,6 +78,8 @@ class Toolbox(QObject, Extension): self._materials_installed_model = PackagesModel(self) self._materials_generic_model = PackagesModel(self) + self._license_model = LicenseModel() + # These properties are for keeping track of the UI state: # ---------------------------------------------------------------------- # View category defines which filter to use, and therefore effectively @@ -99,8 +102,6 @@ class Toolbox(QObject, Extension): self._restart_required = False # type: bool # variables for the license agreement dialog - self._license_dialog_plugin_name = "" # type: str - self._license_dialog_license_content = "" # type: str self._license_dialog_plugin_file_location = "" # type: str self._restart_dialog_message = "" # type: str @@ -122,6 +123,7 @@ class Toolbox(QObject, Extension): filterChanged = pyqtSignal() metadataChanged = pyqtSignal() showLicenseDialog = pyqtSignal() + closeLicenseDialog = pyqtSignal() uninstallVariablesChanged = pyqtSignal() ## Go back to the start state (welcome screen or loading if no login required) @@ -155,21 +157,16 @@ class Toolbox(QObject, Extension): data=data.encode() ) - @pyqtSlot(result = str) - def getLicenseDialogPluginName(self) -> str: - return self._license_dialog_plugin_name - - @pyqtSlot(result = str) def getLicenseDialogPluginFileLocation(self) -> str: return self._license_dialog_plugin_file_location - @pyqtSlot(result = str) - def getLicenseDialogLicenseContent(self) -> str: - return self._license_dialog_license_content - def openLicenseDialog(self, plugin_name: str, license_content: str, plugin_file_location: str) -> None: - self._license_dialog_plugin_name = plugin_name - self._license_dialog_license_content = license_content + # Set page 1/1 when opening the dialog for a single package + self._license_model.setCurrentPageIdx(0) + self._license_model.setPageCount(1) + + self._license_model.setPackageName(plugin_name) + self._license_model.setLicenseText(license_content) self._license_dialog_plugin_file_location = plugin_file_location self.showLicenseDialog.emit() @@ -227,7 +224,11 @@ class Toolbox(QObject, Extension): return None path = os.path.join(plugin_path, "resources", "qml", qml_name) - dialog = self._application.createQmlComponent(path, {"toolbox": self}) + dialog = self._application.createQmlComponent(path, { + "toolbox": self, + "handler": self, + "licenseModel": self._license_model + }) if not dialog: raise Exception("Failed to create Marketplace dialog") return dialog @@ -376,6 +377,15 @@ class Toolbox(QObject, Extension): self._resetUninstallVariables() self.closeConfirmResetDialog() + @pyqtSlot() + def onLicenseAccepted(self): + self.closeLicenseDialog.emit() + self.install(self.getLicenseDialogPluginFileLocation()) + + @pyqtSlot() + def onLicenseDeclined(self): + self.closeLicenseDialog.emit() + def _markPackageMaterialsAsToBeUninstalled(self, package_id: str) -> None: container_registry = self._application.getContainerRegistry() From 1cf3cd8228c9eb10e023a3f10cbdbabbd52a7d5e Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 10 Jan 2020 13:17:48 +0100 Subject: [PATCH 13/68] Fix Subscribing to a package CURA-6983 --- plugins/Toolbox/src/Toolbox.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 45ea8d816a..681dd7ba72 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -152,9 +152,9 @@ class Toolbox(QObject, Extension): def subscribe(self, package_id: str) -> None: if self._application.getCuraAPI().account.isLoggedIn: data = "{\"data\": {\"package_id\": \"%s\", \"sdk_version\": \"%s\"}}" % (package_id, self._sdk_version) - self._application.getHttpRequestManager().put(url=self._api_url_user_packages, - headers_dict=self._request_headers, - data=data.encode() + self._application.getHttpRequestManager().put(url=CloudApiModel.api_url_user_packages, + data=data.encode(), + scope=self._scope ) def getLicenseDialogPluginFileLocation(self) -> str: @@ -299,13 +299,14 @@ class Toolbox(QObject, Extension): self.metadataChanged.emit() @pyqtSlot(str) - def install(self, file_path: str) -> None: - self._package_manager.installPackage(file_path) + def install(self, file_path: str) -> Optional[str]: + package_id = self._package_manager.installPackage(file_path) self.installChanged.emit() self._updateInstalledModels() self.metadataChanged.emit() self._restart_required = True self.restartRequiredChanged.emit() + return package_id ## Check package usage and uninstall # If the package is in use, you'll get a confirmation dialog to set everything to default @@ -380,7 +381,9 @@ class Toolbox(QObject, Extension): @pyqtSlot() def onLicenseAccepted(self): self.closeLicenseDialog.emit() - self.install(self.getLicenseDialogPluginFileLocation()) + package_id = self.install(self.getLicenseDialogPluginFileLocation()) + self.subscribe(package_id) + @pyqtSlot() def onLicenseDeclined(self): @@ -677,8 +680,10 @@ class Toolbox(QObject, Extension): self.openLicenseDialog(package_info["package_id"], license_content, file_path) return - self.install(file_path) - self.subscribe(package_info["package_id"]) + package_id = self.install(file_path) + if package_id != package_info["package_id"]: + Logger.error("Installed package {} does not match {}".format(package_id, package_info["package_id"])) + self.subscribe(package_id) # Getter & Setters for Properties: # -------------------------------------------------------------------------- From 35695e5ab65afb0feac1d9888d824d656e5af52a Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 10 Jan 2020 14:20:58 +0100 Subject: [PATCH 14/68] Install and cloud-subscribe a package when after agreeing to the license CURA-6983 --- .../src/CloudSync/CloudPackageManager.py | 19 +++++++++++++ .../Toolbox/src/CloudSync/LicensePresenter.py | 2 +- .../Toolbox/src/CloudSync/SyncOrchestrator.py | 27 +++++++++++++++++-- plugins/Toolbox/src/Toolbox.py | 7 +---- plugins/Toolbox/src/UltimakerCloudScope.py | 5 ++++ 5 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 plugins/Toolbox/src/CloudSync/CloudPackageManager.py diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageManager.py b/plugins/Toolbox/src/CloudSync/CloudPackageManager.py new file mode 100644 index 0000000000..a724aa316d --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/CloudPackageManager.py @@ -0,0 +1,19 @@ +from cura.CuraApplication import CuraApplication +from plugins.Toolbox.src.CloudApiModel import CloudApiModel +from plugins.Toolbox.src.UltimakerCloudScope import UltimakerCloudScope + + +## Manages Cloud subscriptions. When a package is added to a user's account, the user is 'subscribed' to that package +# Whenever the user logs in on another instance of Cura, these subscriptions can be used to sync the user's plugins +class CloudPackageManager: + + def __init__(self, app: CuraApplication): + self._request_manager = app.getHttpRequestManager() + self._scope = UltimakerCloudScope(app) + + def subscribe(self, package_id: str): + data = "{\"data\": {\"package_id\": \"%s\", \"sdk_version\": \"%s\"}}" % (package_id, CloudApiModel.sdk_version) + self._request_manager.put(url=CloudApiModel.api_url_user_packages, + data=data.encode(), + scope=self._scope + ) diff --git a/plugins/Toolbox/src/CloudSync/LicensePresenter.py b/plugins/Toolbox/src/CloudSync/LicensePresenter.py index 57b9bc5a43..9e77002aa1 100644 --- a/plugins/Toolbox/src/CloudSync/LicensePresenter.py +++ b/plugins/Toolbox/src/CloudSync/LicensePresenter.py @@ -19,7 +19,7 @@ class LicensePresenter(QObject): super().__init__() self._dialog = None # type: Optional[QObject] self._package_manager = app.getPackageManager() # type: PackageManager - # Emits List[Dict[str, str]] containing for example + # Emits List[Dict[str, [Any]] containing for example # [{ "package_id": "BarbarianPlugin", "package_path" : "/tmp/dg345as", "accepted" : True }] self.licenseAnswers = Signal() diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index 952f3eeb54..507de45f55 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -1,10 +1,13 @@ -from typing import List, Dict +import os +from typing import List, Dict, Any from UM.Extension import Extension from UM.Logger import Logger +from UM.PackageManager import PackageManager from UM.PluginRegistry import PluginRegistry from cura.CuraApplication import CuraApplication from plugins.Toolbox.src.CloudSync.CloudPackageChecker import CloudPackageChecker +from plugins.Toolbox.src.CloudSync.CloudPackageManager import CloudPackageManager from plugins.Toolbox.src.CloudSync.DiscrepanciesPresenter import DiscrepanciesPresenter from plugins.Toolbox.src.CloudSync.DownloadPresenter import DownloadPresenter from plugins.Toolbox.src.CloudSync.LicensePresenter import LicensePresenter @@ -32,6 +35,9 @@ class SyncOrchestrator(Extension): # getPluginId() will return the same value for The toolbox extension and this one self._name = "SyncOrchestrator" + self._package_manager = app.getPackageManager() + self._cloud_package_manager = CloudPackageManager(app) + self._checker = CloudPackageChecker(app) # type: CloudPackageChecker self._checker.discrepancies.connect(self._onDiscrepancies) @@ -61,7 +67,24 @@ class SyncOrchestrator(Extension): self._licensePresenter.present(plugin_path, success_items) # Called when user has accepted / declined all licenses for the downloaded packages - def _onLicenseAnswers(self, answers: Dict[str, bool]): + def _onLicenseAnswers(self, answers: [Dict[str, Any]]): Logger.debug("Got license answers: {}", answers) + for item in answers: + if item["accepted"]: + # install and subscribe packages + if not self._package_manager.installPackage(item["package_path"]): + Logger.error("could not install {}".format(item["package_id"])) + continue + self._cloud_package_manager.subscribe(item["package_id"]) + else: + # todo unsubscribe declined packages + pass + + # delete temp file + os.remove(item["package_path"]) + + + + diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 681dd7ba72..e3903d0bfd 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -150,12 +150,7 @@ class Toolbox(QObject, Extension): @pyqtSlot(str) def subscribe(self, package_id: str) -> None: - if self._application.getCuraAPI().account.isLoggedIn: - data = "{\"data\": {\"package_id\": \"%s\", \"sdk_version\": \"%s\"}}" % (package_id, self._sdk_version) - self._application.getHttpRequestManager().put(url=CloudApiModel.api_url_user_packages, - data=data.encode(), - scope=self._scope - ) + self._cloud_package_manager.subscribe(package_id) def getLicenseDialogPluginFileLocation(self) -> str: return self._license_dialog_plugin_file_location diff --git a/plugins/Toolbox/src/UltimakerCloudScope.py b/plugins/Toolbox/src/UltimakerCloudScope.py index b5f2983ee8..f7707957e6 100644 --- a/plugins/Toolbox/src/UltimakerCloudScope.py +++ b/plugins/Toolbox/src/UltimakerCloudScope.py @@ -1,5 +1,6 @@ from PyQt5.QtNetwork import QNetworkRequest +from UM.Logger import Logger from UM.TaskManagement.HttpRequestScope import DefaultUserAgentScope from cura.API import Account from cura.CuraApplication import CuraApplication @@ -14,6 +15,10 @@ class UltimakerCloudScope(DefaultUserAgentScope): def request_hook(self, request: QNetworkRequest): super().request_hook(request) token = self._account.accessToken + if not self._account.isLoggedIn or token is None: + Logger.warning("Cannot add authorization to Cloud Api request") + return + header_dict = { "Authorization": "Bearer {}".format(token) } From f15e21805c65d351c74e945ece1b9c31f93e08c1 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 10 Jan 2020 14:29:21 +0100 Subject: [PATCH 15/68] Remove unused member CURA-6983 --- plugins/Toolbox/src/Toolbox.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index e3903d0bfd..54fe80e23c 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -103,7 +103,6 @@ class Toolbox(QObject, Extension): # variables for the license agreement dialog self._license_dialog_plugin_file_location = "" # type: str - self._restart_dialog_message = "" # type: str self._application.initializationFinished.connect(self._onAppInitialized) From 2da8040e5afc7201d0130dc3387229c39acd5fef Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 10 Jan 2020 15:49:18 +0100 Subject: [PATCH 16/68] Show a restart dialog at the end of the cloud sync flow CURA-6983 --- .../CloudSync/RestartApplicationPresenter.py | 49 +++++++++++++++++++ .../Toolbox/src/CloudSync/SyncOrchestrator.py | 27 +++++----- 2 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py diff --git a/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py b/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py new file mode 100644 index 0000000000..e0d26cbf7a --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py @@ -0,0 +1,49 @@ +import os +import tempfile +from functools import reduce +from typing import Dict, List, Optional, Any + +from PyQt5.QtNetwork import QNetworkReply + +from UM import i18n_catalog, i18nCatalog +from UM.Logger import Logger +from UM.Message import Message +from UM.Signal import Signal +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from cura.CuraApplication import CuraApplication +from plugins.Toolbox.src.UltimakerCloudScope import UltimakerCloudScope +from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel + + +## Presents a dialog telling the user that a restart is required to apply changes +# Since we cannot restart Cura, the app is closed instead when the button is clicked +class RestartApplicationPresenter: + + def __init__(self, app: CuraApplication): + # Emits (Dict[str, str], List[str]) # (success_items, error_items) + # Dict{success_package_id, temp_file_path} + # List[errored_package_id] + self.done = Signal() + + self._app = app + self._i18n_catalog = i18nCatalog("cura") + + def present(self): + app_name = self._app.getApplicationDisplayName() + + message = Message(title=self._i18n_catalog.i18nc( + "@info:generic", + "You need to quit and restart {} before changes have effect.", app_name + )) + + message.addAction("quit", + name="Quit " + app_name, + icon = "", + description="Close the application", + button_align=Message.ActionButtonAlignment.ALIGN_RIGHT) + + message.actionTriggered.connect(self._quitClicked) + message.show() + + def _quitClicked(self, *_): + self._app.windowClosed() diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index 507de45f55..f740c6dff1 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -11,6 +11,7 @@ from plugins.Toolbox.src.CloudSync.CloudPackageManager import CloudPackageManage from plugins.Toolbox.src.CloudSync.DiscrepanciesPresenter import DiscrepanciesPresenter from plugins.Toolbox.src.CloudSync.DownloadPresenter import DownloadPresenter from plugins.Toolbox.src.CloudSync.LicensePresenter import LicensePresenter +from plugins.Toolbox.src.CloudSync.RestartApplicationPresenter import RestartApplicationPresenter from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel @@ -41,22 +42,24 @@ class SyncOrchestrator(Extension): self._checker = CloudPackageChecker(app) # type: CloudPackageChecker self._checker.discrepancies.connect(self._onDiscrepancies) - self._discrepanciesPresenter = DiscrepanciesPresenter(app) # type: DiscrepanciesPresenter - self._discrepanciesPresenter.packageMutations.connect(self._onPackageMutations) + self._discrepancies_presenter = DiscrepanciesPresenter(app) # type: DiscrepanciesPresenter + self._discrepancies_presenter.packageMutations.connect(self._onPackageMutations) - self._downloadPresenter = DownloadPresenter(app) # type: DownloadPresenter + self._download_Presenter = DownloadPresenter(app) # type: DownloadPresenter - self._licensePresenter = LicensePresenter(app) # type: LicensePresenter - self._licensePresenter.licenseAnswers.connect(self._onLicenseAnswers) + self._license_presenter = LicensePresenter(app) # type: LicensePresenter + self._license_presenter.licenseAnswers.connect(self._onLicenseAnswers) + + self._restart_presenter = RestartApplicationPresenter(app) def _onDiscrepancies(self, model: SubscribedPackagesModel): plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) - self._discrepanciesPresenter.present(plugin_path, model) + self._discrepancies_presenter.present(plugin_path, model) def _onPackageMutations(self, mutations: SubscribedPackagesModel): - self._downloadPresenter = self._downloadPresenter.resetCopy() - self._downloadPresenter.done.connect(self._onDownloadFinished) - self._downloadPresenter.download(mutations) + self._download_Presenter = self._download_Presenter.resetCopy() + self._download_Presenter.done.connect(self._onDownloadFinished) + self._download_Presenter.download(mutations) ## Called when a set of packages have finished downloading # \param success_items: Dict[package_id, file_path] @@ -64,7 +67,7 @@ class SyncOrchestrator(Extension): def _onDownloadFinished(self, success_items: Dict[str, str], error_items: List[str]): # todo handle error items plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) - self._licensePresenter.present(plugin_path, success_items) + self._license_presenter.present(plugin_path, success_items) # Called when user has accepted / declined all licenses for the downloaded packages def _onLicenseAnswers(self, answers: [Dict[str, Any]]): @@ -80,10 +83,12 @@ class SyncOrchestrator(Extension): else: # todo unsubscribe declined packages pass - # delete temp file os.remove(item["package_path"]) + self._restart_presenter.present() + + From c86cc3ae5a08df085d59caba949efce414b1c92d Mon Sep 17 00:00:00 2001 From: Dimitriovski Date: Fri, 10 Jan 2020 16:48:45 +0100 Subject: [PATCH 17/68] Added 'dismiss' link and logic for removing the item from the dialog CURA-7090 --- .../qml/dialogs/CompatibilityDialog.qml | 19 +++++++++++++- .../Toolbox/src/SubscribedPackagesModel.py | 26 +++++++++++++++++-- plugins/Toolbox/src/Toolbox.py | 5 ++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml index a6ce7fc865..93945871a3 100644 --- a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml @@ -104,7 +104,7 @@ UM.Dialog{ { width: parent.width property int lineHeight: 60 - visible: !model.is_compatible + visible: !model.is_compatible && !model.is_dismissed height: visible ? (lineHeight + UM.Theme.getSize("default_margin").height) : 0 // We only show the incompatible packages here Image { @@ -117,6 +117,7 @@ UM.Dialog{ } Label { + id: packageName text: model.name font: UM.Theme.getFont("medium_bold") anchors.left: packageIcon.right @@ -125,6 +126,22 @@ UM.Dialog{ color: UM.Theme.getColor("text") elide: Text.ElideRight } + + Label + { + id: dismissLabel + text: "(Dismiss)" + font: UM.Theme.getFont("small") + anchors.right: parent.right + anchors.verticalCenter: packageIcon.verticalCenter + color: UM.Theme.getColor("text") + + MouseArea + { + anchors.fill: parent + onClicked: toolbox.dismissIncompatiblePackage(model.package_id) + } + } } } } diff --git a/plugins/Toolbox/src/SubscribedPackagesModel.py b/plugins/Toolbox/src/SubscribedPackagesModel.py index cf0d07c153..118048eec2 100644 --- a/plugins/Toolbox/src/SubscribedPackagesModel.py +++ b/plugins/Toolbox/src/SubscribedPackagesModel.py @@ -5,6 +5,10 @@ from PyQt5.QtCore import Qt from UM.Qt.ListModel import ListModel from cura import ApplicationMetadata +from PyQt5.QtCore import pyqtSlot + +from UM.Logger import Logger + class SubscribedPackagesModel(ListModel): def __init__(self, parent = None): @@ -18,6 +22,9 @@ class SubscribedPackagesModel(ListModel): self.addRoleName(Qt.UserRole + 1, "name") self.addRoleName(Qt.UserRole + 2, "icon_url") self.addRoleName(Qt.UserRole + 3, "is_compatible") + self.addRoleName(Qt.UserRole + 4, "is_dismissed") + self.addRoleName(Qt.UserRole + 5, "package_id") + def setMetadata(self, data): if self._metadata != data: @@ -33,7 +40,11 @@ class SubscribedPackagesModel(ListModel): for item in self._metadata: if item["package_id"] not in self._discrepancies: continue - package = {"name": item["display_name"], "sdk_versions": item["sdk_versions"]} + package = {"package_id": item["package_id"], + "name": item["display_name"], + "sdk_versions": item["sdk_versions"], + "is_dismissed": False + } if self._sdk_version not in item["sdk_versions"]: package.update({"is_compatible": False}) else: @@ -44,8 +55,10 @@ class SubscribedPackagesModel(ListModel): package.update({"icon_url": ""}) self._items.append(package) + print("All items:: %s" % self._items) self.setItems(self._items) + def hasCompatiblePackages(self) -> bool: has_compatible_items = False for item in self._items: @@ -58,4 +71,13 @@ class SubscribedPackagesModel(ListModel): for item in self._items: if item['is_compatible'] == False: has_incompatible_items = True - return has_incompatible_items \ No newline at end of file + return has_incompatible_items + + @pyqtSlot(str) + def setDismiss(self, package_id) -> None: + package_id_in_list_of_items = self.find(key="package_id", value=package_id) + if package_id_in_list_of_items != -1: + self.setProperty(package_id_in_list_of_items, property="is_dismissed", value=True) + Logger.debug("Package {} has been dismissed".format(package_id)) + + # Now store this package_id as DISMISSED somewhere in local files \ No newline at end of file diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index f28178b99e..c77e148140 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -556,6 +556,11 @@ class Toolbox(QObject, Extension): populated += 1 return populated == len(self._server_response_data.items()) + @pyqtSlot(str) + def dismissIncompatiblePackage(self, package_id): + print("---in toolbox: %s" % package_id) + self._models["subscribed_packages"].setDismiss(package_id) + # Make API Calls # -------------------------------------------------------------------------- def _makeRequestByType(self, request_type: str) -> None: From 071326b890f92d8b16fb094a310f29ea03df9764 Mon Sep 17 00:00:00 2001 From: Dimitriovski Date: Sat, 11 Jan 2020 01:11:49 +0100 Subject: [PATCH 18/68] Added logic for dismissing packages from the Incompatible list CURA-7090 --- plugins/Toolbox/src/SubscribedPackagesModel.py | 8 +++++--- plugins/Toolbox/src/Toolbox.py | 11 ++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/plugins/Toolbox/src/SubscribedPackagesModel.py b/plugins/Toolbox/src/SubscribedPackagesModel.py index 118048eec2..d2ed486de2 100644 --- a/plugins/Toolbox/src/SubscribedPackagesModel.py +++ b/plugins/Toolbox/src/SubscribedPackagesModel.py @@ -55,7 +55,6 @@ class SubscribedPackagesModel(ListModel): package.update({"icon_url": ""}) self._items.append(package) - print("All items:: %s" % self._items) self.setItems(self._items) @@ -73,11 +72,14 @@ class SubscribedPackagesModel(ListModel): has_incompatible_items = True return has_incompatible_items - @pyqtSlot(str) def setDismiss(self, package_id) -> None: package_id_in_list_of_items = self.find(key="package_id", value=package_id) if package_id_in_list_of_items != -1: self.setProperty(package_id_in_list_of_items, property="is_dismissed", value=True) Logger.debug("Package {} has been dismissed".format(package_id)) - # Now store this package_id as DISMISSED somewhere in local files \ No newline at end of file + def addDismissed(self, list_of_dismissed) -> None: + for package in list_of_dismissed: + item = self.find(key="package_id", value=package) + if item != -1: + self.setProperty(item, property="is_dismissed", value=True) diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index c77e148140..f12cc3ba3d 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -558,8 +558,8 @@ class Toolbox(QObject, Extension): @pyqtSlot(str) def dismissIncompatiblePackage(self, package_id): - print("---in toolbox: %s" % package_id) self._models["subscribed_packages"].setDismiss(package_id) + self._package_manager.setAsDismissed(package_id) # Make API Calls # -------------------------------------------------------------------------- @@ -668,12 +668,17 @@ class Toolbox(QObject, Extension): def _checkCompatibilities(self, json_data) -> None: user_subscribed_packages = [plugin["package_id"] for plugin in json_data] user_installed_packages = self._package_manager.getUserInstalledPackages() + user_dismissed_packages = list(self._package_manager.getDismissedPackages()) + + user_installed_packages += user_dismissed_packages # We check if there are packages installed in Cloud Marketplace but not in Cura marketplace (discrepancy) package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages)) + if package_discrepancy: self._models["subscribed_packages"].addValue(package_discrepancy) self._models["subscribed_packages"].update() + self._models["subscribed_packages"].addDismissed(user_dismissed_packages) Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") sync_message = Message(i18n_catalog.i18nc( "@info:generic", @@ -802,11 +807,11 @@ class Toolbox(QObject, Extension): return cast(SubscribedPackagesModel, self._models["subscribed_packages"]) @pyqtProperty(bool, constant=True) - def has_compatible_packages(self) -> str: + def has_compatible_packages(self) -> bool: return self._models["subscribed_packages"].hasCompatiblePackages() @pyqtProperty(bool, constant=True) - def has_incompatible_packages(self) -> str: + def has_incompatible_packages(self) -> bool: return self._models["subscribed_packages"].hasIncompatiblePackages() @pyqtProperty(QObject, constant = True) From fbe38dc6587053f6cf45f8b0aaac1c234c3dcf71 Mon Sep 17 00:00:00 2001 From: Dimitriovski Date: Mon, 13 Jan 2020 09:55:18 +0100 Subject: [PATCH 19/68] added typing, refactored some functions CURA-7090 --- .../qml/dialogs/CompatibilityDialog.qml | 6 +-- .../Toolbox/src/SubscribedPackagesModel.py | 51 +++++++++---------- plugins/Toolbox/src/Toolbox.py | 22 ++++---- 3 files changed, 36 insertions(+), 43 deletions(-) diff --git a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml index 93945871a3..c2cc1ce4d6 100644 --- a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml @@ -74,7 +74,7 @@ UM.Dialog{ } Label { - text: model.name + text: model.display_name font: UM.Theme.getFont("medium_bold") anchors.left: packageIcon.right anchors.leftMargin: UM.Theme.getSize("default_margin").width @@ -117,8 +117,7 @@ UM.Dialog{ } Label { - id: packageName - text: model.name + text: model.display_name font: UM.Theme.getFont("medium_bold") anchors.left: packageIcon.right anchors.leftMargin: UM.Theme.getSize("default_margin").width @@ -129,7 +128,6 @@ UM.Dialog{ Label { - id: dismissLabel text: "(Dismiss)" font: UM.Theme.getFont("small") anchors.right: parent.right diff --git a/plugins/Toolbox/src/SubscribedPackagesModel.py b/plugins/Toolbox/src/SubscribedPackagesModel.py index d2ed486de2..acbf4c13f7 100644 --- a/plugins/Toolbox/src/SubscribedPackagesModel.py +++ b/plugins/Toolbox/src/SubscribedPackagesModel.py @@ -4,10 +4,8 @@ from PyQt5.QtCore import Qt from UM.Qt.ListModel import ListModel from cura import ApplicationMetadata - -from PyQt5.QtCore import pyqtSlot - from UM.Logger import Logger +from typing import List class SubscribedPackagesModel(ListModel): @@ -19,32 +17,30 @@ class SubscribedPackagesModel(ListModel): self._discrepancies = None self._sdk_version = ApplicationMetadata.CuraSDKVersion - self.addRoleName(Qt.UserRole + 1, "name") - self.addRoleName(Qt.UserRole + 2, "icon_url") - self.addRoleName(Qt.UserRole + 3, "is_compatible") - self.addRoleName(Qt.UserRole + 4, "is_dismissed") - self.addRoleName(Qt.UserRole + 5, "package_id") + self.addRoleName(Qt.UserRole + 1, "package_id") + self.addRoleName(Qt.UserRole + 2, "display_name") + self.addRoleName(Qt.UserRole + 3, "icon_url") + self.addRoleName(Qt.UserRole + 4, "is_compatible") + self.addRoleName(Qt.UserRole + 5, "is_dismissed") - - def setMetadata(self, data): + def setMetadata(self, data) -> None: if self._metadata != data: self._metadata = data - def addValue(self, discrepancy): + def addDiscrepancies(self, discrepancy: List[str]) -> None: if self._discrepancies != discrepancy: self._discrepancies = discrepancy - def update(self): + def initialize(self) -> None: self._items.clear() for item in self._metadata: if item["package_id"] not in self._discrepancies: continue - package = {"package_id": item["package_id"], - "name": item["display_name"], - "sdk_versions": item["sdk_versions"], - "is_dismissed": False - } + package = {"package_id": item["package_id"], + "display_name": item["display_name"], + "sdk_versions": item["sdk_versions"], + "is_dismissed": False} if self._sdk_version not in item["sdk_versions"]: package.update({"is_compatible": False}) else: @@ -57,7 +53,6 @@ class SubscribedPackagesModel(ListModel): self._items.append(package) self.setItems(self._items) - def hasCompatiblePackages(self) -> bool: has_compatible_items = False for item in self._items: @@ -72,14 +67,16 @@ class SubscribedPackagesModel(ListModel): has_incompatible_items = True return has_incompatible_items - def setDismiss(self, package_id) -> None: - package_id_in_list_of_items = self.find(key="package_id", value=package_id) - if package_id_in_list_of_items != -1: - self.setProperty(package_id_in_list_of_items, property="is_dismissed", value=True) + # Sets the "is_compatible" to True for the given package, in memory + def dismissPackage(self, package_id: str) -> None: + package = self.find(key="package_id", value=package_id) + if package != -1: + self.setProperty(package, property="is_dismissed", value=True) Logger.debug("Package {} has been dismissed".format(package_id)) - def addDismissed(self, list_of_dismissed) -> None: - for package in list_of_dismissed: - item = self.find(key="package_id", value=package) - if item != -1: - self.setProperty(item, property="is_dismissed", value=True) + # Reads the dismissed_packages from user config file and applies them so they won't be shown in the Compatibility Dialog + def applyDismissedPackages(self, dismissed_packages: List[str]) -> None: + for package in dismissed_packages: + exists = self.find(key="package_id", value=package) + if exists != -1: + self.setProperty(exists, property="is_dismissed", value=True) diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index f12cc3ba3d..eaaf8d94e9 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -557,9 +557,9 @@ class Toolbox(QObject, Extension): return populated == len(self._server_response_data.items()) @pyqtSlot(str) - def dismissIncompatiblePackage(self, package_id): - self._models["subscribed_packages"].setDismiss(package_id) - self._package_manager.setAsDismissed(package_id) + def dismissIncompatiblePackage(self, package_id: str): + self._models["subscribed_packages"].dismissPackage(package_id) # sets "is_compatible" to True, in-memory + self._package_manager.dismissPackage(package_id) # adds this package_id as dismissed in the user config file # Make API Calls # -------------------------------------------------------------------------- @@ -668,17 +668,15 @@ class Toolbox(QObject, Extension): def _checkCompatibilities(self, json_data) -> None: user_subscribed_packages = [plugin["package_id"] for plugin in json_data] user_installed_packages = self._package_manager.getUserInstalledPackages() - user_dismissed_packages = list(self._package_manager.getDismissedPackages()) - - user_installed_packages += user_dismissed_packages - - # We check if there are packages installed in Cloud Marketplace but not in Cura marketplace (discrepancy) + user_dismissed_packages = self._package_manager.getDismissedPackages() + if user_dismissed_packages: + user_installed_packages += user_dismissed_packages + # We check if there are packages installed in Cloud Marketplace but not in Cura marketplace package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages)) - if package_discrepancy: - self._models["subscribed_packages"].addValue(package_discrepancy) - self._models["subscribed_packages"].update() - self._models["subscribed_packages"].addDismissed(user_dismissed_packages) + self._models["subscribed_packages"].addDiscrepancies(package_discrepancy) + self._models["subscribed_packages"].initialize() + self._models["subscribed_packages"].applyDismissedPackages(user_dismissed_packages) Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") sync_message = Message(i18n_catalog.i18nc( "@info:generic", From 6aba835c1c66fb39fd421eb9fc3667ac1cd95951 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 13 Jan 2020 09:57:14 +0100 Subject: [PATCH 20/68] Only show restart dialog when packages were installed CURA-6983 --- plugins/Toolbox/src/CloudSync/SyncOrchestrator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index f740c6dff1..af9f1440e6 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -73,6 +73,8 @@ class SyncOrchestrator(Extension): def _onLicenseAnswers(self, answers: [Dict[str, Any]]): Logger.debug("Got license answers: {}", answers) + has_changes = False # True when at least one package is installed + for item in answers: if item["accepted"]: # install and subscribe packages @@ -80,13 +82,15 @@ class SyncOrchestrator(Extension): Logger.error("could not install {}".format(item["package_id"])) continue self._cloud_package_manager.subscribe(item["package_id"]) + has_changes = True else: # todo unsubscribe declined packages pass # delete temp file os.remove(item["package_path"]) - self._restart_presenter.present() + if has_changes: + self._restart_presenter.present() From 53115dc3b20986c8ba12cd24328563e6b822fe8e Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 13 Jan 2020 09:57:35 +0100 Subject: [PATCH 21/68] Fix Mypy warnings CURA-6983 --- plugins/Toolbox/src/CloudApiModel.py | 2 ++ plugins/Toolbox/src/CloudSync/DownloadPresenter.py | 12 ++++++------ plugins/Toolbox/src/CloudSync/LicensePresenter.py | 4 ++-- plugins/Toolbox/src/CloudSync/SyncOrchestrator.py | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/plugins/Toolbox/src/CloudApiModel.py b/plugins/Toolbox/src/CloudApiModel.py index 082a28a24c..31c3139262 100644 --- a/plugins/Toolbox/src/CloudApiModel.py +++ b/plugins/Toolbox/src/CloudApiModel.py @@ -1,3 +1,5 @@ +from typing import Union + from cura import ApplicationMetadata, UltimakerCloudAuthentication diff --git a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py index 492ccdf186..dcb74ca41f 100644 --- a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py @@ -29,7 +29,7 @@ class DownloadPresenter: self._scope = UltimakerCloudScope(app) self._started = False - self._progress_message = None # type: Optional[Message] + self._progress_message = self._createProgressMessage() self._progress = {} # type: Dict[str, Dict[str, Any]] # package_id, Dict self._error = [] # type: List[str] # package_id @@ -57,7 +57,7 @@ class DownloadPresenter: } self._started = True - self._showProgressMessage() + self._progress_message.show() def abort(self): manager = HttpRequestManager.getInstance() @@ -70,15 +70,14 @@ class DownloadPresenter: self.done.disconnectAll() return DownloadPresenter(self._app) - def _showProgressMessage(self): - self._progress_message = Message(i18n_catalog.i18nc( + def _createProgressMessage(self) -> Message: + return Message(i18n_catalog.i18nc( "@info:generic", "\nSyncing..."), lifetime = 0, use_inactivity_timer=False, progress = 0.0, title = i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) - self._progress_message.show() def _onFinished(self, package_id: str, reply: QNetworkReply): self._progress[package_id]["received"] = self._progress[package_id]["total"] @@ -110,7 +109,7 @@ class DownloadPresenter: self._progress_message.setProgress(100.0 * (received / total)) # [0 .. 100] % - def _onError(self, package_id: str): + def _onError(self, package_id: str) -> None: self._progress.pop(package_id) self._error.append(package_id) self._checkDone() @@ -125,3 +124,4 @@ class DownloadPresenter: self._progress_message.hide() self.done.emit(success_items, error_items) + return True diff --git a/plugins/Toolbox/src/CloudSync/LicensePresenter.py b/plugins/Toolbox/src/CloudSync/LicensePresenter.py index 9e77002aa1..f05feecabd 100644 --- a/plugins/Toolbox/src/CloudSync/LicensePresenter.py +++ b/plugins/Toolbox/src/CloudSync/LicensePresenter.py @@ -1,5 +1,5 @@ import os -from typing import Dict, Optional +from typing import Dict, Optional, List from PyQt5.QtCore import QObject, pyqtSlot @@ -24,7 +24,7 @@ class LicensePresenter(QObject): self.licenseAnswers = Signal() self._current_package_idx = 0 - self._package_models = None # type: Optional[Dict] + self._package_models = [] # type: List[Dict] self._license_model = LicenseModel() # type: LicenseModel self._app = app diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index af9f1440e6..a207523067 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -70,7 +70,7 @@ class SyncOrchestrator(Extension): self._license_presenter.present(plugin_path, success_items) # Called when user has accepted / declined all licenses for the downloaded packages - def _onLicenseAnswers(self, answers: [Dict[str, Any]]): + def _onLicenseAnswers(self, answers: List[Dict[str, Any]]): Logger.debug("Got license answers: {}", answers) has_changes = False # True when at least one package is installed From 071cf5517e759cdd41583587323065b05f64d9eb Mon Sep 17 00:00:00 2001 From: Dimitriovski Date: Mon, 13 Jan 2020 10:37:40 +0100 Subject: [PATCH 22/68] linting CURA-7090 --- plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml index c2cc1ce4d6..bd858b4fd9 100644 --- a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml @@ -125,7 +125,6 @@ UM.Dialog{ color: UM.Theme.getColor("text") elide: Text.ElideRight } - Label { text: "(Dismiss)" @@ -133,7 +132,6 @@ UM.Dialog{ anchors.right: parent.right anchors.verticalCenter: packageIcon.verticalCenter color: UM.Theme.getColor("text") - MouseArea { anchors.fill: parent From 7359492e115855a4fa3e943d91fe888c06a7727e Mon Sep 17 00:00:00 2001 From: Dimitriovski Date: Mon, 13 Jan 2020 11:44:23 +0100 Subject: [PATCH 23/68] Added a Tooltip over the Dismiss button. Changed the cursor on hover. --- .../qml/dialogs/CompatibilityDialog.qml | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml index bd858b4fd9..41020f2415 100644 --- a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml @@ -125,17 +125,24 @@ UM.Dialog{ color: UM.Theme.getColor("text") elide: Text.ElideRight } - Label + UM.TooltipArea { - text: "(Dismiss)" - font: UM.Theme.getFont("small") + width: childrenRect.width; + height: childrenRect.height; + text: catalog.i18nc("@info:tooltip", "Dismisses the package and won't be shown in this dialog anymore") anchors.right: parent.right anchors.verticalCenter: packageIcon.verticalCenter - color: UM.Theme.getColor("text") - MouseArea + Label { - anchors.fill: parent - onClicked: toolbox.dismissIncompatiblePackage(model.package_id) + text: "(Dismiss)" + font: UM.Theme.getFont("small") + color: UM.Theme.getColor("text") + MouseArea + { + cursorShape: Qt.PointingHandCursor + anchors.fill: parent + onClicked: toolbox.dismissIncompatiblePackage(model.package_id) + } } } } From 122b6318feb590ee95a6da9f596ff38ab540fd86 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 13 Jan 2020 12:32:46 +0100 Subject: [PATCH 24/68] Remove dash if print job name is empty Otherwise it shows ' - Ultimaker Cura' here which looks a bit weird. --- resources/qml/Cura.qml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/resources/qml/Cura.qml b/resources/qml/Cura.qml index 8dcf60018f..326edb6e97 100644 --- a/resources/qml/Cura.qml +++ b/resources/qml/Cura.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2019 Ultimaker B.V. +// Copyright (c) 2020 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.7 @@ -21,7 +21,16 @@ UM.MainWindow id: base // Cura application window title - title: PrintInformation.jobName + " - " + catalog.i18nc("@title:window", CuraApplication.applicationDisplayName) + title: + { + let result = ""; + if(PrintInformation.jobName != "") + { + result += PrintInformation.jobName + " - "; + } + result += CuraApplication.applicationDisplayName; + return result; + } backgroundColor: UM.Theme.getColor("viewport_background") From 601a765f739341696f7c23b547da2a690aa87ee4 Mon Sep 17 00:00:00 2001 From: Kostas Karmas Date: Mon, 13 Jan 2020 13:44:36 +0100 Subject: [PATCH 25/68] Fix typo in comment --- cura/Machines/Models/MaterialBrandsModel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/Machines/Models/MaterialBrandsModel.py b/cura/Machines/Models/MaterialBrandsModel.py index 184d27f390..b0594cb286 100644 --- a/cura/Machines/Models/MaterialBrandsModel.py +++ b/cura/Machines/Models/MaterialBrandsModel.py @@ -34,7 +34,7 @@ class MaterialBrandsModel(BaseMaterialsModel): brand_item_list = [] brand_group_dict = {} - # Part 1: Generate the entire tree of brands -> material types -> spcific materials + # Part 1: Generate the entire tree of brands -> material types -> specific materials for root_material_id, container_node in self._available_materials.items(): # Do not include the materials from a to-be-removed package if bool(container_node.getMetaDataEntry("removed", False)): From d5cfca4df00f71269d1b6107050f0c763134e207 Mon Sep 17 00:00:00 2001 From: Kostas Karmas Date: Mon, 13 Jan 2020 13:44:51 +0100 Subject: [PATCH 26/68] Fix loading machine specific materials The container registry was incorrectly being searched with a variant_name == None, which always returned an empty printer-specific materials list. As a result, the generic material settings were always being loaded if there was no variant specifically indicated inside the fdm_material file. The printer specific values were consistently being ignored. This commit fixes that by removing the search with a variant_name==None which correctly returns the printer-specific materials list while loading the materials from the variant nodes. CURA-7087 --- cura/Machines/VariantNode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/Machines/VariantNode.py b/cura/Machines/VariantNode.py index 550b5881a3..0f30782a91 100644 --- a/cura/Machines/VariantNode.py +++ b/cura/Machines/VariantNode.py @@ -51,7 +51,7 @@ class VariantNode(ContainerNode): # Find all the materials for this variant's name. else: # Printer has its own material profiles. Look for material profiles with this printer's definition. base_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = "fdmprinter") - printer_specific_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = self.machine.container_id, variant_name = None) + printer_specific_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = self.machine.container_id) variant_specific_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = self.machine.container_id, variant_name = self.variant_name) # If empty_variant, this won't return anything. materials_per_base_file = {material["base_file"]: material for material in base_materials} materials_per_base_file.update({material["base_file"]: material for material in printer_specific_materials}) # Printer-specific profiles override global ones. From b6216896cbeee47799671418ccc6fb0c5753d9a7 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 13 Jan 2020 14:43:11 +0100 Subject: [PATCH 27/68] Add a script to execute the CI scripts on a local Docker instance --- test-in-docker.sh | 5 +++++ 1 file changed, 5 insertions(+) create mode 100755 test-in-docker.sh diff --git a/test-in-docker.sh b/test-in-docker.sh new file mode 100755 index 0000000000..e5a1116646 --- /dev/null +++ b/test-in-docker.sh @@ -0,0 +1,5 @@ +sudo rm -rf ./build ./Uranium +sudo docker run -it --rm \ + -v "$(pwd):/srv/cura" ultimaker/cura-build-environment \ + /srv/cura/docker/build.sh +sudo rm -rf ./build ./Uranium From e8f22beb276078a8abbae60a12583d7bacea699b Mon Sep 17 00:00:00 2001 From: Dimitriovski Date: Tue, 14 Jan 2020 11:50:47 +0100 Subject: [PATCH 28/68] Removed unnecessary function and a call to it. Added some typing. CURA-7090 --- plugins/Toolbox/src/SubscribedPackagesModel.py | 13 +++---------- plugins/Toolbox/src/Toolbox.py | 4 +--- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/plugins/Toolbox/src/SubscribedPackagesModel.py b/plugins/Toolbox/src/SubscribedPackagesModel.py index acbf4c13f7..53d6eba932 100644 --- a/plugins/Toolbox/src/SubscribedPackagesModel.py +++ b/plugins/Toolbox/src/SubscribedPackagesModel.py @@ -5,7 +5,7 @@ from PyQt5.QtCore import Qt from UM.Qt.ListModel import ListModel from cura import ApplicationMetadata from UM.Logger import Logger -from typing import List +from typing import List, Dict, Any class SubscribedPackagesModel(ListModel): @@ -23,12 +23,12 @@ class SubscribedPackagesModel(ListModel): self.addRoleName(Qt.UserRole + 4, "is_compatible") self.addRoleName(Qt.UserRole + 5, "is_dismissed") - def setMetadata(self, data) -> None: + def setMetadata(self, data: List[Dict[str, List[Any]]]) -> None: if self._metadata != data: self._metadata = data def addDiscrepancies(self, discrepancy: List[str]) -> None: - if self._discrepancies != discrepancy: + if set(self._discrepancies) != set(discrepancy): # convert to set() to check if they are same list, regardless of list order self._discrepancies = discrepancy def initialize(self) -> None: @@ -73,10 +73,3 @@ class SubscribedPackagesModel(ListModel): if package != -1: self.setProperty(package, property="is_dismissed", value=True) Logger.debug("Package {} has been dismissed".format(package_id)) - - # Reads the dismissed_packages from user config file and applies them so they won't be shown in the Compatibility Dialog - def applyDismissedPackages(self, dismissed_packages: List[str]) -> None: - for package in dismissed_packages: - exists = self.find(key="package_id", value=package) - if exists != -1: - self.setProperty(exists, property="is_dismissed", value=True) diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index eaaf8d94e9..dd01458a32 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -676,8 +676,7 @@ class Toolbox(QObject, Extension): if package_discrepancy: self._models["subscribed_packages"].addDiscrepancies(package_discrepancy) self._models["subscribed_packages"].initialize() - self._models["subscribed_packages"].applyDismissedPackages(user_dismissed_packages) - Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") + Logger.debug("Discrepancy found between Cloud subscribed packages and Cura installed packages") sync_message = Message(i18n_catalog.i18nc( "@info:generic", "\nDo you want to sync material and software packages with your account?"), @@ -688,7 +687,6 @@ class Toolbox(QObject, Extension): icon="", description="Sync your Cloud subscribed packages to your local environment.", button_align=Message.ActionButtonAlignment.ALIGN_RIGHT) - sync_message.actionTriggered.connect(self._onSyncButtonClicked) sync_message.show() From 3872e8ffa38622792285f47f38b20bc9a3e94e21 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Tue, 14 Jan 2020 12:45:11 +0100 Subject: [PATCH 29/68] Move 'Toolbar' lower: Toolpanels could be obscured by objects-info. --- resources/qml/Cura.qml | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/resources/qml/Cura.qml b/resources/qml/Cura.qml index 326edb6e97..d6f50f939b 100644 --- a/resources/qml/Cura.qml +++ b/resources/qml/Cura.qml @@ -253,23 +253,6 @@ UM.MainWindow } } - Toolbar - { - // The toolbar is the left bar that is populated by all the tools (which are dynamicly populated by - // plugins) - id: toolbar - - property int mouseX: base.mouseX - property int mouseY: base.mouseY - - anchors - { - verticalCenter: parent.verticalCenter - left: parent.left - } - visible: CuraApplication.platformActivity && !PrintInformation.preSliced - } - ObjectSelector { id: objectSelector @@ -311,6 +294,23 @@ UM.MainWindow } } + Toolbar + { + // The toolbar is the left bar that is populated by all the tools (which are dynamicly populated by + // plugins) + id: toolbar + + property int mouseX: base.mouseX + property int mouseY: base.mouseY + + anchors + { + verticalCenter: parent.verticalCenter + left: parent.left + } + visible: CuraApplication.platformActivity && !PrintInformation.preSliced + } + // A hint for the loaded content view. Overlay items / controls can safely be placed in this area Item { id: mainSafeArea From 15a3922436e82eff3371a840b99b1e77549860b8 Mon Sep 17 00:00:00 2001 From: Dimitriovski Date: Tue, 14 Jan 2020 14:07:55 +0100 Subject: [PATCH 30/68] Removed unnecessary checks CURA-7090 --- plugins/Toolbox/src/SubscribedPackagesModel.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/plugins/Toolbox/src/SubscribedPackagesModel.py b/plugins/Toolbox/src/SubscribedPackagesModel.py index 53d6eba932..e6886f23ce 100644 --- a/plugins/Toolbox/src/SubscribedPackagesModel.py +++ b/plugins/Toolbox/src/SubscribedPackagesModel.py @@ -24,16 +24,13 @@ class SubscribedPackagesModel(ListModel): self.addRoleName(Qt.UserRole + 5, "is_dismissed") def setMetadata(self, data: List[Dict[str, List[Any]]]) -> None: - if self._metadata != data: - self._metadata = data + self._metadata = data def addDiscrepancies(self, discrepancy: List[str]) -> None: - if set(self._discrepancies) != set(discrepancy): # convert to set() to check if they are same list, regardless of list order - self._discrepancies = discrepancy + self._discrepancies = discrepancy def initialize(self) -> None: self._items.clear() - for item in self._metadata: if item["package_id"] not in self._discrepancies: continue @@ -49,7 +46,6 @@ class SubscribedPackagesModel(ListModel): package.update({"icon_url": item["icon_url"]}) except KeyError: # There is no 'icon_url" in the response payload for this package package.update({"icon_url": ""}) - self._items.append(package) self.setItems(self._items) From 88e5626b59a9befd2648eff871b0083a64454bb1 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Tue, 14 Jan 2020 17:57:12 +0100 Subject: [PATCH 31/68] Fix more Mypy warnings CURA-6983 --- .../src/CloudSync/DiscrepanciesPresenter.py | 1 + .../Toolbox/src/CloudSync/DownloadPresenter.py | 15 ++++++++++++--- plugins/Toolbox/src/Toolbox.py | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py index 9e7ad518ad..ecb322f00e 100644 --- a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py @@ -24,6 +24,7 @@ class DiscrepanciesPresenter(QObject): def present(self, plugin_path: str, model: SubscribedPackagesModel): path = os.path.join(plugin_path, self._compatibility_dialog_path) self._dialog = self._app.createQmlComponent(path, {"subscribedPackagesModel": model}) + assert self._dialog self._dialog.accepted.connect(lambda: self._onConfirmClicked(model)) def _onConfirmClicked(self, model: SubscribedPackagesModel): diff --git a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py index dcb74ca41f..623eacc395 100644 --- a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py @@ -42,11 +42,20 @@ class DownloadPresenter: for item in model.items: package_id = item["package_id"] + def finishedCallback(reply: QNetworkReply, pid = package_id) -> None: + self._onFinished(pid, reply) + + def progressCallback(rx: int, rt: int, pid = package_id) -> None: + self._onProgress(pid, rx, rt) + + def errorCallback(reply: QNetworkReply, error: QNetworkReply.NetworkError, pid = package_id) -> None: + self._onError(pid) + request_data = manager.get( item["download_url"], - callback = lambda reply, pid = package_id: self._onFinished(pid, reply), - download_progress_callback = lambda rx, rt, pid = package_id: self._onProgress(pid, rx, rt), - error_callback = lambda rx, rt, pid = package_id: self._onProgress(pid, rx, rt), + callback = finishedCallback, + download_progress_callback = progressCallback, + error_callback = errorCallback, scope = self._scope) self._progress[package_id] = { diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 54fe80e23c..8098d90d99 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -48,7 +48,7 @@ class Toolbox(QObject, Extension): self._download_request_data = None # type: Optional[HttpRequestData] self._download_progress = 0 # type: float self._is_downloading = False # type: bool - self._scope = UltimakerCloudScope(application) + self._scope = UltimakerCloudScope(application) # type: UltimakerCloudScope self._request_urls = {} # type: Dict[str, str] self._to_update = [] # type: List[str] # Package_ids that are waiting to be updated From 6abf916ced39f122bbd2f17471f0a82809740c08 Mon Sep 17 00:00:00 2001 From: fieldOfView Date: Tue, 14 Jan 2020 21:56:06 +0100 Subject: [PATCH 32/68] Fix typing in __init__ methods to appease MYPY --- cura/CuraPackageManager.py | 2 +- cura/OneAtATimeIterator.py | 2 +- cura/Operations/PlatformPhysicsOperation.py | 2 +- cura/Operations/SetParentOperation.py | 2 +- plugins/PostProcessingPlugin/scripts/Stretch.py | 2 +- plugins/SimulationView/SimulationViewProxy.py | 2 +- .../src/Models/Http/ClusterPrinterMaterialStationSlot.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cura/CuraPackageManager.py b/cura/CuraPackageManager.py index 99f2072644..a0d3a8d44a 100644 --- a/cura/CuraPackageManager.py +++ b/cura/CuraPackageManager.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: class CuraPackageManager(PackageManager): - def __init__(self, application: "QtApplication", parent: Optional["QObject"] = None): + def __init__(self, application: "QtApplication", parent: Optional["QObject"] = None) -> None: super().__init__(application, parent) def initialize(self) -> None: diff --git a/cura/OneAtATimeIterator.py b/cura/OneAtATimeIterator.py index b77e1f3982..3373f2104f 100644 --- a/cura/OneAtATimeIterator.py +++ b/cura/OneAtATimeIterator.py @@ -122,6 +122,6 @@ class _ObjectOrder: # \param order List of indices in which to print objects, ordered by printing # order. # \param todo: List of indices which are not yet inserted into the order list. - def __init__(self, order: List[SceneNode], todo: List[SceneNode]): + def __init__(self, order: List[SceneNode], todo: List[SceneNode]) -> None: self.order = order self.todo = todo diff --git a/cura/Operations/PlatformPhysicsOperation.py b/cura/Operations/PlatformPhysicsOperation.py index 5aaa2ad94f..0d69320eec 100644 --- a/cura/Operations/PlatformPhysicsOperation.py +++ b/cura/Operations/PlatformPhysicsOperation.py @@ -8,7 +8,7 @@ from UM.Scene.SceneNode import SceneNode ## A specialised operation designed specifically to modify the previous operation. class PlatformPhysicsOperation(Operation): - def __init__(self, node: SceneNode, translation: Vector): + def __init__(self, node: SceneNode, translation: Vector) -> None: super().__init__() self._node = node self._old_transformation = node.getLocalTransformation() diff --git a/cura/Operations/SetParentOperation.py b/cura/Operations/SetParentOperation.py index 7efe2618fd..7d71572a93 100644 --- a/cura/Operations/SetParentOperation.py +++ b/cura/Operations/SetParentOperation.py @@ -14,7 +14,7 @@ class SetParentOperation(Operation.Operation): # # \param node The node which will be reparented. # \param parent_node The node which will be the parent. - def __init__(self, node: SceneNode, parent_node: Optional[SceneNode]): + def __init__(self, node: SceneNode, parent_node: Optional[SceneNode]) -> None: super().__init__() self._node = node self._parent = parent_node diff --git a/plugins/PostProcessingPlugin/scripts/Stretch.py b/plugins/PostProcessingPlugin/scripts/Stretch.py index 20eef60ef2..3899bd2c50 100644 --- a/plugins/PostProcessingPlugin/scripts/Stretch.py +++ b/plugins/PostProcessingPlugin/scripts/Stretch.py @@ -35,7 +35,7 @@ class GCodeStep(): Class to store the current value of each G_Code parameter for any G-Code step """ - def __init__(self, step, in_relative_movement: bool = False): + def __init__(self, step, in_relative_movement: bool = False) -> None: self.step = step self.step_x = 0 self.step_y = 0 diff --git a/plugins/SimulationView/SimulationViewProxy.py b/plugins/SimulationView/SimulationViewProxy.py index 1183244ab3..ce2c336257 100644 --- a/plugins/SimulationView/SimulationViewProxy.py +++ b/plugins/SimulationView/SimulationViewProxy.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: class SimulationViewProxy(QObject): - def __init__(self, simulation_view: "SimulationView", parent=None): + def __init__(self, simulation_view: "SimulationView", parent=None) -> None: super().__init__(parent) self._simulation_view = simulation_view self._current_layer = 0 diff --git a/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterMaterialStationSlot.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterMaterialStationSlot.py index 80deb1c9a8..b9c40592e5 100644 --- a/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterMaterialStationSlot.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterMaterialStationSlot.py @@ -14,7 +14,7 @@ class ClusterPrinterMaterialStationSlot(ClusterPrintCoreConfiguration): # \param material_remaining: How much material is remaining on the spool (between 0 and 1, or -1 for missing data). # \param material_empty: Whether the material spool is too empty to be used. def __init__(self, slot_index: int, compatible: bool, material_remaining: float, - material_empty: Optional[bool] = False, **kwargs): + material_empty: Optional[bool] = False, **kwargs) -> None: self.slot_index = slot_index self.compatible = compatible self.material_remaining = material_remaining From 290761fccd6cf8f282c110def1de190d5544a223 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Wed, 15 Jan 2020 11:29:27 +0100 Subject: [PATCH 33/68] Fix arranger crash --- cura/Arranging/Arrange.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/Arranging/Arrange.py b/cura/Arranging/Arrange.py index d6b8e44cea..c0aca9a893 100644 --- a/cura/Arranging/Arrange.py +++ b/cura/Arranging/Arrange.py @@ -69,7 +69,7 @@ class Arrange: points = copy.deepcopy(vertices._points) # After scaling (like up to 0.1 mm) the node might not have points - if not points: + if not points.size: continue shape_arr = ShapeArray.fromPolygon(points, scale = scale) From dbab3c6e8b0b6d3617c6d1a9cad801dcc655d44b Mon Sep 17 00:00:00 2001 From: Kostas Karmas Date: Wed, 15 Jan 2020 11:50:59 +0100 Subject: [PATCH 34/68] Add more materials from Marketplace menu button This commit adds a button "Add more materials from Marketplace" in the menu that pops up in the material list while configuring the materials into custom ones. The button redirects the user to the Marketplace materials page (https://marketplace.ultimaker.com/app/cura/materials) CURA-7027 --- resources/qml/Actions.qml | 9 +++++++++ resources/qml/Menus/MaterialMenu.qml | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/resources/qml/Actions.qml b/resources/qml/Actions.qml index e5b39c6ba5..3c978df115 100644 --- a/resources/qml/Actions.qml +++ b/resources/qml/Actions.qml @@ -54,6 +54,7 @@ Item property alias manageProfiles: manageProfilesAction; property alias manageMaterials: manageMaterialsAction; + property alias marketplaceMaterials: marketplaceMaterialsAction; property alias preferences: preferencesAction; @@ -188,6 +189,14 @@ Item shortcut: "Ctrl+K" } + Action + { + id: marketplaceMaterialsAction + onTriggered: Qt.openUrlExternally("https://marketplace.ultimaker.com/app/cura/materials") + iconName: "configure" + text: catalog.i18nc("@action:inmenu", "Add more materials from Marketplace") + } + Action { id: updateProfileAction; diff --git a/resources/qml/Menus/MaterialMenu.qml b/resources/qml/Menus/MaterialMenu.qml index c101f56da5..b733ead40b 100644 --- a/resources/qml/Menus/MaterialMenu.qml +++ b/resources/qml/Menus/MaterialMenu.qml @@ -157,4 +157,11 @@ Menu { action: Cura.Actions.manageMaterials } + + MenuSeparator {} + + MenuItem + { + action: Cura.Actions.marketplaceMaterials + } } From 1a1e8a9525215f3117e6fbb90e93bc4f26a0a199 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Wed, 15 Jan 2020 11:49:46 +0100 Subject: [PATCH 35/68] Fix rebase bug regarding package compatibility CURA-6983 --- .../qml/dialogs/CompatibilityDialog.qml | 4 +-- .../src/CloudSync/SubscribedPackagesModel.py | 28 ++++++++++--------- plugins/Toolbox/src/Toolbox.py | 12 -------- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml index b7eed1b2e3..38cd2b3a42 100644 --- a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml @@ -48,7 +48,7 @@ UM.Dialog{ { font: UM.Theme.getFont("default") text: catalog.i18nc("@label", "The following packages will be added:") - visible: toolbox.has_compatible_packages + visible: subscribedPackagesModel.hasCompatiblePackages color: UM.Theme.getColor("text") height: contentHeight + UM.Theme.getSize("default_margin").height } @@ -91,7 +91,7 @@ UM.Dialog{ { font: UM.Theme.getFont("default") text: catalog.i18nc("@label", "The following packages can not be installed because of incompatible Cura version:") - visible: toolbox.has_incompatible_packages + visible: subscribedPackagesModel.hasIncompatiblePackages color: UM.Theme.getColor("text") height: contentHeight + UM.Theme.getSize("default_margin").height } diff --git a/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py b/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py index d98db0ec4d..3fd2e34b9a 100644 --- a/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py +++ b/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py @@ -1,7 +1,7 @@ # Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, pyqtProperty from UM.Qt.ListModel import ListModel from cura import ApplicationMetadata @@ -19,6 +19,20 @@ class SubscribedPackagesModel(ListModel): self.addRoleName(Qt.UserRole + 2, "icon_url") self.addRoleName(Qt.UserRole + 3, "is_compatible") + @pyqtProperty(bool, constant=True) + def hasCompatiblePackages(self) -> bool: + for item in self._items: + if item['is_compatible']: + return True + return False + + @pyqtProperty(bool, constant=True) + def hasIncompatiblePackages(self) -> bool: + for item in self._items: + if not item['is_compatible']: + return True + return False + def setMetadata(self, data): if self._metadata != data: self._metadata = data @@ -52,16 +66,4 @@ class SubscribedPackagesModel(ListModel): self._items.append(package) self.setItems(self._items) - def hasCompatiblePackages(self) -> bool: - has_compatible_items = False - for item in self._items: - if item['is_compatible'] == True: - has_compatible_items = True - return has_compatible_items - def hasIncompatiblePackages(self) -> bool: - has_incompatible_items = False - for item in self._items: - if item['is_compatible'] == False: - has_incompatible_items = True - return has_incompatible_items \ No newline at end of file diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 8098d90d99..b1928b4e84 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -733,18 +733,6 @@ class Toolbox(QObject, Extension): def authorsModel(self) -> AuthorsModel: return cast(AuthorsModel, self._models["authors"]) - @pyqtProperty(QObject, constant = True) - def subscribedPackagesModel(self) -> SubscribedPackagesModel: - return cast(SubscribedPackagesModel, self._models["subscribed_packages"]) - - @pyqtProperty(bool, constant=True) - def has_compatible_packages(self) -> str: - return self._models["subscribed_packages"].hasCompatiblePackages() - - @pyqtProperty(bool, constant=True) - def has_incompatible_packages(self) -> str: - return self._models["subscribed_packages"].hasIncompatiblePackages() - @pyqtProperty(QObject, constant = True) def packagesModel(self) -> PackagesModel: return cast(PackagesModel, self._models["packages"]) From b1ecfb627d202a1591970cf0201593233f811f3f Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Wed, 15 Jan 2020 12:00:01 +0100 Subject: [PATCH 36/68] Only emit compatible packages from DiscrepanciesPresenter CURA-6983 --- plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py | 3 ++- plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py index ecb322f00e..f4ff895b45 100644 --- a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py @@ -28,6 +28,7 @@ class DiscrepanciesPresenter(QObject): self._dialog.accepted.connect(lambda: self._onConfirmClicked(model)) def _onConfirmClicked(self, model: SubscribedPackagesModel): - # For now, all packages presented to the user should be installed. + # For now, all compatible packages presented to the user should be installed. # Later, we might remove items for which the user unselected the package + model.setItems(model.getCompatiblePackages()) self.packageMutations.emit(model) diff --git a/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py b/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py index 3fd2e34b9a..cea4936655 100644 --- a/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py +++ b/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py @@ -41,6 +41,9 @@ class SubscribedPackagesModel(ListModel): if self._discrepancies != discrepancy: self._discrepancies = discrepancy + def getCompatiblePackages(self): + return [x for x in self._items if x["is_compatible"]] + def update(self): self._items.clear() From 15dc866cbe77c59a7ccf18a169fb9c6a71fb96ab Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Wed, 15 Jan 2020 15:26:31 +0100 Subject: [PATCH 37/68] Provess some code review comments CURA-6983 --- plugins/Toolbox/src/CloudSync/DownloadPresenter.py | 6 ++++-- plugins/Toolbox/src/CloudSync/SyncOrchestrator.py | 14 +++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py index 623eacc395..756a42b7ef 100644 --- a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py @@ -19,6 +19,8 @@ from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPack # use download() exactly once: should not be used for multiple sets of downloads since this class contains state class DownloadPresenter: + DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB + def __init__(self, app: CuraApplication): # Emits (Dict[str, str], List[str]) # (success_items, error_items) # Dict{success_package_id, temp_file_path} @@ -93,10 +95,10 @@ class DownloadPresenter: try: with tempfile.NamedTemporaryFile(mode ="wb+", suffix =".curapackage", delete = False) as temp_file: - bytes_read = reply.read(256 * 1024) + bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE) while bytes_read: temp_file.write(bytes_read) - bytes_read = reply.read(256 * 1024) + bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE) self._app.processEvents() self._progress[package_id]["file_written"] = temp_file.name except IOError as e: diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index a207523067..2420189d70 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -24,10 +24,10 @@ from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPack # - The SyncOrchestrator uses PackageManager to remove local packages the users wants to see removed # - The DownloadPresenter shows a download progress dialog. It emits A tuple of succeeded and failed downloads # - The LicensePresenter extracts licenses from the downloaded packages and presents a license for each package to -# be installed. It emits the `licenseAnswers` {'packageId' : bool} for accept or declines +# be installed. It emits the `licenseAnswers` signal for accept or declines # - The CloudPackageManager removes the declined packages from the account -# - The SyncOrchestrator uses PackageManager to install the downloaded packages. -# - Bliss / profit / done +# - The SyncOrchestrator uses PackageManager to install the downloaded packages and delete temp files. +# - The RestartApplicationPresenter notifies the user that a restart is required for changes to take effect class SyncOrchestrator(Extension): def __init__(self, app: CuraApplication): @@ -45,7 +45,7 @@ class SyncOrchestrator(Extension): self._discrepancies_presenter = DiscrepanciesPresenter(app) # type: DiscrepanciesPresenter self._discrepancies_presenter.packageMutations.connect(self._onPackageMutations) - self._download_Presenter = DownloadPresenter(app) # type: DownloadPresenter + self._download_presenter = DownloadPresenter(app) # type: DownloadPresenter self._license_presenter = LicensePresenter(app) # type: LicensePresenter self._license_presenter.licenseAnswers.connect(self._onLicenseAnswers) @@ -57,9 +57,9 @@ class SyncOrchestrator(Extension): self._discrepancies_presenter.present(plugin_path, model) def _onPackageMutations(self, mutations: SubscribedPackagesModel): - self._download_Presenter = self._download_Presenter.resetCopy() - self._download_Presenter.done.connect(self._onDownloadFinished) - self._download_Presenter.download(mutations) + self._download_presenter = self._download_presenter.resetCopy() + self._download_presenter.done.connect(self._onDownloadFinished) + self._download_presenter.download(mutations) ## Called when a set of packages have finished downloading # \param success_items: Dict[package_id, file_path] From 248a4cbb94ac67ffce16388893ec639cbae863e9 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Wed, 15 Jan 2020 16:14:17 +0100 Subject: [PATCH 38/68] Fix merge issues CURA-6983 --- .../resources/qml/dialogs/CompatibilityDialog.qml | 2 +- plugins/Toolbox/src/CloudSync/CloudPackageChecker.py | 10 ++++++---- .../Toolbox/src/CloudSync/DiscrepanciesPresenter.py | 10 ++++++++-- plugins/Toolbox/src/Toolbox.py | 5 ----- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml index 9a439930d3..06c1102811 100644 --- a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml @@ -141,7 +141,7 @@ UM.Dialog{ { cursorShape: Qt.PointingHandCursor anchors.fill: parent - onClicked: toolbox.dismissIncompatiblePackage(model.package_id) + onClicked: handler.dismissIncompatiblePackage(subscribedPackagesModel, model.package_id) } } } diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index cc5654437e..b062979271 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -44,13 +44,15 @@ class CloudPackageChecker(QObject): def _handleCompatibilityData(self, json_data) -> None: user_subscribed_packages = [plugin["package_id"] for plugin in json_data] user_installed_packages = self._package_manager.getUserInstalledPackages() - - # We check if there are packages installed in Cloud Marketplace but not in Cura marketplace (discrepancy) + user_dismissed_packages = self._package_manager.getDismissedPackages() + if user_dismissed_packages: + user_installed_packages += user_dismissed_packages + # We check if there are packages installed in Cloud Marketplace but not in Cura marketplace package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages)) self._model.setMetadata(json_data) - self._model.addValue(package_discrepancy) - self._model.update() + self._model.addDiscrepancies(package_discrepancy) + self._model.initialize() if package_discrepancy: self._handlePackageDiscrepancies() diff --git a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py index f4ff895b45..55df42879c 100644 --- a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py @@ -1,7 +1,7 @@ import os from typing import Optional, Dict -from PyQt5.QtCore import QObject +from PyQt5.QtCore import QObject, pyqtSlot from UM.Qt.QtApplication import QtApplication from UM.Signal import Signal @@ -18,15 +18,21 @@ class DiscrepanciesPresenter(QObject): self.packageMutations = Signal() # Emits SubscribedPackagesModel self._app = app + self._package_manager = app.getPackageManager() self._dialog = None # type: Optional[QObject] self._compatibility_dialog_path = "resources/qml/dialogs/CompatibilityDialog.qml" def present(self, plugin_path: str, model: SubscribedPackagesModel): path = os.path.join(plugin_path, self._compatibility_dialog_path) - self._dialog = self._app.createQmlComponent(path, {"subscribedPackagesModel": model}) + self._dialog = self._app.createQmlComponent(path, {"subscribedPackagesModel": model, "handler": self}) assert self._dialog self._dialog.accepted.connect(lambda: self._onConfirmClicked(model)) + @pyqtSlot("QVariant", str) + def dismissIncompatiblePackage(self, model: SubscribedPackagesModel, package_id: str): + model.dismissPackage(package_id) # update the model to update the view + self._package_manager.dismissPackage(package_id) # adds this package_id as dismissed in the user config file + def _onConfirmClicked(self, model: SubscribedPackagesModel): # For now, all compatible packages presented to the user should be installed. # Later, we might remove items for which the user unselected the package diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 8004cca9ed..b1928b4e84 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -528,11 +528,6 @@ class Toolbox(QObject, Extension): populated += 1 return populated == len(self._server_response_data.items()) - @pyqtSlot(str) - def dismissIncompatiblePackage(self, package_id: str): - self._models["subscribed_packages"].dismissPackage(package_id) # sets "is_compatible" to True, in-memory - self._package_manager.dismissPackage(package_id) # adds this package_id as dismissed in the user config file - # Make API Calls # -------------------------------------------------------------------------- def _makeRequestByType(self, request_type: str) -> None: From f12501aec48a4d0355871165c38bcd01d3db468e Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Wed, 15 Jan 2020 16:46:04 +0100 Subject: [PATCH 39/68] Move the message to body instead of title CURA-6983 --- plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py b/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py index e0d26cbf7a..6f1c76d0cf 100644 --- a/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py +++ b/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py @@ -31,7 +31,7 @@ class RestartApplicationPresenter: def present(self): app_name = self._app.getApplicationDisplayName() - message = Message(title=self._i18n_catalog.i18nc( + message = Message(self._i18n_catalog.i18nc( "@info:generic", "You need to quit and restart {} before changes have effect.", app_name )) From 0f7f39745d7287d7d623b9b32d764d3b72ea70f7 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Wed, 15 Jan 2020 16:47:58 +0100 Subject: [PATCH 40/68] Add missing typing CURA-6983 --- .../src/CloudSync/CloudPackageChecker.py | 5 ++-- .../src/CloudSync/CloudPackageManager.py | 5 ++-- .../src/CloudSync/DiscrepanciesPresenter.py | 8 +++---- .../src/CloudSync/DownloadPresenter.py | 12 +++++----- plugins/Toolbox/src/CloudSync/LicenseModel.py | 5 ++-- .../Toolbox/src/CloudSync/LicensePresenter.py | 23 ++++++++++--------- .../CloudSync/RestartApplicationPresenter.py | 10 ++------ .../Toolbox/src/CloudSync/SyncOrchestrator.py | 16 ++++--------- 8 files changed, 36 insertions(+), 48 deletions(-) diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index b062979271..c1ca9cbddf 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -15,7 +15,6 @@ from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPack class CloudPackageChecker(QObject): - def __init__(self, application: CuraApplication) -> None: super().__init__() @@ -37,7 +36,7 @@ class CloudPackageChecker(QObject): # check again whenever the login state changes self._application.getCuraAPI().account.loginStateChanged.connect(self._fetchUserSubscribedPackages) - def _fetchUserSubscribedPackages(self): + def _fetchUserSubscribedPackages(self) -> None: if self._application.getCuraAPI().account.isLoggedIn: self._getUserPackages() @@ -57,7 +56,7 @@ class CloudPackageChecker(QObject): if package_discrepancy: self._handlePackageDiscrepancies() - def _handlePackageDiscrepancies(self): + def _handlePackageDiscrepancies(self) -> None: Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") sync_message = Message(self._i18n_catalog.i18nc( "@info:generic", diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageManager.py b/plugins/Toolbox/src/CloudSync/CloudPackageManager.py index a724aa316d..bf3ed02de3 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageManager.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageManager.py @@ -6,12 +6,11 @@ from plugins.Toolbox.src.UltimakerCloudScope import UltimakerCloudScope ## Manages Cloud subscriptions. When a package is added to a user's account, the user is 'subscribed' to that package # Whenever the user logs in on another instance of Cura, these subscriptions can be used to sync the user's plugins class CloudPackageManager: - - def __init__(self, app: CuraApplication): + def __init__(self, app: CuraApplication) -> None: self._request_manager = app.getHttpRequestManager() self._scope = UltimakerCloudScope(app) - def subscribe(self, package_id: str): + def subscribe(self, package_id: str) -> None: data = "{\"data\": {\"package_id\": \"%s\", \"sdk_version\": \"%s\"}}" % (package_id, CloudApiModel.sdk_version) self._request_manager.put(url=CloudApiModel.api_url_user_packages, data=data.encode(), diff --git a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py index 55df42879c..4e202fb7b1 100644 --- a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py @@ -12,7 +12,7 @@ from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPack # choices are emitted on the `packageMutations` Signal. class DiscrepanciesPresenter(QObject): - def __init__(self, app: QtApplication): + def __init__(self, app: QtApplication) -> None: super().__init__(app) self.packageMutations = Signal() # Emits SubscribedPackagesModel @@ -22,18 +22,18 @@ class DiscrepanciesPresenter(QObject): self._dialog = None # type: Optional[QObject] self._compatibility_dialog_path = "resources/qml/dialogs/CompatibilityDialog.qml" - def present(self, plugin_path: str, model: SubscribedPackagesModel): + def present(self, plugin_path: str, model: SubscribedPackagesModel) -> None: path = os.path.join(plugin_path, self._compatibility_dialog_path) self._dialog = self._app.createQmlComponent(path, {"subscribedPackagesModel": model, "handler": self}) assert self._dialog self._dialog.accepted.connect(lambda: self._onConfirmClicked(model)) @pyqtSlot("QVariant", str) - def dismissIncompatiblePackage(self, model: SubscribedPackagesModel, package_id: str): + def dismissIncompatiblePackage(self, model: SubscribedPackagesModel, package_id: str) -> None: model.dismissPackage(package_id) # update the model to update the view self._package_manager.dismissPackage(package_id) # adds this package_id as dismissed in the user config file - def _onConfirmClicked(self, model: SubscribedPackagesModel): + def _onConfirmClicked(self, model: SubscribedPackagesModel) -> None: # For now, all compatible packages presented to the user should be installed. # Later, we might remove items for which the user unselected the package model.setItems(model.getCompatiblePackages()) diff --git a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py index 756a42b7ef..2d785549ee 100644 --- a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py @@ -21,7 +21,7 @@ class DownloadPresenter: DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB - def __init__(self, app: CuraApplication): + def __init__(self, app: CuraApplication) -> None: # Emits (Dict[str, str], List[str]) # (success_items, error_items) # Dict{success_package_id, temp_file_path} # List[errored_package_id] @@ -35,7 +35,7 @@ class DownloadPresenter: self._progress = {} # type: Dict[str, Dict[str, Any]] # package_id, Dict self._error = [] # type: List[str] # package_id - def download(self, model: SubscribedPackagesModel): + def download(self, model: SubscribedPackagesModel) -> None: if self._started: Logger.error("Download already started. Create a new %s instead", self.__class__.__name__) return @@ -70,13 +70,13 @@ class DownloadPresenter: self._started = True self._progress_message.show() - def abort(self): + def abort(self) -> None: manager = HttpRequestManager.getInstance() for item in self._progress.values(): manager.abortRequest(item["request_data"]) # Aborts all current operations and returns a copy with the same settings such as app and scope - def resetCopy(self): + def resetCopy(self) -> "DownloadPresenter": self.abort() self.done.disconnectAll() return DownloadPresenter(self._app) @@ -90,7 +90,7 @@ class DownloadPresenter: progress = 0.0, title = i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) - def _onFinished(self, package_id: str, reply: QNetworkReply): + def _onFinished(self, package_id: str, reply: QNetworkReply) -> None: self._progress[package_id]["received"] = self._progress[package_id]["total"] try: @@ -108,7 +108,7 @@ class DownloadPresenter: self._checkDone() - def _onProgress(self, package_id: str, rx: int, rt: int): + def _onProgress(self, package_id: str, rx: int, rt: int) -> None: self._progress[package_id]["received"] = rx self._progress[package_id]["total"] = rt diff --git a/plugins/Toolbox/src/CloudSync/LicenseModel.py b/plugins/Toolbox/src/CloudSync/LicenseModel.py index 1328383d76..c3b5ee5d31 100644 --- a/plugins/Toolbox/src/CloudSync/LicenseModel.py +++ b/plugins/Toolbox/src/CloudSync/LicenseModel.py @@ -3,13 +3,14 @@ from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") + # Model for the ToolboxLicenseDialog class LicenseModel(QObject): dialogTitleChanged = pyqtSignal() headerChanged = pyqtSignal() licenseTextChanged = pyqtSignal() - def __init__(self): + def __init__(self) -> None: super().__init__() self._current_page_idx = 0 @@ -44,7 +45,7 @@ class LicenseModel(QObject): self._current_page_idx = idx self._updateDialogTitle() - def setPageCount(self, count: int): + def setPageCount(self, count: int) -> None: self._page_count = count self._updateDialogTitle() diff --git a/plugins/Toolbox/src/CloudSync/LicensePresenter.py b/plugins/Toolbox/src/CloudSync/LicensePresenter.py index f05feecabd..bafa57cae3 100644 --- a/plugins/Toolbox/src/CloudSync/LicensePresenter.py +++ b/plugins/Toolbox/src/CloudSync/LicensePresenter.py @@ -15,7 +15,7 @@ from plugins.Toolbox.src.CloudSync.LicenseModel import LicenseModel # licenseAnswers emits a list of Dicts containing answers when the user has made a choice for all provided packages class LicensePresenter(QObject): - def __init__(self, app: CuraApplication): + def __init__(self, app: CuraApplication) -> None: super().__init__() self._dialog = None # type: Optional[QObject] self._package_manager = app.getPackageManager() # type: PackageManager @@ -34,7 +34,7 @@ class LicensePresenter(QObject): ## Show a license dialog for multiple packages where users can read a license and accept or decline them # \param plugin_path: Root directory of the Toolbox plugin # \param packages: Dict[package id, file path] - def present(self, plugin_path: str, packages: Dict[str, str]): + def present(self, plugin_path: str, packages: Dict[str, str]) -> None: path = os.path.join(plugin_path, self._compatibility_dialog_path) self._initState(packages) @@ -51,16 +51,16 @@ class LicensePresenter(QObject): self._present_current_package() @pyqtSlot() - def onLicenseAccepted(self): + def onLicenseAccepted(self) -> None: self._package_models[self._current_package_idx]["accepted"] = True self._check_next_page() @pyqtSlot() - def onLicenseDeclined(self): + def onLicenseDeclined(self) -> None: self._package_models[self._current_package_idx]["accepted"] = False self._check_next_page() - def _initState(self, packages: Dict[str, str]): + def _initState(self, packages: Dict[str, str]) -> None: self._package_models = [ { "package_id" : package_id, @@ -70,26 +70,27 @@ class LicensePresenter(QObject): for package_id, package_path in packages.items() ] - def _present_current_package(self): + def _present_current_package(self) -> None: package_model = self._package_models[self._current_package_idx] license_content = self._package_manager.getPackageLicense(package_model["package_path"]) if license_content is None: - # implicitly accept when there is no license + # Implicitly accept when there is no license self.onLicenseAccepted() return self._license_model.setCurrentPageIdx(self._current_package_idx) self._license_model.setPackageName(package_model["package_id"]) self._license_model.setLicenseText(license_content) + if self._dialog: + self._dialog.open() # Does nothing if already open - self._dialog.open() # does nothing if already open - - def _check_next_page(self): + def _check_next_page(self) -> None: if self._current_package_idx + 1 < len(self._package_models): self._current_package_idx += 1 self._present_current_package() else: - self._dialog.close() + if self._dialog: + self._dialog.close() self.licenseAnswers.emit(self._package_models) diff --git a/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py b/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py index 6f1c76d0cf..96aa9fea7b 100644 --- a/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py +++ b/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py @@ -18,17 +18,11 @@ from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPack ## Presents a dialog telling the user that a restart is required to apply changes # Since we cannot restart Cura, the app is closed instead when the button is clicked class RestartApplicationPresenter: - - def __init__(self, app: CuraApplication): - # Emits (Dict[str, str], List[str]) # (success_items, error_items) - # Dict{success_package_id, temp_file_path} - # List[errored_package_id] - self.done = Signal() - + def __init__(self, app: CuraApplication) -> None: self._app = app self._i18n_catalog = i18nCatalog("cura") - def present(self): + def present(self) -> None: app_name = self._app.getApplicationDisplayName() message = Message(self._i18n_catalog.i18nc( diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index 2420189d70..2dd1e999ac 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -30,7 +30,7 @@ from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPack # - The RestartApplicationPresenter notifies the user that a restart is required for changes to take effect class SyncOrchestrator(Extension): - def __init__(self, app: CuraApplication): + def __init__(self, app: CuraApplication) -> None: super().__init__() # Differentiate This PluginObject from the Toolbox. self.getId() includes _name. # getPluginId() will return the same value for The toolbox extension and this one @@ -52,11 +52,11 @@ class SyncOrchestrator(Extension): self._restart_presenter = RestartApplicationPresenter(app) - def _onDiscrepancies(self, model: SubscribedPackagesModel): + def _onDiscrepancies(self, model: SubscribedPackagesModel) -> None: plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) self._discrepancies_presenter.present(plugin_path, model) - def _onPackageMutations(self, mutations: SubscribedPackagesModel): + def _onPackageMutations(self, mutations: SubscribedPackagesModel) -> None: self._download_presenter = self._download_presenter.resetCopy() self._download_presenter.done.connect(self._onDownloadFinished) self._download_presenter.download(mutations) @@ -64,13 +64,13 @@ class SyncOrchestrator(Extension): ## Called when a set of packages have finished downloading # \param success_items: Dict[package_id, file_path] # \param error_items: List[package_id] - def _onDownloadFinished(self, success_items: Dict[str, str], error_items: List[str]): + def _onDownloadFinished(self, success_items: Dict[str, str], error_items: List[str]) -> None: # todo handle error items plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) self._license_presenter.present(plugin_path, success_items) # Called when user has accepted / declined all licenses for the downloaded packages - def _onLicenseAnswers(self, answers: List[Dict[str, Any]]): + def _onLicenseAnswers(self, answers: List[Dict[str, Any]]) -> None: Logger.debug("Got license answers: {}", answers) has_changes = False # True when at least one package is installed @@ -91,9 +91,3 @@ class SyncOrchestrator(Extension): if has_changes: self._restart_presenter.present() - - - - - - From fa3a985404afcfa2adaabb976db7904e56aabfc5 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Wed, 15 Jan 2020 16:57:40 +0100 Subject: [PATCH 41/68] Rename 2 functions that didn't adhere to codestyle CURA-6983 --- plugins/Toolbox/src/CloudSync/LicensePresenter.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/Toolbox/src/CloudSync/LicensePresenter.py b/plugins/Toolbox/src/CloudSync/LicensePresenter.py index bafa57cae3..7c6b231f0e 100644 --- a/plugins/Toolbox/src/CloudSync/LicensePresenter.py +++ b/plugins/Toolbox/src/CloudSync/LicensePresenter.py @@ -48,17 +48,17 @@ class LicensePresenter(QObject): } self._dialog = self._app.createQmlComponent(path, context_properties) self._license_model.setPageCount(len(self._package_models)) - self._present_current_package() + self._presentCurrentPackage() @pyqtSlot() def onLicenseAccepted(self) -> None: self._package_models[self._current_package_idx]["accepted"] = True - self._check_next_page() + self._checkNextPage() @pyqtSlot() def onLicenseDeclined(self) -> None: self._package_models[self._current_package_idx]["accepted"] = False - self._check_next_page() + self._checkNextPage() def _initState(self, packages: Dict[str, str]) -> None: self._package_models = [ @@ -70,7 +70,7 @@ class LicensePresenter(QObject): for package_id, package_path in packages.items() ] - def _present_current_package(self) -> None: + def _presentCurrentPackage(self) -> None: package_model = self._package_models[self._current_package_idx] license_content = self._package_manager.getPackageLicense(package_model["package_path"]) if license_content is None: @@ -84,10 +84,10 @@ class LicensePresenter(QObject): if self._dialog: self._dialog.open() # Does nothing if already open - def _check_next_page(self) -> None: + def _checkNextPage(self) -> None: if self._current_package_idx + 1 < len(self._package_models): self._current_package_idx += 1 - self._present_current_package() + self._presentCurrentPackage() else: if self._dialog: self._dialog.close() From ab6effb712522c3e96225663d2b95832e375a58c Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 16 Jan 2020 12:58:01 +0100 Subject: [PATCH 42/68] No longer use sceneLock for startSliceJob --- plugins/CuraEngineBackend/StartSliceJob.py | 247 ++++++++++----------- 1 file changed, 123 insertions(+), 124 deletions(-) diff --git a/plugins/CuraEngineBackend/StartSliceJob.py b/plugins/CuraEngineBackend/StartSliceJob.py index d30a77177f..26b78dbfbd 100644 --- a/plugins/CuraEngineBackend/StartSliceJob.py +++ b/plugins/CuraEngineBackend/StartSliceJob.py @@ -171,146 +171,145 @@ class StartSliceJob(Job): self.setResult(StartJobResult.ObjectSettingError) return - with self._scene.getSceneLock(): - # Remove old layer data. - for node in DepthFirstIterator(self._scene.getRoot()): - if node.callDecoration("getLayerData") and node.callDecoration("getBuildPlateNumber") == self._build_plate_number: - # Singe we walk through all nodes in the scene, they always have a parent. - cast(SceneNode, node.getParent()).removeChild(node) - break + # Remove old layer data. + for node in DepthFirstIterator(self._scene.getRoot()): + if node.callDecoration("getLayerData") and node.callDecoration("getBuildPlateNumber") == self._build_plate_number: + # Singe we walk through all nodes in the scene, they always have a parent. + cast(SceneNode, node.getParent()).removeChild(node) + break - # Get the objects in their groups to print. - object_groups = [] - if stack.getProperty("print_sequence", "value") == "one_at_a_time": - for node in OneAtATimeIterator(self._scene.getRoot()): - temp_list = [] - - # Node can't be printed, so don't bother sending it. - if getattr(node, "_outside_buildarea", False): - continue - - # Filter on current build plate - build_plate_number = node.callDecoration("getBuildPlateNumber") - if build_plate_number is not None and build_plate_number != self._build_plate_number: - continue - - children = node.getAllChildren() - children.append(node) - for child_node in children: - mesh_data = child_node.getMeshData() - if mesh_data and mesh_data.getVertices() is not None: - temp_list.append(child_node) - - if temp_list: - object_groups.append(temp_list) - Job.yieldThread() - if len(object_groups) == 0: - Logger.log("w", "No objects suitable for one at a time found, or no correct order found") - else: + # Get the objects in their groups to print. + object_groups = [] + if stack.getProperty("print_sequence", "value") == "one_at_a_time": + for node in OneAtATimeIterator(self._scene.getRoot()): temp_list = [] - has_printing_mesh = False - for node in DepthFirstIterator(self._scene.getRoot()): - mesh_data = node.getMeshData() - if node.callDecoration("isSliceable") and mesh_data and mesh_data.getVertices() is not None: - is_non_printing_mesh = bool(node.callDecoration("isNonPrintingMesh")) - # Find a reason not to add the node - if node.callDecoration("getBuildPlateNumber") != self._build_plate_number: - continue - if getattr(node, "_outside_buildarea", False) and not is_non_printing_mesh: - continue + # Node can't be printed, so don't bother sending it. + if getattr(node, "_outside_buildarea", False): + continue - temp_list.append(node) - if not is_non_printing_mesh: - has_printing_mesh = True + # Filter on current build plate + build_plate_number = node.callDecoration("getBuildPlateNumber") + if build_plate_number is not None and build_plate_number != self._build_plate_number: + continue - Job.yieldThread() - - # If the list doesn't have any model with suitable settings then clean the list - # otherwise CuraEngine will crash - if not has_printing_mesh: - temp_list.clear() + children = node.getAllChildren() + children.append(node) + for child_node in children: + mesh_data = child_node.getMeshData() + if mesh_data and mesh_data.getVertices() is not None: + temp_list.append(child_node) if temp_list: object_groups.append(temp_list) + Job.yieldThread() + if len(object_groups) == 0: + Logger.log("w", "No objects suitable for one at a time found, or no correct order found") + else: + temp_list = [] + has_printing_mesh = False + for node in DepthFirstIterator(self._scene.getRoot()): + mesh_data = node.getMeshData() + if node.callDecoration("isSliceable") and mesh_data and mesh_data.getVertices() is not None: + is_non_printing_mesh = bool(node.callDecoration("isNonPrintingMesh")) - global_stack = CuraApplication.getInstance().getGlobalContainerStack() - if not global_stack: - return - extruders_enabled = {position: stack.isEnabled for position, stack in global_stack.extruders.items()} - filtered_object_groups = [] - has_model_with_disabled_extruders = False - associated_disabled_extruders = set() - for group in object_groups: - stack = global_stack - skip_group = False - for node in group: - # Only check if the printing extruder is enabled for printing meshes - is_non_printing_mesh = node.callDecoration("evaluateIsNonPrintingMesh") - extruder_position = node.callDecoration("getActiveExtruderPosition") - if not is_non_printing_mesh and not extruders_enabled[extruder_position]: - skip_group = True - has_model_with_disabled_extruders = True - associated_disabled_extruders.add(extruder_position) - if not skip_group: - filtered_object_groups.append(group) - - if has_model_with_disabled_extruders: - self.setResult(StartJobResult.ObjectsWithDisabledExtruder) - associated_disabled_extruders = {str(c) for c in sorted([int(p) + 1 for p in associated_disabled_extruders])} - self.setMessage(", ".join(associated_disabled_extruders)) - return - - # There are cases when there is nothing to slice. This can happen due to one at a time slicing not being - # able to find a possible sequence or because there are no objects on the build plate (or they are outside - # the build volume) - if not filtered_object_groups: - self.setResult(StartJobResult.NothingToSlice) - return - - self._buildGlobalSettingsMessage(stack) - self._buildGlobalInheritsStackMessage(stack) - - # Build messages for extruder stacks - for extruder_stack in global_stack.extruderList: - self._buildExtruderMessage(extruder_stack) - - for group in filtered_object_groups: - group_message = self._slice_message.addRepeatedMessage("object_lists") - parent = group[0].getParent() - if parent is not None and parent.callDecoration("isGroup"): - self._handlePerObjectSettings(cast(CuraSceneNode, parent), group_message) - - for object in group: - mesh_data = object.getMeshData() - if mesh_data is None: + # Find a reason not to add the node + if node.callDecoration("getBuildPlateNumber") != self._build_plate_number: + continue + if getattr(node, "_outside_buildarea", False) and not is_non_printing_mesh: continue - rot_scale = object.getWorldTransformation().getTransposed().getData()[0:3, 0:3] - translate = object.getWorldTransformation().getData()[:3, 3] - # This effectively performs a limited form of MeshData.getTransformed that ignores normals. - verts = mesh_data.getVertices() - verts = verts.dot(rot_scale) - verts += translate + temp_list.append(node) + if not is_non_printing_mesh: + has_printing_mesh = True - # Convert from Y up axes to Z up axes. Equals a 90 degree rotation. - verts[:, [1, 2]] = verts[:, [2, 1]] - verts[:, 1] *= -1 + Job.yieldThread() - obj = group_message.addRepeatedMessage("objects") - obj.id = id(object) - obj.name = object.getName() - indices = mesh_data.getIndices() - if indices is not None: - flat_verts = numpy.take(verts, indices.flatten(), axis=0) - else: - flat_verts = numpy.array(verts) + # If the list doesn't have any model with suitable settings then clean the list + # otherwise CuraEngine will crash + if not has_printing_mesh: + temp_list.clear() - obj.vertices = flat_verts + if temp_list: + object_groups.append(temp_list) - self._handlePerObjectSettings(cast(CuraSceneNode, object), obj) + global_stack = CuraApplication.getInstance().getGlobalContainerStack() + if not global_stack: + return + extruders_enabled = {position: stack.isEnabled for position, stack in global_stack.extruders.items()} + filtered_object_groups = [] + has_model_with_disabled_extruders = False + associated_disabled_extruders = set() + for group in object_groups: + stack = global_stack + skip_group = False + for node in group: + # Only check if the printing extruder is enabled for printing meshes + is_non_printing_mesh = node.callDecoration("evaluateIsNonPrintingMesh") + extruder_position = node.callDecoration("getActiveExtruderPosition") + if not is_non_printing_mesh and not extruders_enabled[extruder_position]: + skip_group = True + has_model_with_disabled_extruders = True + associated_disabled_extruders.add(extruder_position) + if not skip_group: + filtered_object_groups.append(group) - Job.yieldThread() + if has_model_with_disabled_extruders: + self.setResult(StartJobResult.ObjectsWithDisabledExtruder) + associated_disabled_extruders = {str(c) for c in sorted([int(p) + 1 for p in associated_disabled_extruders])} + self.setMessage(", ".join(associated_disabled_extruders)) + return + + # There are cases when there is nothing to slice. This can happen due to one at a time slicing not being + # able to find a possible sequence or because there are no objects on the build plate (or they are outside + # the build volume) + if not filtered_object_groups: + self.setResult(StartJobResult.NothingToSlice) + return + + self._buildGlobalSettingsMessage(stack) + self._buildGlobalInheritsStackMessage(stack) + + # Build messages for extruder stacks + for extruder_stack in global_stack.extruderList: + self._buildExtruderMessage(extruder_stack) + + for group in filtered_object_groups: + group_message = self._slice_message.addRepeatedMessage("object_lists") + parent = group[0].getParent() + if parent is not None and parent.callDecoration("isGroup"): + self._handlePerObjectSettings(cast(CuraSceneNode, parent), group_message) + + for object in group: + mesh_data = object.getMeshData() + if mesh_data is None: + continue + rot_scale = object.getWorldTransformation().getTransposed().getData()[0:3, 0:3] + translate = object.getWorldTransformation().getData()[:3, 3] + + # This effectively performs a limited form of MeshData.getTransformed that ignores normals. + verts = mesh_data.getVertices() + verts = verts.dot(rot_scale) + verts += translate + + # Convert from Y up axes to Z up axes. Equals a 90 degree rotation. + verts[:, [1, 2]] = verts[:, [2, 1]] + verts[:, 1] *= -1 + + obj = group_message.addRepeatedMessage("objects") + obj.id = id(object) + obj.name = object.getName() + indices = mesh_data.getIndices() + if indices is not None: + flat_verts = numpy.take(verts, indices.flatten(), axis=0) + else: + flat_verts = numpy.array(verts) + + obj.vertices = flat_verts + + self._handlePerObjectSettings(cast(CuraSceneNode, object), obj) + + Job.yieldThread() self.setResult(StartJobResult.Finished) From f5a64704bd040aca0765142568b5b27c4ed43472 Mon Sep 17 00:00:00 2001 From: Lipu Fei Date: Thu, 16 Jan 2020 13:11:22 +0100 Subject: [PATCH 43/68] Fix branch checkout for PRs with GitHub workflow --- .github/workflows/cicd.yml | 1 + docker/build.sh | 46 +++++++++++++++++++++++++------------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 40acbc44f3..ff0923f9b6 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -6,6 +6,7 @@ on: - master - 'WIP**' - '4.*' + - 'CURA-*' pull_request: jobs: build: diff --git a/docker/build.sh b/docker/build.sh index 6aa0678ca3..5b035ca08a 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -17,24 +17,40 @@ cd "${PROJECT_DIR}" # Clone Uranium and set PYTHONPATH first # -# Check the branch to use: -# 1. Use the Uranium branch with the branch same if it exists. -# 2. Otherwise, use the default branch name "master" +# Check the branch to use for Uranium. +# It tries the following branch names and uses the first one that's available. +# - GITHUB_HEAD_REF: the branch name of a PR. If it's not a PR, it will be empty. +# - GITHUB_BASE_REF: the branch a PR is based on. If it's not a PR, it will be empty. +# - GITHUB_REF: the branch name if it's a branch on the repository; +# refs/pull/123/merge if it's a pull_request. +# - master: the master branch. It should always exist. + +# For debugging. echo "GITHUB_REF: ${GITHUB_REF}" +echo "GITHUB_HEAD_REF: ${GITHUB_HEAD_REF}" echo "GITHUB_BASE_REF: ${GITHUB_BASE_REF}" -GIT_REF_NAME="${GITHUB_REF}" -if [ -n "${GITHUB_BASE_REF}" ]; then - GIT_REF_NAME="${GITHUB_BASE_REF}" -fi -GIT_REF_NAME="$(basename "${GIT_REF_NAME}")" - -URANIUM_BRANCH="${GIT_REF_NAME:-master}" -output="$(git ls-remote --heads https://github.com/Ultimaker/Uranium.git "${URANIUM_BRANCH}")" -if [ -z "${output}" ]; then - echo "Could not find Uranium banch ${URANIUM_BRANCH}, fallback to use master." - URANIUM_BRANCH="master" -fi +GIT_REF_NAME_LIST=( "${GITHUB_HEAD_REF}" "${GITHUB_BASE_REF}" "${GITHUB_REF}" "master" ) +for git_ref_name in "${GIT_REF_NAME_LIST[@]}" +do + if [ -z "${git_ref_name}" ]; then + continue + fi + git_ref_name="$(basename "${git_ref_name}")" + # Skip refs/pull/1234/merge as pull requests use it as GITHUB_REF + if [[ "${git_ref_name}" == "merge" ]]; then + echo "Skip [${git_ref_name}]" + continue + fi + URANIUM_BRANCH="${git_ref_name}" + output="$(git ls-remote --heads https://github.com/Ultimaker/Uranium.git "${URANIUM_BRANCH}")" + if [ -n "${output}" ]; then + echo "Found Uranium branch [${URANIUM_BRANCH}]." + break + else + echo "Could not find Uranium banch [${URANIUM_BRANCH}], try next." + fi +done echo "Using Uranium branch ${URANIUM_BRANCH} ..." git clone --depth=1 -b "${URANIUM_BRANCH}" https://github.com/Ultimaker/Uranium.git "${PROJECT_DIR}"/Uranium From d18c0703b49aba38da18810c1df226166f61cf63 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Thu, 16 Jan 2020 13:59:02 +0100 Subject: [PATCH 44/68] Fix getting correct initial extruder number As found in the discussion in #6847. This was done as a 5 minute fix. --- cura/Settings/ExtruderManager.py | 21 ++++++++++++++++++++- plugins/CuraEngineBackend/StartSliceJob.py | 7 ++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/cura/Settings/ExtruderManager.py b/cura/Settings/ExtruderManager.py index 62bf396878..e0ec6c4d14 100755 --- a/cura/Settings/ExtruderManager.py +++ b/cura/Settings/ExtruderManager.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, QVariant # For communicating data and events to Qt. @@ -275,6 +275,25 @@ class ExtruderManager(QObject): Logger.log("e", "Unable to find one or more of the extruders in %s", used_extruder_stack_ids) return [] + ## Get the extruder that the print will start with. + # + # This should mirror the implementation in CuraEngine of + # ``FffGcodeWriter::getStartExtruder()``. + def getInitialExtruderNr(self) -> int: + application = cura.CuraApplication.CuraApplication.getInstance() + global_stack = application.getGlobalContainerStack() + + # Starts with the adhesion extruder. + if global_stack.getProperty("adhesion_type", "value") != "none": + return global_stack.getProperty("adhesion_extruder_nr", "value") + + # No adhesion? Well maybe there is still support brim. + if (global_stack.getProperty("support_enable", "value") or global_stack.getProperty("support_tree_enable", "value")) and global_stack.getProperty("support_brim_enable", "value"): + return global_stack.getProperty("support_infill_extruder_nr", "value") + + # REALLY no adhesion? Use the first used extruder. + return self.getUsedExtruderStacks()[0].getProperty("extruder_nr", "value") + ## Removes the container stack and user profile for the extruders for a specific machine. # # \param machine_id The machine to remove the extruders for. diff --git a/plugins/CuraEngineBackend/StartSliceJob.py b/plugins/CuraEngineBackend/StartSliceJob.py index 26b78dbfbd..c6841c6ea9 100644 --- a/plugins/CuraEngineBackend/StartSliceJob.py +++ b/plugins/CuraEngineBackend/StartSliceJob.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import numpy @@ -343,10 +343,7 @@ class StartSliceJob(Job): result["time"] = time.strftime("%H:%M:%S") #Some extra settings. result["date"] = time.strftime("%d-%m-%Y") result["day"] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][int(time.strftime("%w"))] - - initial_extruder_stack = CuraApplication.getInstance().getExtruderManager().getUsedExtruderStacks()[0] - initial_extruder_nr = initial_extruder_stack.getProperty("extruder_nr", "value") - result["initial_extruder_nr"] = initial_extruder_nr + result["initial_extruder_nr"] = CuraApplication.getInstance().getExtruderManager().getInitialExtruderNr() return result From 4ba8b4ae91f2db4316be98ceb38c528431c7ffdb Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 16 Jan 2020 14:13:16 +0100 Subject: [PATCH 45/68] Fix Toolbox import error (and remove unused import) CURA-6983 --- plugins/Toolbox/src/Toolbox.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index b1928b4e84..2b4cbb2329 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -19,12 +19,11 @@ from UM.Version import Version from cura import ApplicationMetadata from cura.CuraApplication import CuraApplication from cura.Machines.ContainerTree import ContainerTree -from plugins.Toolbox.src.CloudApiModel import CloudApiModel +from .CloudApiModel import CloudApiModel from .AuthorsModel import AuthorsModel from .CloudSync.LicenseModel import LicenseModel from .PackagesModel import PackagesModel -from .CloudSync.SubscribedPackagesModel import SubscribedPackagesModel from .UltimakerCloudScope import UltimakerCloudScope if TYPE_CHECKING: From 6763bed95f7102e6c1c328276c1527d6ac2d9ea3 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 16 Jan 2020 14:39:10 +0100 Subject: [PATCH 46/68] Fix Toolbox import error (continued) (and remove unused import) CURA-6983 --- plugins/Toolbox/__init__.py | 1 - plugins/Toolbox/src/AuthorsModel.py | 2 +- .../Toolbox/src/CloudSync/CloudPackageChecker.py | 6 +++--- .../Toolbox/src/CloudSync/CloudPackageManager.py | 4 ++-- .../src/CloudSync/DiscrepanciesPresenter.py | 4 ++-- .../Toolbox/src/CloudSync/DownloadPresenter.py | 8 +++----- plugins/Toolbox/src/CloudSync/LicensePresenter.py | 2 +- .../src/CloudSync/RestartApplicationPresenter.py | 14 +------------- plugins/Toolbox/src/CloudSync/SyncOrchestrator.py | 15 +++++++-------- plugins/Toolbox/src/ConfigsModel.py | 3 ++- plugins/Toolbox/src/Toolbox.py | 3 +-- 11 files changed, 23 insertions(+), 39 deletions(-) diff --git a/plugins/Toolbox/__init__.py b/plugins/Toolbox/__init__.py index 212e70fd36..51f1b643d0 100644 --- a/plugins/Toolbox/__init__.py +++ b/plugins/Toolbox/__init__.py @@ -2,7 +2,6 @@ # Toolbox is released under the terms of the LGPLv3 or higher. from .src import Toolbox -from plugins.Toolbox.src.CloudSync.CloudPackageChecker import CloudPackageChecker from .src.CloudSync.SyncOrchestrator import SyncOrchestrator diff --git a/plugins/Toolbox/src/AuthorsModel.py b/plugins/Toolbox/src/AuthorsModel.py index 7bfc58df04..81158978b0 100644 --- a/plugins/Toolbox/src/AuthorsModel.py +++ b/plugins/Toolbox/src/AuthorsModel.py @@ -4,7 +4,7 @@ import re from typing import Dict, List, Optional, Union -from PyQt5.QtCore import Qt, pyqtProperty, pyqtSignal +from PyQt5.QtCore import Qt, pyqtProperty from UM.Qt.ListModel import ListModel diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index c1ca9cbddf..14305a56b0 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -8,10 +8,10 @@ from UM import i18nCatalog from UM.Logger import Logger from UM.Message import Message from UM.Signal import Signal -from plugins.Toolbox.src.UltimakerCloudScope import UltimakerCloudScope from cura.CuraApplication import CuraApplication -from plugins.Toolbox.src.CloudApiModel import CloudApiModel -from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel +from ..CloudApiModel import CloudApiModel +from .SubscribedPackagesModel import SubscribedPackagesModel +from ..UltimakerCloudScope import UltimakerCloudScope class CloudPackageChecker(QObject): diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageManager.py b/plugins/Toolbox/src/CloudSync/CloudPackageManager.py index bf3ed02de3..ee57a1b90d 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageManager.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageManager.py @@ -1,6 +1,6 @@ from cura.CuraApplication import CuraApplication -from plugins.Toolbox.src.CloudApiModel import CloudApiModel -from plugins.Toolbox.src.UltimakerCloudScope import UltimakerCloudScope +from ..CloudApiModel import CloudApiModel +from ..UltimakerCloudScope import UltimakerCloudScope ## Manages Cloud subscriptions. When a package is added to a user's account, the user is 'subscribed' to that package diff --git a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py index 4e202fb7b1..f6b5622aad 100644 --- a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py @@ -1,11 +1,11 @@ import os -from typing import Optional, Dict +from typing import Optional from PyQt5.QtCore import QObject, pyqtSlot from UM.Qt.QtApplication import QtApplication from UM.Signal import Signal -from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel +from .SubscribedPackagesModel import SubscribedPackagesModel ## Shows a list of packages to be added or removed. The user can select which packages to (un)install. The user's diff --git a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py index 2d785549ee..f19cac047a 100644 --- a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py @@ -1,7 +1,5 @@ -import os import tempfile -from functools import reduce -from typing import Dict, List, Optional, Any +from typing import Dict, List, Any from PyQt5.QtNetwork import QNetworkReply @@ -11,8 +9,8 @@ from UM.Message import Message from UM.Signal import Signal from UM.TaskManagement.HttpRequestManager import HttpRequestManager from cura.CuraApplication import CuraApplication -from plugins.Toolbox.src.UltimakerCloudScope import UltimakerCloudScope -from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel +from .SubscribedPackagesModel import SubscribedPackagesModel +from ..UltimakerCloudScope import UltimakerCloudScope ## Downloads a set of packages from the Ultimaker Cloud Marketplace diff --git a/plugins/Toolbox/src/CloudSync/LicensePresenter.py b/plugins/Toolbox/src/CloudSync/LicensePresenter.py index 7c6b231f0e..cefe6f4037 100644 --- a/plugins/Toolbox/src/CloudSync/LicensePresenter.py +++ b/plugins/Toolbox/src/CloudSync/LicensePresenter.py @@ -8,7 +8,7 @@ from UM.Signal import Signal from cura.CuraApplication import CuraApplication from UM.i18n import i18nCatalog -from plugins.Toolbox.src.CloudSync.LicenseModel import LicenseModel +from .LicenseModel import LicenseModel ## Call present() to show a licenseDialog for a set of packages diff --git a/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py b/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py index 96aa9fea7b..6e2bc53e7e 100644 --- a/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py +++ b/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py @@ -1,18 +1,6 @@ -import os -import tempfile -from functools import reduce -from typing import Dict, List, Optional, Any - -from PyQt5.QtNetwork import QNetworkReply - -from UM import i18n_catalog, i18nCatalog -from UM.Logger import Logger +from UM import i18nCatalog from UM.Message import Message -from UM.Signal import Signal -from UM.TaskManagement.HttpRequestManager import HttpRequestManager from cura.CuraApplication import CuraApplication -from plugins.Toolbox.src.UltimakerCloudScope import UltimakerCloudScope -from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel ## Presents a dialog telling the user that a restart is required to apply changes diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index 2dd1e999ac..3961f69d79 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -3,16 +3,15 @@ from typing import List, Dict, Any from UM.Extension import Extension from UM.Logger import Logger -from UM.PackageManager import PackageManager from UM.PluginRegistry import PluginRegistry from cura.CuraApplication import CuraApplication -from plugins.Toolbox.src.CloudSync.CloudPackageChecker import CloudPackageChecker -from plugins.Toolbox.src.CloudSync.CloudPackageManager import CloudPackageManager -from plugins.Toolbox.src.CloudSync.DiscrepanciesPresenter import DiscrepanciesPresenter -from plugins.Toolbox.src.CloudSync.DownloadPresenter import DownloadPresenter -from plugins.Toolbox.src.CloudSync.LicensePresenter import LicensePresenter -from plugins.Toolbox.src.CloudSync.RestartApplicationPresenter import RestartApplicationPresenter -from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel +from .CloudPackageChecker import CloudPackageChecker +from .CloudPackageManager import CloudPackageManager +from .DiscrepanciesPresenter import DiscrepanciesPresenter +from .DownloadPresenter import DownloadPresenter +from .LicensePresenter import LicensePresenter +from .RestartApplicationPresenter import RestartApplicationPresenter +from .SubscribedPackagesModel import SubscribedPackagesModel ## Orchestrates the synchronizing of packages from the user account to the installed packages diff --git a/plugins/Toolbox/src/ConfigsModel.py b/plugins/Toolbox/src/ConfigsModel.py index 9ba65caaa4..a92f9c0d93 100644 --- a/plugins/Toolbox/src/ConfigsModel.py +++ b/plugins/Toolbox/src/ConfigsModel.py @@ -1,7 +1,8 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from PyQt5.QtCore import Qt, pyqtProperty +from PyQt5.QtCore import Qt + from UM.Qt.ListModel import ListModel diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 2b4cbb2329..0081f5cb8f 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -4,7 +4,6 @@ import json import os import tempfile -import platform from typing import cast, Any, Dict, List, Set, TYPE_CHECKING, Tuple, Optional, Union from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot @@ -19,8 +18,8 @@ from UM.Version import Version from cura import ApplicationMetadata from cura.CuraApplication import CuraApplication from cura.Machines.ContainerTree import ContainerTree -from .CloudApiModel import CloudApiModel +from .CloudApiModel import CloudApiModel from .AuthorsModel import AuthorsModel from .CloudSync.LicenseModel import LicenseModel from .PackagesModel import PackagesModel From 46133fe2f3977d19f3667e9ba9598e8caa032910 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 16 Jan 2020 14:44:37 +0100 Subject: [PATCH 47/68] Do not start Sync flow when there are no compatible packages to sync (and remove unused import) CURA-6983 --- plugins/Toolbox/src/CloudSync/CloudPackageChecker.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index 14305a56b0..78d13f34fe 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -53,6 +53,9 @@ class CloudPackageChecker(QObject): self._model.addDiscrepancies(package_discrepancy) self._model.initialize() + if not self._model.hasCompatiblePackages: + return None + if package_discrepancy: self._handlePackageDiscrepancies() From 8dffed919526c3ee92e977deaaafdae904511551 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 16 Jan 2020 14:58:09 +0100 Subject: [PATCH 48/68] Fix 2 mypy errors CURA-6983 --- plugins/Toolbox/src/CloudSync/SyncOrchestrator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index 3961f69d79..674fb68729 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -1,5 +1,5 @@ import os -from typing import List, Dict, Any +from typing import List, Dict, Any, cast from UM.Extension import Extension from UM.Logger import Logger @@ -52,7 +52,7 @@ class SyncOrchestrator(Extension): self._restart_presenter = RestartApplicationPresenter(app) def _onDiscrepancies(self, model: SubscribedPackagesModel) -> None: - plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) + plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath(self.getPluginId())) self._discrepancies_presenter.present(plugin_path, model) def _onPackageMutations(self, mutations: SubscribedPackagesModel) -> None: @@ -65,7 +65,7 @@ class SyncOrchestrator(Extension): # \param error_items: List[package_id] def _onDownloadFinished(self, success_items: Dict[str, str], error_items: List[str]) -> None: # todo handle error items - plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) + plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath(self.getPluginId())) self._license_presenter.present(plugin_path, success_items) # Called when user has accepted / declined all licenses for the downloaded packages From 18e5d76990536ae9d5e211cbdcbdbe0c32f219f2 Mon Sep 17 00:00:00 2001 From: Kostas Karmas Date: Thu, 16 Jan 2020 14:57:41 +0100 Subject: [PATCH 49/68] Fix button to open in-Cura Marketplace materials The button was opening the online Marketplace, which was wrong. This fix changes the behavior to open the in-Cura Marketplace at the materials tab. In order to achieve that, the button calls an action called materialsMarketplace, which in turn is connected through a Connection in the ApplicationMenu. When the action is triggered, it first launched the Toolbox and then it sets its category to the materials tab. Since the callExtensionMethod does not allow us to provide input arguments to the called method, a new method had to be created in the toolbox that changes the view to the materials tab, instead of immediately calling the setViewCategory("material"). CURA-7027 --- plugins/Toolbox/src/Toolbox.py | 3 +++ resources/qml/Actions.qml | 1 - resources/qml/MainWindow/ApplicationMenu.qml | 11 +++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index dd01458a32..0436befdb9 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -779,6 +779,9 @@ class Toolbox(QObject, Extension): self._view_category = category self.viewChanged.emit() + def setViewCategoryToMaterials(self) -> None: + self.setViewCategory("material") + @pyqtProperty(str, fset = setViewCategory, notify = viewChanged) def viewCategory(self) -> str: return self._view_category diff --git a/resources/qml/Actions.qml b/resources/qml/Actions.qml index 3c978df115..8a1b2092fa 100644 --- a/resources/qml/Actions.qml +++ b/resources/qml/Actions.qml @@ -192,7 +192,6 @@ Item Action { id: marketplaceMaterialsAction - onTriggered: Qt.openUrlExternally("https://marketplace.ultimaker.com/app/cura/materials") iconName: "configure" text: catalog.i18nc("@action:inmenu", "Add more materials from Marketplace") } diff --git a/resources/qml/MainWindow/ApplicationMenu.qml b/resources/qml/MainWindow/ApplicationMenu.qml index 30e44d7d3b..9ee9e75ad3 100644 --- a/resources/qml/MainWindow/ApplicationMenu.qml +++ b/resources/qml/MainWindow/ApplicationMenu.qml @@ -163,4 +163,15 @@ Item curaExtensions.callExtensionMethod("Toolbox", "launch") } } + + // Show the Marketplace dialog at the materials tab + Connections + { + target: Cura.Actions.marketplaceMaterials + onTriggered: + { + curaExtensions.callExtensionMethod("Toolbox", "launch") + curaExtensions.callExtensionMethod("Toolbox", "setViewCategoryToMaterials") + } + } } \ No newline at end of file From 50fbe02c58ffe0d76d74715d05253829f5f78836 Mon Sep 17 00:00:00 2001 From: Kostas Karmas Date: Thu, 16 Jan 2020 15:19:51 +0100 Subject: [PATCH 50/68] Add comment CURA-7027 --- plugins/Toolbox/src/Toolbox.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 0436befdb9..85bfaeafb2 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -779,6 +779,8 @@ class Toolbox(QObject, Extension): self._view_category = category self.viewChanged.emit() + ## Function explicitly defined so that it can be called through the callExtensionsMethod + # which cannot receive arguments. def setViewCategoryToMaterials(self) -> None: self.setViewCategory("material") From 59331666dbaec53a8f47f85aee04a92544977122 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 16 Jan 2020 15:45:42 +0100 Subject: [PATCH 51/68] Add missing cloud package manager CURA-6983 --- plugins/Toolbox/src/Toolbox.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 0081f5cb8f..f1c7c35419 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -21,6 +21,7 @@ from cura.Machines.ContainerTree import ContainerTree from .CloudApiModel import CloudApiModel from .AuthorsModel import AuthorsModel +from .CloudSync.CloudPackageManager import CloudPackageManager from .CloudSync.LicenseModel import LicenseModel from .PackagesModel import PackagesModel from .UltimakerCloudScope import UltimakerCloudScope @@ -43,6 +44,7 @@ class Toolbox(QObject, Extension): self._sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int] # Network: + self._cloud_package_manager = CloudPackageManager(application) # type: CloudPackageManager self._download_request_data = None # type: Optional[HttpRequestData] self._download_progress = 0 # type: float self._is_downloading = False # type: bool From d6392c117d9d2819d1de9fa077c24ee4cbd35422 Mon Sep 17 00:00:00 2001 From: David Souza Date: Thu, 16 Jan 2020 12:10:24 -0300 Subject: [PATCH 52/68] Add 3D Tech Printer (#6948) * Add a new Machine Profile for 3DTech --- .../3dtech_semi_professional.def.json | 40 ++++++++++++++++++ ...tech_semi_professional_extruder_0.def.json | 15 +++++++ .../3dtech_semi_professional_platform.stl | Bin 0 -> 587184 bytes 3 files changed, 55 insertions(+) create mode 100644 resources/definitions/3dtech_semi_professional.def.json create mode 100644 resources/extruders/3dtech_semi_professional_extruder_0.def.json create mode 100644 resources/meshes/3dtech_semi_professional_platform.stl diff --git a/resources/definitions/3dtech_semi_professional.def.json b/resources/definitions/3dtech_semi_professional.def.json new file mode 100644 index 0000000000..fe466192df --- /dev/null +++ b/resources/definitions/3dtech_semi_professional.def.json @@ -0,0 +1,40 @@ +{ + "version": 2, + "name": "3DTech Semi-Professional", + "inherits": "fdmprinter", + "metadata": { + "visible": true, + "author": "3DTech", + "manufacturer": "3DTech", + "file_formats": "text/x-gcode", + "platform": "3dtech_semi_professional_platform.stl", + "machine_extruder_trains": + { + "0": "3dtech_semi_professional_extruder_0" + } + }, + "overrides": { + "machine_name": { "default_value": "3DTECH SP Control" }, + "machine_width": { + "default_value": 250 + }, + "machine_depth": { + "default_value": 250 + }, + "machine_height": { + "default_value": 300 + }, + "machine_center_is_zero": { + "default_value": false + }, + "machine_gcode_flavor": { + "default_value": "RepRap (Marlin/Sprinter)" + }, + "machine_start_gcode": { + "default_value": "G28 ; home all axes\nG29 ;\nG1 Z5 F3000 ; lift\nG1 X5 Y25 F5000 ; move to prime\nG1 Z0.2 F3000 ; get ready to prime\nG92 E0 ; reset extrusion distance\nG1 Y100 E20 F600 ; prime nozzle\nG1 Y140 F5000 ; quick wipe" + }, + "machine_end_gcode": { + "default_value": "M104 S0\nM140 S0 ; Retract the filament\nG92 E1\nG1 E-1 F300\nG28 X0 Y0\nM84" + } + } +} diff --git a/resources/extruders/3dtech_semi_professional_extruder_0.def.json b/resources/extruders/3dtech_semi_professional_extruder_0.def.json new file mode 100644 index 0000000000..4952d274d9 --- /dev/null +++ b/resources/extruders/3dtech_semi_professional_extruder_0.def.json @@ -0,0 +1,15 @@ +{ + "version": 2, + "name": "Extruder 1", + "inherits": "fdmextruder", + "metadata": { + "machine": "3dtech_semi_professional", + "position": "0" + }, + + "overrides": { + "extruder_nr": { "default_value": 0 }, + "machine_nozzle_size": { "default_value": 0.4 }, + "material_diameter": { "default_value": 1.75 } + } +} diff --git a/resources/meshes/3dtech_semi_professional_platform.stl b/resources/meshes/3dtech_semi_professional_platform.stl new file mode 100644 index 0000000000000000000000000000000000000000..8f83d21563fb9b6f39ff0ca441abfcfe971405d9 GIT binary patch literal 587184 zcmb?^dz?XT zjN~JgL`kURBPo@9DYx|7Yp+>z-rF;$#~;7*dYNb0XT8^2`+i${AIJIs`+QrSW$l-NWSg-f2KE-y|sbk7`h?TaS&NtjPND zLp-u{GfrZUh?tmXrHHR7;vK7WvzqtKo=ghsTl9L>x9BKC502if4t~brj`RDK&Gje! zmTXekt=%9zYvxz_U&#i0{z?c}8*9@nJsyI%L=q$VhyLD@FW*R`1La=X|3J9-LDnJk zwIPU0Br(GO8*v9NouamV{Anh|WAlqs)#BM78v58XQ&hoUIo#*h`R?X_VyZemITnGE z9B1K(@zEb!zhvSe_L7d;o$m`D9d^K^o6syj9)h?;5?Vxb#6{bSZl9zE?b~DGk-zv9 zRpGwR42`IWi!ns>_S#ca*TRB$vF^d>j=j&Cc!<3ek9{rg3D0=pYZDKlS$;eOafu|f zi0Ft{H(yZ|EypFUQ=!7@=tU`gMb(IZJ~O2fQ4ud!wSsErZsuBXdl9u}EpyE(SxDtS zAZQ;G@#f7cpqBl?Z?LpuE%5oduKU{2s;Y;Js14udWL5RGMbz=nKg$gLFn1*)Ry8T2 z%I)|pv)dEPR#-Y*%(V*lrKw!Ma1NHtJw<)mLh{4ML|lA>`HYerCs*&n-jh$aH906b zML9U zcqzZ+Yj9-YVdaNjH1w{QqTy}%KhFx!U#p8(uJT_|;>x?CYr>M)%ap~L9Ws0it6w~ucO;L^iWZH4g zzTMB;vtgAL50WAAAWO#HRyy3F`*9OLkx#!77fblsH`CPE+demKRI%UERip7z+6VmK zA4vsjk8}ysYx9Z*+s`cv6TSUh`34E=R$4U2JRX9$L{e;8wff;M)j4s8$*GEmDywbd zn06e>NzP5uFHginE<2xt5R4UBK9`La4U|@AicV35l%XGEsMn<4+$9e0}KhY0w~A7UCM*@#}ih3FmgZcqhRGTKHg?03D& z-Ht86`WRdXPt=>Dc0SB0ig|GQM7kP$g~Kf!F22E|WY(Ng~%<`H-IdT)6d`Mxz=LQ{C` z2!}!`9#v}ARVmMJi-|{?N>E=uxHT)h={L#l>KF2FskqkNl=91BrRIHYdi~QU7VDCK z(9k`8T&!Au`n9%_p8Vy04PF1guEwfrV_R*T+xMWcYF?>Nx9zU*iA7%l%+-0v)1^(kyG^+*JwM}>2`sNd zliXa@#%?Y55SMAk2|^&3&}_?Zp=2-bEpKk=m2XU9-zgn+pK&Df)>CQK=gsP<^1s3P zJatA#JtE5~&oKuv-EhQdrLJ4@!*sQwERVu)bT-0E*EK4qQ6D0(ydv7t;o*n{Qx{X+ zab9hBoBOtU#OpNT<_9}pT&&8aaJg!8ez96QhRYI`F~|AfzDr3pi|{HArlQc4AH$3N zyfyWeBc=uiBalmIjzg)ALp=R2y5+qhn=n5x53oM!wxs2)?!dE;c&DE2umR(Nwqpsk z(+Wp@II^_IiOV;qg$A!1WZEmVS7?g)W5+6OYqQ}{R?LGC$R#vdM0CW((s22?$*TVr zE*TX5+vc1~$*dYUJ5vH`Ax}*M= z>|H!`s($HQ2Qy~P?5IlrekAjWXYX1zXlh4wrs%gO*RUmWoS{E`9Q``q^Df!9{{L>= z*5&wqQ{sfC@aYXpZ=3hczN{8J2!UKea}JUWej?u3+e+zCk!NJ>onKllF3t3|=EYUd zA~FIHe(FDvYvLQXtD_k_4hlvfSMAPaRL@7_A&{$N?y_p^S3C*})`473q^$bxk$4E? zYV|`|RpZUgCKZBpAlLSDW%W2Z9s;>)<}IgAY~qont>d6BB@g}b^X5U9D%8VLf+Yj% zjh??u9pNsTQ6T#FXMGZ}#9=+RM`Qv&bIN00Sh-g{Vw z(4+(PIZhA)xrAmNUwJ=o`Qnk0?$b4^*TbBW*b*1!tm9l)ZhdrrxUcCyj(@&Hd^h5p)G`qD`~jG zhU=F$0!LoB_J)#z5m=V6pV#GT*wLu>)ib8FON&f$zI$ZDw)s0gGOdr$oL2D=#3hm# zL8A+?jq;#V8t<40Oa#hPP2t)@XQ0~HhrBng6!JvZ?2Nsl7rVW`id_Y#M{-cheKf162iFgNWS{PoskZ{+C}N$;0E^#Q)Y-UlT)&K>LPM@PLm z+|+ZdokCN&vgdqPV@-6#!*v*(h0XXaN$vgNeUl=B;u0@y6osy8@|bD0iI#Aw z9~8z2;i5OlCE>)0j~@Hn(Ln9!@|8(lYH@bn#PeEgi5%zUB2S0Pelyh6!@8;0xt$*Q z(%6eFwa{o4>590knoKe~qx1%Mc)u$&Nww3v`iy9I<8XKR7fnNi8UA`(z01n;exZqL z-mJFjU_tJ?ST_BoM_K^l5?aEk#b6!j(|-;X|AjW3DVw@P z5iac#bKCbZ-Oqhq6`Oc{8SFpsXtIw$&qK zPSHP?wpGPTew#U9aa50FhIoCvt%tXm{K#|Na&>SL_ff4UE>jEF@EVzfbLv)U(afDt zwXAzv{aZ3H9;naO@oUu|w%py9=jU@*Pf{1Jd}B(Y&?NV_mnW(A7dYJeSvfCPji{j$Ad4+@p78;~|i%XL@xt>)`4* zI*@BlqkGl1{P7UT)u~Voy|%bA2OZVdmQhs?v#k72l-H|uYdIqv)q!qI7jB=R zjy=O8K)wE*G}J4&<2h3Y3%6gcYbw8ydPfq4W*zYm#3hm#QSO10p_V7QxlXq=30L;F zRUPi+)(2}Uwi13UsP)-5af05pAExV-8>jLY7r&+4?S6=EpU2}w84r@2 zH3N!d9$3KRD+!NHE3AW<&zPHz^IVH$@4iuQB$3>~BbTe(w|!~aL7^#JVor200=a}{ ztM0wDmL{42yX&doe z#;oY}8(%d2M~k`#w|04Et|ILnx-Z{WaODz<&WcAI1aXNZM)-g2J^ITh({&%k`}Kjd z32)))Bvt*)L{lfyGLzKqw_h}L@q>Q2&V%dzN_w@zNVj?SLm3!zjIE4IICb|zxK)#IjzQDc+a|rblxL}^1LX?Mh(psBMQ;ubFgiZ{q=2`(!Yq@b zl0IaK(AS0_E|J6t|8K-i4Nue~8SaI#M|NwEQ&s-pp4#W98ijp0av|DA;Ft=z?D3V4 ziC8{fh{lnc&reoi$w1#2yOjK1d|mY70^V_7_U=+W>N}*{LdRK`cd2T0(_xF|JdcMU zE|J6t->!&qcFTZ>GhftDix15(#HUx2rBu4CP{~xc@ z=`*Isk&zy~DcW$E_DJHfsU+d$JS5Z_OOPf@kzXbIju*h)X0fVrKDOKnwZm}hrF6T0G|wr01NbufBn%3x+}6uqK~l=$W^+=%J7JP7Q{gySNnu^;hK48XCZL3 zB&C7$3Qg$~I9hVg-;%Bh&-fyfxJI~Bbj!mf{7lhw)uRW8`>~*yA0769dgWFgfA*bH zTkqF#t6imLRXtkfaL1W8E!i8@_Bm5ZR_!d6nWo-1t(wpzGhtn+%)&Ex4Ja6aTtai6 zH@)zP8+qtfuX4p9^{_PTnAcY2dF4pv>D7@HQerp`@n)&SWw$<`S8U|o@yeZEi-cb- z9iz6kRjLcOsHo3zGTs~M{rcCZW@MRbd8y2gde>E33D9m2eF6K`<&18aC6`PooPiehB?JNMu%JU`gG-< ze%rc~zf*6U3v5t=NZk&_Vq=aeLOSJ%cn22LRk^=n1f~zpMIGnD$@HWbpJX2vT!#^w zX^vXq4P|G>AW)KU({IFE*zd~*CpNtigXo!lU3mI-4!05b z2Cd5TO3Co9Iy^?T5ee(oG^n;SRtM^~5r;1HXmGj;%R(I}$wnmi`b6(A#UfCWjlkIt z>Oe`3lQ^o8_i>RsO`fOxQX;d>5YBUQtGN(urIow+fhj7f zIk%!X{;dYG|P{NATE)_h}VD2?fv(Y z4ly~1Ga<}Por8c7E|P^@63#jJ@e75$0Z;K+GO2@v5SqB`Jr+dBo2$7HP158ckF7eY z!~bG)5KD}3)304hFgH<>jlljK_O`WV<#n9~&%7&Agzh-De={@1f)xvw{kB>p1BpY$! zQtfc3M|eFEb)Y00f!Yz#w`l2b^l4tJu@U(XGz_0UF)79>)NLb*j%pWPHFk0g0wvi9 z9Bt#O5lXTV=;N_!X9Im=fc-ttC&Wg~E0w5byinIzHLz!W^~$u6q06P{z0`t>wleoy z8@=d;r~7#2(wZk=KiGZva_!G>D3eNz)LNo#9r`4+N_dg`yZ%WHb(_d^#x7UYI3oSv z>QBP%pAD;+lDPDv%9+KV%rGr=TISuEN%^_e7MkV9LlBopV#EuVeh(F^*wx!M^!vnC zcciH@-*5@9aC(y3e3t2*H>Ih8KZ#Y0qrVYv_uCcpx!vj}eJ~HY9IvELlh-uij#Pn3!V>g{Bn!@_hBoT+j17(Q$$h z$R)HN1EwPGs0Neu3gkYMYvp!KR`YLQzvrT(CaV>PIUG@XED9_1yo$yvX}DVNICak@ zdq2*7eRJFU>TVprV!0}MJNd^__i+)=E!_i-P*VVhYg%H8h1%;}@nn*6}lS;DD|*$CmHH^?R7ehiq3c%3sE>JvO{Rrh+X z>Q$fVqr)2NvzAOFidJj=H`pU%ZaNNmwvoRYVKIgnbD=pN-_{)JHcRal6LXA*v{(O* zKrW#<4llmj)|>Eug}fiW-L&!Gq~&VG?VRqIH)yTnH2yR-`oToLzeM(dN&A;WZp}RS z*}JB03(Y#>A&5&PF~a|=&#~wg^H%eDWPvl*QBJzvyO(l>2%Ntn7ovTB>eY}7%PZ=( z5FU>DaHN;It&VP$?lUFSMpU>jNqbXq`6=^FlD>z9>GcB}>9LQLtGP=$dAUBh&RcZx zQpPLOmaE4q@*B(#%qM+r`@v)1H?&}>mW1aXNZMx5y| zB&7d&Gh4N+w|-!fI<|=YNNOBOQY+?snn_#*S0-uiE%7g59Q}=Wm?oH3HD9}5d#6;( zA+2zPga{m8JI;hHy|)aQ!oCItS5DH`Z}13d=fjh9$*^gzAMp^xC6X8c@o=ZMC%-q| zgQWKHyEI-k+Bb2MI+PXe^HaHMzj2bPo3+o0T!?m@mG9;CzUbfH`!-ap9?l1Fo?z#B zJiiPR5Ae}2JBqICDDJ&uW0-EzAX|kd#PDJp!{Te=IFoWc9_`tK?*ph^Hcfl{%J|by zX*;drAxMWvib-A19^?2U89UEE3+M5=pMTPn_A_ltWcLPG!X=z_AVRq44RT31=U&S$ zH+bV-?cl8{bt)0_0PCZjAB786_J)3?EUQRIwHr(7y(zAPLbHx|2;ve88R7q>6AFI& z(R@Nk^_`MaTe9J`I^ItsY8k6!1%j+XZvhEs>4*?6dV^dN&Z+yb)6o0la3!OId?SWd zeb2NwLKE7Kc@P4*gk~KigP(|pGesPQt!h$LH9cB8hqgq0UriOeH_^O7E<}rb`i*!v znnZLk0!Mc^>I+6Bjk{f49C^2~3b}BGY+Hq^Du_TyHUdYiIQB)fji`2GBNcrswk%;8 zvk`e-sjn)Q=9U3_IJ(P3x8j-1y>xU(@5=kvn^H2XWU0*W(zuieOY^~!2A&^Vxn3CugP?OXshkF2Q&#{*vo$^H7 zPd}NYXYq`{o&nLqO}`O$@D=jYXT+S`X=+p?R-cVPt^&Ez)ahsAA&~3n#YyU5t9S_H zO8<3|9;?SgV9YVLh<2P~+xvL)KH6f&yK4rN${g_zmlB~VrdVQv5y&Mp=ir~shj|Ti zKVfu;rvu4F9YS9lg1AIdOeyhj27xn+;2MQ92%K4>6yQ+K7&#p_DSEfzS;~j1p&bOVQ?<`_k!a3#@Z>iv=Txe_%atjo_$yIGf_{?Ad@IM=(>>-4xZfoW`&5EZf7D?}_jJyDhE!02EEa$ya2oQ|V!azhK-nLMwQXQJMT zvr7iav`a}m1f_yV;gFs1+GrQSmp(ZVRS@9t49lB3cX}Jz(qE##(5yC}p#CD=3JSHAi+m0zp zZQ`K>*?n!h=<(>5r+5^8zU?+$cKNJjwXt3}y^l?^j(7;-5=m&4+z@+3ry%{KhiSf3 z_DZH7&Tr{ P&*xf<9rbu0a^TuMmqN2MFu&X0Slj0$xfG}xrBqzNH}CQGERrEteV zguJ<$3(=1ANyGP|8N1on`Ot;TaC&u4ABinV6`Ern4?$cai4i1&pNMSqcL2Fwh{OS+eegSBe1nZE<`(yKARDxvl)byR79Fm6z>PLbhsP79j`_; z;CAWD2PdeKAM(t5-^5gXI+tk+;bG~k*s+!x99^=NIP8+>V$HH z)t<}r@M87#o(|JKKjk2DIZl0jS~4C2xw>zvr+(93S|l@A2XZ-)dV05qThw3#a{YN% zJ$2;HcnIXm{Z*p6Ts0m7xfah()F*10Q6-Mvvs? zls*)0r`4IRqqqG260Z(QZ%NvPCPeFrTf*75JyJl{LwDBj{j?N-ZSbKB9l||&e3L`F zi*H(WZLaTxJYcv`lE|lDecLp&;fO|kHUj$zl#BXo1ir!h)KQX+ko$ru6uGd!vJv~n-Lw1zeQgNjvhO4>u>3~%p%PD;`k~K3W*%&Ht6A~t zpHwDuYKbC-mT-;(B7}?HAeV%5{pg=>fLHdvi=#`*ugt(x8(8wO#n7JUiv5a&-;%YptJ$5l`;&^~0EwmVy6w>#hZ% z))NMpc!+j-v+vWJGjlEHnV!&WRXhZ7iKN(g^r@m(wkL8tIu%OOCw-Z=TOYQK{zhEn z!c+)EczXud%ifQ`nfKWdiTWNtS&af6dIi$I57`g*??dLW7n|)2?b*Zkc#XZSQ0Ce) zH<@v}lqKR4nqwXhL0lp!CapZ|^@~55sH*C{Lehb~DE(iah=*J_8VE$VxWA11&IMPd z>Dy3q+U=XuC{51?V{;At9K<5}jkuWSm}@A>ab8-!H1z)aT%)kW2~Dyjt%wdrAeYdw zsjI%Pr%zFFX~0r~rN?nrrzg9cCiO8VUmDlBU!T6whOkbrS8D%Y zhH3u4kFX{AI6FUL<+47<`SYErAytlNUScmv5t?kDHFHx~zQCdH-z~m8G_GlX&pUb{ zBPahvLSx?0|K*{0@Ewg5BKH6iZQr&)A^b!p+8X`B*5s!92{hP7;l4zGuA4i_cso7s@QC(8aSCpt+o&7si&8;EcJ7w!9dp9S} zHNLegI(e#ejW4&7ltvV~NNmtAu79@esr%k{CfU_=$LOqMQDYCEvdB zB#T}DMD%wW_(m8CLytC;Xvz&okRiT6Mk{Rh=`o zZ6!GpTckPW@esr%l48@UO3eqAzPZ}8yMv}asHSgcdi%M$*)-{tC*l?Ux~}%wjpahL z<1DGr!P|6&J^R~ii0bpTo3@e+u>@(hDjtHkL{e-#&|IQ002M<(qweKh<2P8Zmi^$eVr)b&OoMHTv^Cd?FX$2u(Ux+`dI^KeseXM-T$J zgk~N7Uw!5$>*PI!$=P6{Z4Xll;Ya&;W!+Lh|3`E%0_DodQbGqKkPA;W1|yJ5&OZLT z4s0_}E}}_-JP{AyU`vFOgqwcp93lc~=f^yz%)-$F`4atEnY8r!WWGY^bg8$(0=R zF}fte?K#MWy7jr{6)UzZyp3QX#$1CUc&X=wJkL3GLg%6nR?s=_6+p>0R)t zl!d2jtG#!`#vD@w^+ddc2kNMyRbvq-$wqYOUMI(OYLsLnFb_^#x>J9@l*e)!LzfKZQjxz=I~g{HD}e(D;XBJX6?+8_jS35^yJt@{ti1I(LfxjXgA z1r)t-{%zv>7J0;5&#jJ&a4lJM%>) zbz*pIS;F|~`z&9(!7bmbgJ}~{yU?Vs>D#N-fdw)m3ql~5&}b3Sdi7&_WwkELp8-oa zT8pTNhxJ)bZ81ksxw21Z`CK;I*FtBi`Mm4Za&1Y7u)PX>9k!(RUajozAHZiCFinJ} z)UDZcwVC@6?KnXQd5Pm}{-B}THaE}JetUU?zC(z=ws~d$hVY!+Jjao6j)$*B zueWENqM|T7k719mw%2Ir-m-{wU<`#OxqJRvrOvnAlvU4z5XdF8*yGi45M8&~SD6xzV1#f9LUhQl_B{`!=z9iuyz|QADQZV~PTl4c#;Ww{9Ip4U|LW-0 zIhV`SL9|zBvZV3)Rm!cMnHBRO1ab+@*8HdLaJNO?hs;cPV9yO<`No;#8gU8DF@Lgg zvODRfS!S0EV<l51S{)|{fm}jM8p&@&?~M9)y_8FNK#vn+?|pHcIyYb3 zvierOTU}1hktOxM3#sudxa14X@wj}jxBGeXOcQfV6QPM~RAiODMeyUS)D1!)m(XIr z{OWcWPE^Sm9C~Y)cHyTs5xL|%#=j$wOMLVH9U=bs#3i2g|BgT|xz+FA5y*ves^FNT z?Py^z0&ktcTY&I(Aji?8e3!=VWH08F(3Iy>+pkgy>-J~mc@P4*geIxl3-+6*yoZMT zV|3selF(O|N*jS?RA`clX!^fA5f|GJY_;vSMEI#~$2J_f5bZd6%ca$N%?x&cNM1y+Y479uUIj^3g1f|JpG>*<(!hYCW#D z34%wiI1j<^uX}$+hiIIU z*$AAI;rI&2T{Z&STC@Z$v=R6Q>m5q65vkj%sPVn|8!X-HJ)rVG!Q+Rc!|u{wuaobL z`WU(oz<+bB(gUh*QARH=T~*~hC5XPudb*b;e;FcVg*El{ZK^yLDRys?{^BhoB;1$I z6y-bbrSkuAe+c)Dd>rjf__I4)@xXb4&rdms9w~Uv?`niU%8;5P^={|G6SpBP^8Xz{ zTp~#j@@v;Y@h{=GK{X0TGT4^XE}Nq2?vjy=XrR`|JvUl^af;s{7ozp7@7vz)OZR7* z9PB)7yq;};WA;BTR~@f!yx{M#NI2KQV{4M#LbaNiaY>nP!fI|`&U5vBSQT4k)4OU; zTH)J7`C)s?`R~xj&kv<|G!HBO|txt?*GRmYP>l+d$&($jz>HMafu{G6q_~BO@8*x(91tA$k=`^tUB%C*rxv)R;zY?p80Lt zj@2*~9Eax3dNyqOs2Wer&fe`4xfD~OSw}nsafu{G_^B0fAFDV@75$_#R#PsM-AIB=qR%w~MqbMm#RtU|h8xKKTA}J<6^qAWBRt+lQjzjZ* zJ*zkKj|0bMW$zD)T(U}N))5auTp}sPDmUeqQTisf*jlvfrBQmtg2V0o!HI9o%HAIo zxnz~ltONH4b3zc8gvVH=cT6hk^-nHK8N15sJ13Y9P55BajRG61&~?F%cJg7UV*- z&rhunmPBml5$!n9>Iv@Y%=_H?XI@Bbbd%EGB8zQzu|2kQct;ji(Bn@|_b-Q3P{}_t zJ+(wd)n$?7hmWCi6TTlZ=DGMG`@Bra+AIHW@$473iiauIf5*XnPRuExDbE}2o*A$+ zDqN%mxrAozGYV~uwr|BdqxeQ>(y^+^OxPI>LLisWtfOeIZ$h=^KkL%}==Lr7x}W3? zg$o^1&)w!NQuMb{xW~a6($w}Ts>KJ=NA=j%Kl)=PuR5c4p-Jvz3ueGxQ4j*TgeD8| ziFjC_G0*MMnU9Hh;(U=R?%Ya z_aB%hLX(9TqZ44)G6;cOLZd}Q)2`+1`rA%iQxAStM%8$V=Qy1+N~p%`nYMQ=QI^nT zRU3UyJm+0Y;UbO5B{YT8SL0f^T8^1b^f?jMz6M;vg(e=%8yg{SuI56t<7jV2mwXh7 z3$+VP@%Z$U@vv(dgg`E#Nh&@O7wa01Sp z;_R=P6TPP{mr4mI3x$`F=ue9}Y~8rqhxQ6ha`)abK45=PxJV;%35^!9Rp=dyR@v6# zP8`Y-nyhMibX>sRop6yxchbR|mvMTS2aj@qVgg`E#*{bK} z4RL#28EACi8=*l_eeXq0B49bqergqn5-xg!ToNwa@=JYGIaM;t zI}+EwPgX9M?ZqzK^c(R~e$nd}N8UOJ%fN zivFI4UlRR!xvcz>6ryQOA8AS5e@76PND_qDrBCo=`POlW9}hczI#pf4P>D;*e~Vd# z!qO+3suugLNBIUAT=7eVbK<9DVMv z^w@~%_v`st4t{+o$wvHnS4DkRJGLaEBpZSLVptMU65B60>{E#@U4!#R=bJxU)e zeOMoD#KQYhRE;u>z?Pa?4DlAT5%>o6p`=)Zv_6PNeKrEyC~Ui~M)-YH)(9zUZ^iEi zDV)|CW9vujn@i|^oAU$n22!6xLeJyF0JzAo%Ow<46 ziFkASmQW`y@#qzMoc>8A^jZe@U5;~C=LYWBpYHM=y06(LY}>It4?dewd+sQmR=i5y zX2>XYWk1t_`?W4|;cl;u_v<@~5NcCothI)Ax9!ees~NB0rHNCO>C0{q9A|Cc+XrYbv zF%b{7qdr9Y{FGL>^MhQrZXXlzaK3~iZ=7G5^~5dO3&M;?EA-o}Iu$ByYVEuZoAh0* zHqCiX9v1(GATE)_h|YzFx_@}RLu4KLo(fG|=xgG~f<~`cM$xz7yF#P%*BxcAh&(K` z--L;WD!)aw-&o4TLo6YgLbFxz5X2>t7~$KcXGPB!&92W_x3T0q&WoRX96DC$dGCX6 zGZS#!R%uL<{uW5AkGNSKjB0-a6OWUNx9FQ-IUZsO*&sAqMZOIGh9EAH#0YQF&!I;q zbkxWAVPnGr-Yi*hdRcD14tx2~2|9`;4Zg=iarT(|;_x@`om zMtempwDd+x8x892|t za}!uswEyn#;_m9s{FO>9qe7G1=?yc&T?_Lq-FD1z{E2-y_U-zsaWxux`V@qTd9Te; zeHUj5Q`*ILk|{LDBOZddL=q!>yCNRW2XN$#);fV+_n21@zdl+udYE+}7ou%Mfy~jWT$WXcwh@=tcJ!9@tV&b819v-c_*W>)al(tiswDJ>i@l9H; zllqB5rYma?!eugPxBWNxwcduZ*!?CuFxOgS6J`J?bq*cL{c~!wa>|sdxGZ-Pw&5Ia&U1+PuHkhja8BdBvWY4k9Y{;5=o4B zeE!MkExWsU+op`!cwj-Qihjo<@08`K>ak10|4v4ms^O%@h-b(QYBZp&Kz(04lJh(Io(*{aW{cJmqz{W-d{`}2ud649!j>8WO2 zi%Utnk-gpj-1wof3S|jRT!%KUQhVnY&$22Afm}kfRqMJZySM)|!Q_Y3HIlpH_Ko4= zpYte8!WnVlyAJN3ALTaVE0iTPp}SvNrH&3Ok!4j70=a}{9sb{lSM_iaJ&%fQuTESl zqSyCx8ihNNBKo{RECMC@8mN9Gk^|Bpb1+Nn!1=5sN@cHlk$i!s`6gSOiM45!g>)%u$k!KrW*DCvmTj zscs|a&Cc<>PK{iMwh->|!sGQW3E#hqcUvC+e4KJ3{B2{21YT;@AvV(;HNx=yG zE(D@&1ih*G8uu)8>X%MSQ~j_J3p*>f;jGH0Mqxb=nh-QPqd6SWdjC8Cfm}kPMMOuu zoIE+`4Su1<__w?IrYU39$;GSHzE61uiE@QztMpA%|B4_kk;I7Pmz%lgeko&g;5iTU zJrsSOzGJfH%zA?+hV|cMO>eQp9%*aw=OEQJ#m}^h+e?<_NNMgjWn?vsISF0HvXQnLC zzG{57OK8$ju+I{G&mD);|K*8zIr+)ZTTC~{(H<`$@?)Vl6W>^)dTr)-h$R#*G+Px9 zL0lq<5hQ~j?U#||K|}w?+_ZC0pF0;vQRsUUm2OY0$id+g8nsvlO5SK_|g!Yb3 z(%#WVhm7v%jnG%?Hr~-WB1A4CSOE%N9c#Vj9l z3K6dtej6|r)w?q69ZOtTUTrkXpuZ6h{VWlIl6-zbi1#0bB0~Hjk(+26fh{Vc5k&_?QtGN(O7~a<=>gs)Y@-L9^oP7klMBo`@&A>=c5l z6o}S0bd-2FsoQF<=knbZLX7XdIb3}J-`FfPr&T-zafu{G;2ulG*)y_tW#*^6k-aM; z^tB;~OC&LZZ03hYujzewox0kMMfD7V>9owk`aWBxg`0lqz6_tM_02`q_*3x^xHE!s z2i;OsZ9bcm4wQ>rh_y(Ze&d_lhKaNT$%7R`C$TC6XAC z(^q>)uH-Y)j7XT=)_wZU!ln*Nk4$gYtX-nLo_GW(;e>XayAN-RE;`M7MSZ`RnY~ve zmJpB7tRo(RxI_{oep#FB9i7?O%fzc&Z#RjakVQrPW5?Kau9cZ&L+*u-YXKh zWR=jYBOZddL=q!N20!*r9OgNe1>vS&UC)mc(rZh+7msBL(H6q(oH1JUdO3CshhsY% zv3cESRUk7Kfs$-Qr$Q-e*&p18<7g5`nKq)@*fFX@ch-SE8o`KNFOAV(`eH=E>SOc? zK-PznY#lqgjM3k1V?^tgW3=xm(AT48=5 z7ozRc19(b6XtG;Q5^x-FmyCArWnWE7IB^Nhr9B>kxI_{o+D;nj&3y2n>7B853Qb%| z`Nyd|;X_&T!H>ERckBEoY*vb@)Z7?O`Gt4Zg(jJOpuxBt|@SKH2@C^svn&3-6i0)D@Z$#K}*@6Aw)OzdtsVm2D3Htk=dZhtBZ# zwtM`TYP@$yR{M>qN<4OiW*v01G0naRKeX!X?ENUQox+8_HUx2rBu0=7ej;A;2?h1- z2eE#yt-2P>_PKSO6Pd~G%rEXs+JAm-0;ZMRgh00<6h9bN6+7~2E|E{aG|s}iR}8(K zIj-nbpnvpq+uh0?aN;)2?KsUREYW*{-s*rOBv)wS+I}uo?fO&dN4LYt?vitP zwtP}*&mvlREUWxjxI}7@sg5OH6PE6?my7my|LDFiJdQ|s`XiCJ|uNXs-3!YXRW^f%8W8->AWahQ4;#Km(?%(5zW?($f_B_{( zQw4vOn6qv4SKm4WUPzd_nCd=1A#nWh=Us(V(+X@Mj#upc5A^>}dzDT&@&1ROwPb#^Cn%$9D^!jo795ts2rh*;wn_7-^%T4};$BDa^=(|O@HJH9# zuO~I+(QDnTaLy0h|3G_%Ca!`j#|7+|2$!)PxCFuR@c%~KV)wqOzYfdw4(nsP&*rGp zRpkCeKzP``V%`jzdUN(2#y&sgAm$n(umyLVUvC)U9$i;DO4yPgF3~aMw376}a84^k z2p7E(G|>{y7U4b)risvmIP$-70Xsj!MY51fXx2e<4?UwW?bX1Z8^f({W?uuLiA%;0 z91q6PUp`SKV;`&^;#0)DIRC)Wi_pY{a}jfsj!XCB(i^l_XtLyF^nZly7EbbH0+XWp!t+3ozv9&^-*dO4LV@$n!z7<2JqVfhc8>EUiZao8#)WV`g1 zNM9R*xTM4|g1oipY%2W^V<~?H!-Jn}M+v z`q~i0C6XAyw$Wd@i`DOuF%{7J*Z$TPep5?mk|p2WA{w8Fi)9qc?&6MfRJA5jc5$a+ zggYU7Z$fM*nL=|s;vtAjB*nzsMeS(SnKpBj>LOP8F=6i=IX!@7>2lRPs#n%`%6)!H z-7AmfS5^Mtl6drD9xazkB9>5ny8GTTp*DZLZ0ZND=?P6TC8aqYh#$^9QJlWg48iA7Z_m#l+ zTzt$4A-?BCOE||I_i?HZn3Qdaqz@s4zBUALiKLj+^<)Q>WjbQ5m+jd)lOuzp}0EC}AqL3?qfS;B?e$TuZXxF`g^o8ng{JVeWnNZQ zYTjnf71ICZ(ch4UJxJUiM177k?UCo)k(<5H|u^UlYf=(T0Z>wKP5c7DjJRZW`f(>$qLk!EY+A&5&PiT(1c$GgynW6Rm}Xdc~v z$UI8;sU+gu5_>pI1)u-weh|~gMl`rN$=f~W9hL zS7?g4>?m?t1tE}2XtwBN<6_>D)vdje-K!@`4$>RkgP!_zYW59Xc0906#CDMO?&N&E zji5JI?@kj3q8;bIcQuRNc>Qpb=V-6c6kDku9P=OqatX~A^=r|>tJbKL(Sb3?vMb@N z!;gtBB`|-*c`eRMe12V{to|Ii5N#vy4bG!CbbeDUtC_=y%*8iYUQv>zL!YAay+yEn z#5!*ygwe5$@R$*z!RW+ie77(XAG^X}jgD%JOt1=z9W%%-Z9+k3O+IH**rN1M4 zu}ze4kuARwuWR8vDkER)I$W`PZ^&N#(%*xNRQER4uW!<7@$8rN4ay;tR+2s>PiRi7 zcnIPWNsJJisD2bKu|S>Ld%&#LV!K;>=9{X;;G9Z3zM&F+G=sTNlCObu92&Yn<-Uza z!pMba8-cw6>O%`Hgf+*(9K@V;oOeHJ=9QVq9(3hWM(A;Sf=OLTACf6Fr&T-zafzgu z)b+4^#kwtfUL1!uU7q$ny8WV=pU)~eQdR9#+gK%XiA!kK5f4FJA}Q7?*-;?5Xrb)m zP&hvNHbkfsOB>&$Z?Wb&(dBp^Rr9}6KYR=!{5M#OP?FD2ZOOKe^QaYvW96bg8!>;! zP1#>B!xhM21g?1_0wvix&?=ORXd8j`ywUhP*|$IiBHTRJ&CzSD+>%A#nyEhhgzL(N zZ)fX!^>S$8+@=-g=)0+y3umb)$+F7D*(f4Vl8s1MH(Q-bXYFVSTIe|2Ztv$EOMBOp z-Sq=Us@4;0nzAdUk7NqXB{3d?xI|KH*~RjTvqr34Ry;h}MWRqdV=Zu;T>lyF4PMQA z!UqQ6%RpNA}KZ=STeAT1(p)+*?)cZ4k-2+He&t!b)iG4JbIO{ zWs&Wt9^DxJ@;rNNNqDSPvWr9rOe;)7dM8iB!!`pEvKua(@~eG0i;mJetQSp>bL7rN zYWgVd&(H6ht#7)P8OwkFNcQe~ds%(L{ke3cI`~;tlOK|z6fQK!BOZddL=q!bl|C7r z_-i*WVbX$)IP%8RF2SR4j4h@cjyT9ldD!PpeED#Uf|BfSpJ7hP*Rn{Sgo|wX)#ue< zw-5Jj>{259)b8RP33zt`-f^Kd-aEppHLSQ-u*Da$&C@++JbL!IFV!?Ex{ovAcg|1v5@Ol(5j8n;=Vsru5nK!9v}yNR8URW1x5C zi8&^9Thtw?&vV{ka!~Ss&_Z+S#zPR7NMZ!Z;3wi@ea3oTx`yuU=8ILrPvb;FFBlP*0vL+s4355&IR>eaQmq=p7O(!1p_D?8kW~s~mcv+vD zC~0&E7wWz?1aXNZM$}qzhr4j~L++m6_9eF2urZu`H@D7b|K1pulgwp0WQ2F>Q|6@4 zao$Kx@{)_)W2};S2FVhdcti)$!3g9Mnyq3S{f&4#=KV)y>?&onH?GrL%__-fxMk;u zdD}H<>y}SrUrvk%?u<$}>p+BX(HrEFaNi!L*mFSca7PPmt9+b2|0wptoBH$skDOY3 zut2Y0G41nHNyM2+*U4|H=+n%FGZ`C!T&N=$QLEOQszN^=U!f(4wsq`2s&`*cGXm>5 zN^+chOEQxlJ-}ZqtZ?6h>d&Qo$JGv?fhO{ z)B0d}l^i6r(5xfg8UP`1J-~6Uf2ne)`3&BVO8>QozB_i9se`S4ct+naJ=CIEM?3^^ zi6lm>=vd7Cd0uN153Fk#CkbangXQPo3;x(-bFsBA)D2 zQE0i8m&`pAM9VH2(fXTnO{Z*n@*noPKl8=2s#RBxx!6uTLbFxz5X2>t7(p`lp<7S; zs#k91_4d?R&fx- zC6Z!O7x#zSO&YFR{KaV{`)VAA<+}!NIX{;3e9q`4`rg6AW`ta?+>-23ztF5BCtpsI zCH|X4ix*m~RkBk>Txbo~uUoFDycJNpjK`S3Y4>RN#2x!o^i-SS2C**hQq?HyEAp6b63z&c!B50Z?=xNd!|}<| z0%}_JiPLIhr|a*r@Jg8DJox&FXuU_f8y$}w>!tp@>m?I&v7Mv|%{t;Ch)X2JSmov9 zg+w}v9~`9j)eB{f04RhX`lg=WBO{mi{s^c1Mm&5YUN7_(B?&kEM!d5lMyREYZ!p@? zYp!nU2)$RFC(Ek#+m|G5t;l5;%c#&KD5XdDor`7Q(#k`qAxuwQ_ zLTJ(y#1nCx<+uZ^TXdc)I?A2aiRtyka?aoO9C`hF*G(Yc2McpMKI^ zkCwRx3(YZ)hafJI6q{DqlFjWqJiAxJ-cEmY`lqUH^Os#yqhxeXvBFu3gvaX8ZF2Z% zMr?jyJ~_@`&2Dn5eB93HICSA@y&Lwt*M#s@%-BjJw zK_-UP@BfcJY4d_bUmJqBL=q$FcO2sBf1$#?U&zSGcZ}kJ9<=%{z5kpH%|F!5SS2Nn zxI`|}tRo(RxI_{odanK`)Z+B>R_fB5MmH^17q9TFQPPU^>2sTa5H1QwE(vEH^p>B9 zhb;iM3^)!FZu+Hj+dqYS?&Z-Ej;Xhw`zgHk2#4DUe1ixaci9Npou*JcEh~Fd$W3&_ z!&xJ8;r9X@r(esVp%N|F|4Pz?@Iq7iNJ~xOHbUN9&4p+^TOa+hTdVzDp(g3I64BoS z{Xl3%hi>v^9a(<*CeNjWlMbPY3+sHgXMYR=xrC0*b4+*4kKjDV6vcDk(tK(29W;9Xo^(c{-XyHsTAk|g&|?rYOBTWb%~)~1CMnqp2KFmCNuSuI=; z0=a~aiMhM^#aa4hG@f(jT0TqHD5m@WJyTU*%QWefC*rozUvX@Dl;0pa7*X}`OjT)2 ztX$M5^659?;+tiE_!goh8+?T!-xgUIjKEnDav|E*fe7T9pE6r@-^BWY5y*u*LfZFym8A8_V%I+r58ohSVZZBjc@spQTZVasZt57l zFm6sFdb(ge$NI0&sNC{q(w`yrZp(jQqnTk-n&3W;gtI-@WhA>zY9}S7kAE$($B&Cu z>reUq_R}X8>v0f|8Iylrtd3QZTOCgo`y{mUzr9SGC}SLwg*hlR=SMsQafu{Gux<30 zPK-Dz_gAs0i>dB7DGl@4iPDmh#-{&itiN~m zh-n9fW~<^Mh)X0fV&Zf8Liem5;Z^wX(~O+*fVlb=-5CDuGOqzSPC~)%t~dLq5Ltq& zL-KuiLP$9A=<}C9jdXi`vDus}!ko$}2iYpd(O-SfcH6(hJ9l&H4n27>eCCVTl=k@v zfn3;=VL7%Dn1iU#MvQ3R(9Jifl1JDyRYJE6#ZAjl^R>eI>pJx(r z@y}0$GxYc2>Ah`LxLGH+Qtw>GDzSvPgr*c-Ga$ddMf4eCq2mN0kV|Nigipky`Lf)} zfpcfs4M%RGBcAwp(EpGN(T+3b;0SkQ|J?`a1-2v@k^jJ@@Y*cf5#6K5rEurOoOHC=a50>B1;0uE^sgsq{w~~&E9c!t<(Ipnm^5Y?hOC&L(N9iH%Z;!kjdahX{ar-&l20&!uVX!nC)m7Y6a3|C*v_Ze0jdof&VAk$R4+7IPhj~(Z= zS~t1xe%;RG2d1LXq_0KYa(YMTN>+XZA&^UG-yWvOv)}hG!Ti8{LX@4ln4(yl9p}fB zce$OOYGC4l+J&ZgVE-75KrW%tBBH6?_5Ct19vDBz`6&G!H?4S*OPJ}`FHw(m<60{; zSrvRUp6sj>8h67{pX0nVu9Ek7W4;aXyGLrPRZX5VeX7ue-aG$3?VZ4TFTn`p5}GU| z3GzhT7JtoBN`JSH-e8-Ux3|7oKz{-7-x1hyB3k6rZ^T7e$aUWGTkUWS5=Z z+i7*=om}4XJ3E>D*gHQ_??`ttX(covFx~W8VE_WTgpP^19)C~TB?&kEMzp7_-=Cu;XrYb3H`sn)yJ92o4UPs-lH&}X z_(AleOD~xG=<-Yhwe91!CO?EGnV2`h2;>qvHb1ar;F_FWO8l5aytK^o;j>Bm%^0^+ z+WBzx`?=+;HuiG3>S1X`wKtWky{SywE;kd96uD!IXbESlo_+a`=z`7vG3{03I`wtW z!aKRGy4F`I)ol9O5X2>t7_n$uycOYe+c991+4rZ;(sEG1eS&Iafgc6EAq8 zDdst!D?%=uz1WBrkL`_ip7)$puE?s!>l3wq5r2b6N(t%HE5eA#;-YZmLbM(;w7$jr z{;5Zejup4}*85yPnOa-8eJ?d?>v4-FN%0ZHBa(>5M}N-?JW^DbXY_qCADVRr*(F16 zq9fj%4=-pBTpsTXFLp7!_)tz`A0HRdcXC|N-*$oefH=%mMcn3zj!Ns7>v z(g!L&r*Gc)pGk#a1ab-O+rw1E6W<8>KgJwWM0=j~tLN_Tbg!`r+mD2G_0@=fIL{@V z%dW2}qJ2N~zP!GFmEP1Y`-a-^Ezcm%Pn`zef)AX0Ka$4oYH;CBRavFT! z;O2*?MqjHw$ixHFL};>b>}@^uy+6mYQa1>JTtfRXU@GE?HwFD4Qx{X+adxg46d&EFZITmL+`cG$`5kWCu|1z! zVo|uuqMX`x@d_c$n1h(Jj#HxTk^04c;rL}*IgzD0Xz-w#RMAOvy=&9TN2fb{w# z7khmPXGB7;&0C86%)WzI8pL;y&=MYt5H5OyToO)bz5BX8*}YUynN`k`xfg{8-o>el zDJ|j8+&*DB>*2oUy_tSF-zbMwO&rufIP7a|-36KF7g3oiKgyrXdMu zTk2J*O*wnASUg1AHyBmBP+Z_nW2s&nFXX4HpV*lYXz z)Z-u*S`v)Femmutm-XqR>y5rZgggJCY1!Z4z?KtRRLd&2@%m}m`<#QL)3WbnJTd$Y zH7b%*OO3rj^0H~!=h}mHAQ$Qj)`7AR9gIL<9Q40INp{SU3)9U;tQl3_d!$bjx7`=H z>&|JEpufY*w^NRA6V$SIKg*o>?(}=+l}gZ~8u=ciuZ8Y5^!;Qi<@qB#IBQK8Jr(o} z@{?v>?UXX6>|(jf-A? zo8RY6o0kw{70Q(|%{qb*$R)I&N=)f5LT~*&yl*1Ucrkr;K72ZyrgEAIV~QddqO~8I z`YY+p^4y9ZTeK*A^dj%X^iNux?I|lX$K!^F)<@q+?CX+coT@D@Ck5hc8Q5t!(yGZw4VZQMplIOoTyNk>9;KJMXtHRbR|jA8C} zi^6HSxQA=iby0YfBV&fvzZ7(9b$`s1_AiHYQG5RS$;4b}lG|=lM?G%;JuBux2;>r) ztaY53KfN6~et3ZQ)qD3OVt$~7wpG7w*c0r2R?R z$9;<6y$O7S{VPh+x27(r?%r~HeXr@+KR2RPXvvQ9i^2yBa_!P5U)mLQ)y-{8jmo>? zKl&>OCro+88YMKxAs&LbL=wiD=!m!T;orkf#BWQ^{>^K0*v9CuE4<&&t>0=@wCx99 zB;xoJ<7CI<@#-r>T~GEi@u-yN8NH*(GxsA4yQvko^IC?`Y*jo2afu{G;Eh0d^N-NP zCAR}H!v9M?^wSjG7fme#u7%H=H7)y2Y3|8DlsR7&@{TYV-7mJh5ddH zpWPChx|r%dKjnw)t5E2|e#gTHDqg3pbR5|?B3h?I-t(bZXP-8yi>W9yStxlD_FVv?MtWJ3qoKxBdY9)gvx)&s~^%AQBL8QLFj8k5SK_|g#S09OQK(v zu#5%o#EDM{NvU|~`>=e2B^ZHzB!sBo;ux<2Lf1N99myG_0eWL7nQ8+$?@b?;#3(*$B#WTlvLIKZU z+6b&USSPTq*a-B37TjoClbq>A$WG*Ub8Y ztUfPaX1M!ku_mV7o!&5yzLVoM(+?iKc)gl6^DT>Ji{c@OOC(_&h}P%mvwTYEf7sSy zy`gvVM7%*$4~8?Et=LK-T=wL)fvqRzJUTX{&qHV;Fvy$CeeY%;lE3qXV zLR0Ei8+*N~cxbgr1-;`QfIu#xNfJI0x7v-9wRbdsN3qfP$?ELie12?HgUKr4EQi~O zf-BQhvETU3C}*<15lZg7OVeK{$q9ivhVS>e*4#f?_ip|TP27T#HvAro&=Nj(bq*^m z9-gAYwVwaS*tNjfRDJ)GkR;?0C3%H}Buz5Y+~e$|67ncXnj}f`%$pf=kyrA5R6-K+ z`b$!oVeavMza?oxk|aWsy!-EU)~tKJd+yxdbv_?+mwncE?X}l_@3Z&X<~Y$$Nzldh zB>B=n?-6ZTLoW1n2X*h_mBgINE+McS>-+Nle9`^<-CJ#XGp~Miy>sAQFQA1t^S9UiQy*mwUg?0Pfo~7OllH+)+d0#gpv#+O>#7-JE zZd*zF4V&$0CG?FUh)X0fBBSU7Zd?o7eX?b>__1r2I_IzPS_W!pYBlq(HK8TncCxn@ ztd}lp7POXu>sPq0VOjd@pi3tLv+LgZctx+KQn=8pRV)N?nJFh3`~+S3fF$QKiM9{wuq==UcT*U-HgUy_17?C}VD#IjFzSyY$ta@JmZq(qEUp_3EI|6u!M- zWqm*I+N?Sl4S`%jbIM5uKW2WQ?wBUaO5Xo&;Ohh)^@)8*g3u)Q#N68YOoN=LLWI1@ z&V^`wFU(`xL)Axibo(~EmcI4VrA|`T${pGYZK!=4ev<0aZgSE@$|%ujE1@~%u@J;1 zk{Chq_zAk$)?#@zZ6*BFJL4N{ol%m{PYB$RP^;E?r`}ec*LE2Hk29mI>`w48G!yps zEg;%NY;3SOWA>VX?oH23ikG8Y4zw-Jbx1wn$YHbEEK|z zK8bX3oWA9l+wfcq$Eg&T+xDdg$LSN`(zXk~PPvV5iVPX2{n0U^%=&R^Rvtk-J*j!d z;CZ}eDe0h`5}G8M>9|;arrgyaG{It#M)s8i}MV~S|=(Rr2AH7ZQjXP)1D4%Ha`yh^LuofW7{H})e zL%x3?H^+hZu{8c=fZi9Je+|9CuV2Wvj%s~=s)LBY@h--#eT_YMr#-YOpLdo$SCm?zNhW@EGU9M?u8(uT z=mwnVR<7s&Ofs{IV-tk=^JBaP$v0Z1K z#ZTqb*W#Na{r_^-w~1^~%{cad_IG%9J@)4lOP+Izb;&6XL|`9|XfqC!)u6yRy=t`2 zYZFmJ6H))0vrflk=E}TuMtgT)I=ZcLw>aa}zV&-g?)Gh`weC!t2z`H>tBNuE-5bjU0r*Z>Cx7%w*7P91J+)O)+wk6v35`01TR>1dyC{+{d1eu!(W zX)ApWz@_s5gwMCAn%+-+z^k=FQ#j3v4&HY(Yc>%Lfm}j!>q9d5(Ozz{zLL=Y(LQLm zEN1T~=*n6s{TI>E5jgWgw263hQ*V2JR9X?H(q zQe;RKZIhjzt%Rm&$|ju2=nZ#-f{2J&U<0Q;T_Erkph3hxRA(x*`3)*TnTs!`d^w@+xbKS&ZQy?E$mscvbmJ9u?s_ zlexCEX?siipK|#;>*5(4p-G^52J2L>{-N%5_-r5cuQR#^^qF)1MlUq06AM9HB8d?l zr@d;w^W>$#-bLfm@eH46MHV%+TKxI8(7t(micVrDu8Mi~WwZ6Cw40LeV}LAHDNOui3x$$i0mc&Nb?Z=WcVq-N5q?$y<8Uwa6p-HlPz;+a)x~ z*JIHb2;vb*MB@{5v984>WjfsxTX=O)X2r}suv`^4rCMFD6_waYE9^6bzA*%Gi6ln& ze}ne1U1L?gYFy88R#bD-Snbm*r&&?=jIpZEKp(N7Ca*wKc(K!hz>|8?zEZqW2Z@$& z&X4C$)v&+6RL5&e)X(wy);FFT2u+9qlj5~+2$>J+^CdQ&8lg8h62{g}!Z{8^2p7FU zE(s^8_ylci`*6J-*J`xygmKO7SMOt=ve@IuZe@y3XqNxUm2P(W#zkKL5qIbj)#^T8 zrxuzJr;{Jip1(NUvJfF}vU4HYNQe8)-`_bc3ZC;yU&obeobi8d_GqyJb3glJs#|Tq zRBv^#>hPbPQZ@FiqBrsH7->=dlNWfjR4O_07Z1aXNZM!ZwMsXeQEQTx!# zzsDz~FLz3v;qRR219=f@wJLk~k0HMbJ`Iz=L%ykX(L+FS&j4?yi zTgIyk9r)x6jvp**Z>jQjuJZNm#TVD6qfO{UcJ*_-9|~<~Sr2^J%Dp`?kJl!y{pX-l zAlHgjq@ko1Y1S$hg1AHyBXU1^I`Hq5wsw!+pQd5#sMXw$)0_qcc*IF@%0pi>xX+*v z?3beV1~@iAE<}rb`sIB9GS5IRjN3%W*qK5RVfG(BM*H~98n^4-1w=<3YjiNGEb5!gTKF~dpa>YIDL zvO8_vPfm{xU#}uLQu>hQk{Am?Tq21P7q|Qnxc0}J_QJUQ30QWqyl(ED=9J09bKI~; zIZrG61|c+QCHKY?ZCUg4SFqhzp0p?Qn2?5YuYZ^3Of1QA%{aQ;QqjHlYd&Kl`jRA} zNq5;9!Rd&GKrW$45OjU*ZZxr9cIhz{B~ z2f+H+@Y}I!$vZiC(y{RkBKChaR`olP6Jp-5v3h3>&pD6_(WqI_MZa1TOCAi5eK2oK z1ae`Xn~2eMU$%d*l-pkOXbJ64#%~9)?3SvLuKgYCUbCm>+g0_rz2%~e5heKB1X@vO z%8zB6cInaioUHtahCnW%F%3l1R{+1qk(Pnpm^;tjUz}nu?0;+E_7rOc>Ymy$-I@9k z*FiHKB%9>WgL43(C0urbYR_NId%Ehthu(M>>)qMPb@WV+Yp{ez)-*Y24eK7bw8%|BeK8%4+on59-jRNgWbhMou}4O`nQ^>5C)Gav)QjGX1?37&(xS(a zyG78sJaDZ;TqxJf!Q10!WZe6Bs!d$y_I&45Y5lS{KRcbgOYf9yZP0A1SP0@0Ns-pY zR%ZFV--kW%&3ba5b6|(eyyE(|v1?Yi;N_sqElET99^uhhv)9Ntj_tCXWt$dyDQ~vC znlt5zB_5rWzPm1!c0%J5wC84ySH}wTo{5Aq6V%lcT<07AGEOzQooUPJTfJN8`%L!a z*=yo{ZJ$=2eI&IcQ)t#I7J|4$Qe-+RWFB-vW!NS)4jy#2zt1zho&RP!#}k+~(%~Wk zxv+1ytk-T?7iuxElb7ek7Sj&XmR>qU6Ot)3YZVJYTq21Pcrr{*gOOaJiAzp~QMkTY zuJZtU{?~QAJa3oqh(2G*^E07|2iG4=guKblg=l&wkKT<7`^|CpIEDLdiSSb^gb19` zN8hcETr$HZiO~_rg>%m62;{zoMi0hP6mPn@1T&`jvh)X0fV%L)X?hk#Q^U4*T)WOmxG$WpvTFITA zq&%&tbt7jUk9jBr$?y@I$NT2c4AvB1hqu2Oe~mAIfRmj&HDT=c;

*t`VG1$3lTVHFcCQCz!?OhO$7D^I7>lE(GkdneTm*reLj!9qjD>| zV5O7svW`n~LS^DrzxiyRCb!3{_H$)@mu8Q8PUz+NUAYfAPakRG<+U>6IFSDTL1bt(v^f(kNKS3M&CFC;uDB;&RsCV7_>te`du8|=EwL&gL zYoAU_Uv?LCxY@H+V*bO<%ASopTS;n3qR^c3SP0@0NsMUzLaIG${=~$&@12Qn-+q!a zWYw-U7kj<9Xydb!oGC-NXDHlql2dH4oN7FIrIVX$&guZg{`8TK9eXqZL?ETK^+qczG>t{xmB`jmwi%0T6yHkfl-WU#b&nZo$p+0G$ z=TLfPnzf3BATE)_h^m+JxrOStbUU6rQ)lCCLsjcSJoZ_7 z@Lg5?9GAXOnW6d=rQBJZx+~S*cB)iFIwVb`)s!bDIeYcVCys;W9(qRMA+S#sO$Z_M zjUk9jBr$?y@Dp^awBF#22_O83eM56|To%QU0-ufC}XzDHzUTJIEC!+B%Qw!4>U?Jiu(`%A<=BmwOs;hg9Ct51Nw z{ty?YP-x;(ZNAp?kNH`%?Pv((5}Kr1*5A3~Z2Cv^>MfI;&|xm4n9mX(Q4;O_N5`wv zSJ^%tc8~Y&+a@ma6x{=)81b>_QV_{GW^J78$ZAD;?1g$R#vsZdniC_q_Y# zGlkrT?s;GZS`ka7wQ8`27JfX#oC3I7WwoWbg`aexx(^hB3iZE;2dkh?Gm<8CSt|)Joe}1T6y__Rur1@ z!;YJ)Z_r+nl^@X%$R#vtPPDx zx#?4%IQpleP2WIBF4{zBvhLiEXKF9nUuRi28Une5CaIQHyH$HP@zukj{+};TvsS&U zy6)ljb%>_r9+PIv-&IrAN@;(;asR}3hjJ^ryZmJB<(=06hMt@p_KPDlX=quo5X2>t z7@ zey}7)ux|7>=+1g_gWl!KZ3(t3W?O=Y`roYAV`oMn+C-GP|3KjR@7}auYVgI1xTJX1 zGL7d;IHxk_QGZnHY!6(v%A0MY6@@0ojOC&|)hrWMstlIny zm+FRn#_ILN$bQ$)4VtUX+b~{x#$&FLtH-Om)@32?7^gD(F>P6YZ0~FDFWTL+F4{zB z(&Xy=@pbDg`%bJI1%X^bqeevYmmm4sgY$E*>|Se z2Nx~MAUs-8Xwqb)Gp;V(Z^BwdLm-#XtW}>0HSI&~A9L>;^Lc#tlwsl3fvv9%Q{h{# z`wi14fO8tR&+hi8E4!)M7QU#h%{!7r!-$k?zvo;#v!@BsdOys7`u2DKSI#Cb@%uou z+_#P4oboMsQtYkEN`>;A%ew++UOD9^<{;lvvGvoY-uDQ4Bi|;7mT-zs9!3Zkg(8=P zV;sbzzv@H3g!ydtQN+uS*{k6i2}-i8ODD!>^n8iGvEZr!p8A$>)(R2AMQ@Nx!s*Po z_Ep^AOvb%7`_j{Uw)H(GTt;myVrNLzpJ<{ z^wuk*9-B98nD)i^dArr+oYdMlEM z#wX|=zp}?!puY)HD7Ig=n9jG?NlXp?2KYdOdlpx1xik zQlHOG{xZ~|EPJ!Uu@B~;gmZo%Lb&J+a!I(U5&aKm)wn7z;h5)aUE!iP$R*(z2hl;d z`qEw6iy-&4?GiS4KFH|Jv0b~J!dZPV{a+sXe(t|Pi()k>15jL}z{S{>P{I6QnECg|hBu1P_D`8iu+1Tq1aBP^yi$m)$q1yqyYwt13v*4vBN4(yZ;(sES*u&i zJrrnEBiSp7(p%CSp$ToySZZspEw1+7^756GG%SgjrUnIOIhOWF%37`Mb9Cj3-*|q0 zdEnPhi34N36CBdE6PM7eRV)N?i6lmJdpyPdvGARqbvKS0=K0>y9$=~oO>tl^s!zCW z?vrtGIFB->y-PkI_!fD*U0%XD9nW2TlB1aXNZMwH*HLZ_My@Ny9KU6ya2s&^ZwNy6DydQXN+J1yvc zy(X@6)|X{ZLn4>Lh2}V7A&5&PF=BAnnr@E~kJ+CUI+%_-Gtpa5V*Wa+-d6e2K(X>e z=bin&@7$jC&haaeBM!~`V3E+aHe5e2KV#E6>M5 zAXnq^V^!%NV-Zw{0 z8N#z4oUsbcrG046MHzYYm&?&NRY|}yie*>oAS3YnN3q3goZKxYdv#E15#_PaH-;cC zk;DkUoS0v*u-@T{p5F17q+Hs^LdYZiS35J#O!Tx8xfCum#}NxbTp}q_E0INVWe*(D zl5(Q)(f(flZ;ZYxnn$l2pB)pv_Y=p#2EvutDHP+txGk&boQm$+8hjQ25kj->tMuyU z1h3bQj?n!ra50S<5goKqUmTHR`VbX#)1L`BXFnhBe$lRV|S;8`gD6=HuybXIh6A}7kr*mvve=M5NdUoZM4#I{$$P1qtmuYECg~jtexp>Zx{=KTnUFWovzJdA&@IE|3Rlz z*H{STq7xAtN9BY--vXmQSm)%by1^^qE$ggzMt>l_STP3?fm~ABeO{)hrS?7BpcjY# zh49;UY&m6RA~Ftqg9wZpQ9&2?2x4!5y@ZKCF5Js!?v3&>K^JX>sYNU3`Ny59uKqIW z?fw)ls%t`%M6`5t1ab)-QEP2%aj-4HQIuunA3HJdT!mglo-k@b!p3J8>eV5(F50KuLksnO z$b(tyyLE4R!QN*T^zvhMg9NqYVLmg6p135O;?`r)Y<@p7FJ1PmE4mYp(43A~2;!o% zKqN_0PEzQhlSurA{)agzaZtGSZaJ`%ODDI8rc*)*hc9|sAzEmTBNl?VL=q$NUwAO| z+~d67o|APcZnU)CzxPFbduSKlIUrUfSwa&+{P1ubh>$ngxe!h7v zZ+|7U=jK=K0yE3UpM7(oUd`l|0n=orV`kY@x6g@r8UOv08DFZ#P*vvRB-#6hR9YmYqd#fQDN zKFJ!N-&Oryv_Go7ISo^esh!_U??Ge2;R4C4@*&=Mb~BN_s^gpN!HzM+%l#TK(K zEHgiRoSB2zUP&oo-X%RAcF(?F+urx=m+?i0EK~*7a86-vnmGt@*av?drG|gc>*`7U zN2z{Qm@bg(z3`p0KEH0^?9CTOhVPR_E=23KkZfK&B-g|WWKppKTQ?d4xrAno7No!K zHvZ*=k>~VA@|@)2SrC15PX3qOwu5qe{dtvX3)RFA53c#B`x^_3_%-wUpSUr;ZHYzK(g&Vf@JOJ;NkTo}j(|vym!vI5N*s zl5o?nzFq$C$nY)l7zawyE2g>QT>Z~(zU+8>`pAW<*>Y}0u_U6UjX3n#*IiDXWq)~f zn^uQa;&fcp6B^o6r;P+bmMvGo` z%#|XHU1-W@(U$WZSBiv-QiohZbE?b!*2g_OE;T^^yD)E|I?(DIj407&cVgkQ{C5J-p( zyQVFxU(%<`sU0DtlsJl=b{JM)`j^*6NjOW7J%ev>wRQLGirxU-6|1};{j;Tef>zTsk7u`kOZnY~F+`K${3E!+sIHjrF zLqpZjlQO>Y^`dk1X->I^B5j2>^!dqN!|z<>%>8&`VgGp9~h%aizSpST&*_09^OAxStRh?5_>8ECEcd%ejkCD_)Y z2UOvvUz$PszEQC+!Lnc?u*JbRFmBVs29_U5Ey)y`(}5l~azYT7gfpUa{()}b(p7I) zSi#QI)$FspN-p-HIE2121aXNZMl89O>NXml@A1s;W72U%k29}YwT9{$i1kwxBqQq_)g!JX(Pj@C2=*CiD@3M8pJ`{)0H-;cC zk;Dj+!B0@1m09iV?8CkAvP~hqw;}QzlW@~7*~;f?QE;W6#m7R_8?~!$%R1cat7@>9!bU%4=y{8pg7cD9@#fMMOrMjJcyh9vV=k*!IYMotu z0@NEJV;?2iOOK?3e$8@)?TXo!Wcdp7YOPq2@IsR&1^@h5-@_}X2cjX6OK6g$zim~o z9s1?a0Q<^}I_a3g4^P`_^atDyc0O*a654+zBmBQXJ2C%zDzC#O9OnZ#PtYgtVO}eB zjid-o>5%y@TNme?(gM&Mp$Uy61IzlQUir|;wfwC}(m`B86WUCNdOy`(*Sv?-t}Kb3zc8gbRYC&_mvn);dG0M&<|R6Z7(4+R@lT-9H7T=pyqwZ>Qn_GE93%h>#`stV-m@#)!d2TI*CYdwuo~^#=AvG%Q=jYr?Zxy#ElpPnpyL?@B zc_7yh%o{U5sC0;Klv?p_D=DXULW`~bH$v=2k^~_!1l<#rM(KPm>6M>aJx1xTuXlNL z{YN{gsdwF(Rjx`5>~3HBeZG-$k|8N4xho&}%&Y$#M>GU-3C-H1U+nC5Te&i@XX4Rx zv=!P1$Jdeu`VHFXON&mQiI-;d6=xt^y!W->|GlRMP3F{=IM7RVTv;e9bX0`f@2wQo?6F%StczAR;&3b1Oml{wjGRZ|Y{{}7+SxHNeiF|OS{G8v zj=R_EQ>8~Hy9v$mZ|&H|u9$Sev+khDiK<5j?hVkQLUXx_g&;1G#E32LK9E>rBcJ*` z{M=NvaVzf%TYm3UwRgKovsSSX#3hm#;r|V~wMV9?mUZ$*SQo9XR|FC(yEFIjDkruw z*m4TZ>4=3OE|J8D{eL&L2lg-Gm3XlUX(}{v)mfIPcfiQl=cScr6X(}qZzN(LLI_QW zr$7Bj-y6?%v#e+c#?PoREbI*t=l!@I zpii>4nU_(%JeNdl8L;#T&9;h#ATE)_h+9uTZC{#subueF{P-zr6VINx#w3Iudl$=jVQS<8O@xm?ZTH5G9)&F{V;~%u5(4@)1`#w@b!e`v0A&^UG zUmvD|Zs}I(&c1NB*^o_?%Th=p6l0%J(vp;=oYsEe!O=z}NECg|h zBu2bB!L+-f1e2B_B$WXWiNiCwO87ul#nJ^N+g`)uxI`f=sD&M z&$?(6p^0nMq7N1M?El{gD#6S&g|}C z_jxK`0=8sQc1a6t`E+m6eNE`@J?!IGR%u^k*~=W&egZL|G=5)j5Fy@jaOupQK-?I z?!b?%s6Lev1%X^ba~vdt zpP(zVBKkj;CCtq%X74BH$|yBE0wr12x|Y4%YgHzAw!(Raq?XV^vsSSX#3hm#G4F*} z+>d5iUL8F9d4ekQB<~ivTE$lB&+PN+hlDdCr&VWqBP|ZnUUID(RdstoMapBBMbf<8kS*utG;u1-WAQ}AV9YU(4T5^up zGO(4v+_bFkOQzc2|F|&Y;dU?TRcC+Q7ATO;Qr%U@6TMoCa{$qV;uo5=iiIF9 zk;I5S8<%`(LVY<=@=tB>c4QgEm21Mcp-Nqb%gYxb=M#7rMCd z3%>QNiz9up59uH@+bR}r}h$vTyHvFAPkO>DyC_R&(#ov0 z(GbWbG{-?Q_~Fybv%B#Z6Ko}D=em47v8++mTDu=kGU9Tp}ql<>ZrN-Uj|YgsDX_2==&_*t7C8Q_fbo_GknZRe4 z`g^v*QY|z|l$5jdXb9vII?`5j6HSXT{6&dw^O*Hyac|5Z<;qV1Q$ZVBPShRKWLd?J z^|jAW>>3~x);pm|lXH6>QJpLE9)#!!r=17jE?O1Fa}DSwa0Ar_X}$K9J}L4>?qlVED$c1Af z6M?4}@l=wW`bBP{gD%R#v)YLE?vKjQUIe@vMZSqz7vi}rc?6POvqEzY>N~3bCxW;{ z5+i)w%n=!mmT;`)^HVxR7KM^$tCndwe^C@v&?Qeys~bdeAzHZUmwh@(|ABo5N-`1T zDMb2O9EBs=vhFo{hcjgfXN-_~BxAXC(;oDrI$ARfUE=22l6Mlau zr2Rm6Wf$`nON`KL-B<|X5=oI|7h5VNY=-QBZzZfIGHQ`Usu-|633EcHWDOPUDH zDUXF9E~y_8wsLp=8@F1`{U7TyAUsC3takbKVu&4@lD^0;L?`gi0?Jr~_64xw)hL0mH8WJJPw<(4>E(VczuDPv@bxrujIc{gXd z+Eam7c5%cia!D(pISzeu*8fBhmq=nn_8V5Eln58aA@q$Qh)Y^NMtm_l)qSyYOD_jQ zpH$HvFZi6-;*C|*@fExpCE<*~vjEs~id@o4=o>>2mq=m+$>4|XH}Kb3Wi5-ia6B*E z^c%GCivn^X+C<=}7~{hkfF7m1Jix7AMR~o!+)`CkzEiw%C-xzkLUTG|A&5&PF(Uh& ztTNIU`%oN0-xz|pWF*T7lEDwHA%CsU5OH5iVcKbl*Bhud|5x`^Qwjg?$!>1-B}=@N zAL)O;>Tn0ATy!U-&>TlB1aXO^NUhA+eXaC8)phr7=Xqk}O|df4m(-G0LUSD1Z;H*1 zATF6xF`__~SKYlwE_*2-xw^6%)rnUiM0biq=o>>2mq=p7+a*)orMdl^VrlG5Z*XKO zT2XwK6%B!0LUSC9qrZF~i;NAWf2B9(*oQ*+F;~^*mKv&0fbusFv*#3ks)L!A-c@p6 zB61$e@DJ;M?)Z&(41--v(s&{p1xT-0ZDF$@#FRWnBrdk3w>hh~ZolLfUdmBlp^2+S!HU{PiljUm z0=a}{t^B`1eT&j0y~~W(GH|?u;~<}($}WyS=r#`-qu_YOM9}}FRYNXBn+SYkwm7~d z6M_2n3DsCFtv065^O9!Mt+#~5ycL?PD+taH8Xf0^KrW$Kqa{fv1EX8M?sh9ME4^^b zVcIXok6wF@U*AVn8>Y4wkS{-eN`fx#7J2$eQ2PPpy^7g;3Bk~5S^spV%roh&*_Lei zVsofOqqn_sg{47gO6}Hi_o=o2$am*x2;>r)wV~0m9`kzEMVkmsaYz|s>qbK$m(Uys zjYIW#)TaO88=*iW6h=azNjiR$*sP$gvxspe+$zm zy>F>#)19e=$6ADPg=Vc{A&5&PF@klYzqEQj$DwxVjeFi-h&Q;Dsxik|ou7LN%bHas zw|$TOvZob}GDI%P5}Mt5+MCt?L=cxqVnp*fFN88);nnlZOB3~8 zPP1R4ILtn3QM0RoE31;+*1?l?u!X~Y+SuAzR?n24?#xvmgc=V%l@R)*gg*Pqwvw_- z<=8B{6e~YLcVfvoPMbMge(=sPEN{Y1zjTv;f1ej}?SIF~fi1OIlw=Cc*42Kz{wIRCL=q!5v`ulJ`1_8~@5}P8I5Ah(^FR1p zJK73uXjv~dC~GHu{FFQ3+xLtf2iq>p8_&~J;)oJFF2VjoG@*0|&01+sQ~wh|Tq21P zB!eHbSL>cQH+*8`=$tvuk;}4AM))ZQ@#YiJgx;HN2_hP|o#W8%VG2V;^f<7TV0>mA zbKia0y{7MXE?@TM_|YGfP)iSTeqi31`2kuP}Z z3??i5>WJZ?VuyOWhi(eg!8lOvw0b3Uiz?~RX|Q!#ycwjdK#*LaNq1axu&kzEs3u8><;gw1<~|G+)be zK^LuvE9po2FLx&XAfpT)6?9Yo*yinmrm*2tHtRUJ><(|S-P;lG^D?Eqa={x!VEPah zw6UkhT>J3!Al0x>P9p&9^-t|iRk^?C97MDchnxR{UEV2v3PtO-|7};;3xhtl8G6N? zmib?3bj{-N3;r7CWTtW1?OJ5K{=(07;+wz6>90UiKeD_{d-WXagwPaU^gQ4C=~5>t zs}0Am4rA9W4d3TS66Dcsd%~{p`D{!Frcb!(H|XknaJ>;3{SOf+N$&*d@_6F(bS~}s zwkaikak?i?QsfDq21~(#z~uP9506c`h_dj)fpDk;I55?m*H^Tv&zE_B83769Dx74`j zU*=B4B{a{Y@Dv@E2B8Tdy#dD&4S`%jW2%YPx9Vn!mqAu#5^#_BlGIjGC24*7~9$kV|OR2v5 z(yMWPL_;8#&>YA2cl;Xo`C&eNhOrAxa&v!wuRcpE>4=6vE}=P&F<%$Ao%wv)3Ev1! zab#X9t(J|DIHDntOK6S*Ptjotg{C-UWWctHhCnW%IS&7C(0=~tV7-@%Te1q7gVlhn znRml~2kTLkv~Y+(uDC;k)q?KK6&-Q7&=7U>7>_@Y3(1_|2f=L-ZE`rsHM|(RU@~H1@$bP%cU`v~s`sd#*G4A^!5CZ>8Une5##9r{qwqnKd1V_%-bv}p zol<9V%8&dd_JsX%w%)lr>~~Vn_`F#k$8k1&R>qcxc$6Wjr8h#8?lSh_bVNfSm(U!? zwaXRVwBMB158~KIXp$>qQH~=T0=b0dIPk;>z7d+_%IKBjh=xEep*fB%W4;gN?bg9w z*K&jBnOi(?%NIW4!rl1hxjU34G-)Dz7i)#*?u3ivBA3t>1NM z6Nj>dCQYOlWv%c`oN!Uh$R#vtL^Pd5;?X_*4>dHkqPP6$(@s!cO)fu8mi7C66Ei+- z!DSa?7n*dJQ3j_Y8Une5W?deze#q!Qfp2QUFT9fHBv-;C5yC}pkW0c@E5^}Zv!3J3 z8|%E!pB;g%!RFp+PMJJ%dcen+aUj?A@6w!!C1W9wt5l72=X~PsHG68lUDb~vyH?26 zxNW+#f%Czc$g{7Rsx=xAWZ@b0ug!WUiK%e~s99lY;-r>b6iqnyIEH(ZmmtYZT=hK8(r z+gn+~yv5c)!Z{u2ZF*Y0Jz;OtHJ{!S_BJiyTuK&Pc+LH5)>&`P`Fw@R`u-1|VGB)i zn>{vJ^*bT!4Ctj9djpY62%$NSSP0@0NsJhFAjM7To;z@{)XX~Qza4kVn!D}~c1Un5 zyz!uCtFA>R>yZqv4wXAQNw3MVr6ru>D1ToayV~}Ly#C{K@@QwtQl2{rOt7=a#? zv8|QVQXE3x7=pM&5+g_kKS6hPx1IV_H1D}9^W+YLuDUU&dVa3i4prf0elx1mPPKSA z(}d0$q1xJD|iSR?`^nTVwH9V+QEkGxPTlw>0Gyg=>zHxhx8 zOvK?rJJsdukqDGzBJ})5rQaHv4wPgfTGrX=ov5NvOb1Fb5ltHH)V}Q^<3LF!V(BY8 zmE0$aaiAmN82t@0BWZ-id$0br;NeiL5?8ZV6mq>~z?RNEt2PxXE zPH(VokMA5*4H`;4_tPGs!Qk(5VA zP^*my)~NK+3U-AB_1rdFo=C?W#1RtarXIJyP%TjBrDSgeAng*#6`JIlEnGANatSTz z5PJpn{mzq|0pD>ey7Acw-iVdnwr@Yl8M12Env1<&T%_kRpMDo;a-@T&m9%{%OK3u% zhS3qoB{XaG<-C&iC&5Nu9MW3S8=)x_tS1Mm4^ML$qd4U; zXOMGW-W>iCd)K;6DlzNk0^z1#jQ}p3-OcE_S#94YBeL|F$#%>27v_z=)%}|k zyUO%@UR#3cK;0#rfyj&dGf}i*D}-!Ev-FsA9>CG>+D%C9oRdgeI%UYK!kA78|0F3PQ!@V z@ovr9HC*~1&QgRX^v2sJs|)jF#v*B_a)mZQTeWI8NzYPETM>@#sPgMGT6*KNlbk6- zazGe;Ew<;_qWU^fOO5SrX4)Xtq9E5I6ESpiPkYXx$%b_)wQb(mse1#Se~R5$e*4DB zc9}-i1CIu3u0XD0U3O}JLOCI}kGvX6`ZCG0F4hCIsDyJ4`Y8#zyUR~@T3+IHQMB&R zlas?=<$Qj!?$YU#oQitJLZOI2NhShw3K1yDK-g$e{HlxJ>rDi{!8|}o+K)?oU;Fmg z`v;!=y+8um9j%KaP9qK%*QsgMxmRA0$U;$^$!YyrX{3 zp|smGx(@L6mk`(LEd#=S8L`#2tbMs_gcc?wdwDMHAjKgxq5BM+q<26_9JlmMwe_x) z#A}B#(lE6$0wA=R@`lq>?S|jB@%HKGgrGPOO%mh@x}h>#!aE0WCr9mPws?D;g;Rd1 zp3ibx-NzQiy@P0Jz5aP?s(Yy8m!4J)8tzaldvcw?`GC-*p=Dj!l*@gnNNdklrD}}Q zWtZp9LKD}rO(XT49nwc3Lf&NOLbPRl{b!0>_|i>*D!U(Efm-1_Q?%j~-&J{S=*vay ziB005H$qc<35Q2G`<6=%J~ykMJLhcM5OK-+4$)TK$*SIMJd%}gPPzBi`=fW(=>0e3 z;f?-_`GNW5^HV)PHGZ=m8?a3(4%@6MWHK#Z<&c|bbIgETh(=V<#Wy%k!105LK&>zi zOp~5l+|$cFyK-V6Z}XP%m>;8G+@V@O$0Y{y#)!kdS|vrbpUd-_s>4&%#%GzXeQUBx zNoCqZAXl^HDdAHhm#+`dGvu5Q_y*;oB<*M7iH`2!K05-fave=W-ElP{YC5zp?=5;4 zG}ov$Z){Pg?qE9l*CpgC@zR#?p0el&CI){9U<6IxTi^ z9HT!Np>Hzd*5|_g)QU1K@$l%C&cx6TQx5gN-Qf8+%R2b^Gw$GJrR^>8BhoRom`ys!8R_tSlaVCoAi1ahii`zt5yW&JkTW~ z&oQ5Izp!OZIP`VssYYGx+ta75z|x1g88rvpr2d;!<6k)C{odN71{90b&`d|^R-4p; z#>|DfW194;+OTdJC&uu311w9pMj|xJ-&v`w{pYt&c`29EHS`8|N=rDG_H9pm6-wXQ z)oXp8e|?gYJCX2Ai-a@cttb93BTw;E`{rl5$D>wgE76J(e%keECs={NHp)ynBJd5i zc4&2UCy1g#)i~+qtG;`8ldT?AzboOsLqLuGGDJKMR zNw^?bKD9o08f^W{Zs9xCuqD!7<$GD~zg=JPY$bgk$w2!^IL5)6q3*I4N@$^P3?b=| zHBz&8mR^{+gr+!h(u%@q*Q7r)!92j6B~E#?H!GMyqJ7E??H!(33OD@*ZG3}>#QeRr zZ!#%Y1&dBkyb$1B7E%W(9a0C0mT;Eet7591xAf=U$w^r+p*J!oCtAV@P5+mNZs+2a zEegSuW6vPm^h>9mMmzgI<5m~7X=E%wP{3~h^?QFb2@*{abSOCB0l|na^k~H z*j80q4|Sf`T2TtyB@7F%;t9Xi+>1Vpq9lF)+};ju zuS4I365Ae3!~DQJs5fe}_5?0D=*Oa0Io}zinq=*hnY|>a?|XVvZWNawJEd#b0LKFJfuHnvE-6}C+W|@cGNr!5?mpaC!V>(dxBmFn44tmxX zS$1XBiR5Bg!k&oU$rE(3<;3v=qQfH@&sLH`k|8u{f+y-60dOjjtiK^-oX70wZPe}gWzmWamI6#%*|L0QO!XcK{R*^$mL?E{-NnQ>;gL%X+- zn`rV<;IA4W0wp1eyoHW*hJVkp5P_0dw&19j!BtMV(*k>+NCcfrB)cP*NQ%f0ebab~ z+T5GHG^?MJ)#0&B&*(Z(pS5IK^zq`-BQpPu{dKacRg3+|PW@=0`sVK(5N^lhjY^L@ zJuBi20%sOR9PW%78&t}FjA&DFgOdAk5N#qbj<$t2s-53P#%Cfh4wQQ&eS`j9%V-l} zL3u?4YG@+x4VFrjWLbYTOm*`&_`s`Cxc)6P$)vvhL?u~aL4>@C;L2J_M~#T)uYK1i zaUX^KRrEdzdl~GnaL)|Jqi^noUTtmMAT?zz_aD&_$b~z3Oay*65c^Q6Hr7a@rQ8@( z<7nAz+x?UTUFnM`47sph@%gD`KrTdM4;LMQTr$>1ZlcXNkn7m4Ax`~ocs!3NeMa%M zt$HN0*QpW=kd(QAXxgJ!m`JR(`iA$e^ zN46!)lY)Bm$}IrRgi5^hqt_DE>Je09*O)B>&r;=kjkH(T!b!^~>4=UX4w1y%xEohy zrWA+lDkUxnXN3PZXrsQEj*7$dm~pz94#EXpwCLy(TXj$Ny=Q6Dy3f=|b^A2^%&X_P zjxRK6IP~o;Dp%DUMt%BTGXER4^t%*0j<4ze@|d$C7Xv`Uus`tg45%@vYD70;cj zgEnd3ev7(TG^;IvuRwZSGID_Xa;re7>VOIHVo}lr(fCEkvfkN!k3F$v1N+dsd*hpo z9_f_+kxMwH$xH{H4bSO3IK?O7MB@{*v3JIs|sC(-CfXDFL7NSi=XksCEz`5q$ z2wA?8Q3)5CQi_uFxPNX5w}Rc+ee6i-bbNzy1x=C+Te&h4rcfLq<5*sPBEnytURek9wXX*5u5DWf#-zl@6N8X5zjyO?ijI%gvcReA_ zU*leF`?k?~WlDOrC(29=-TesP8Y<<7(j_#NrIn9tQ8UiSJSwNXwSviaoZ!@$&aq?LzIw|fCzQ43v9e;`;Mj)VZu%ZOSd#s`q8EXWg+N$wXj2BLYj0iNH9J3(+P5bw^F2BJAOJ z2K9*%p3&jhr*h4ps(Opu7~;ngwDAoha5SjDt6c5wE-U`Ix0ZpeXijY~=O7}4i{1#D zXbBg6{I4=H0w6A-iOZ}}lyia=E4Lc&=-?n#xMlJo|@B1?EgI`kSqs;W^s zGS9Ir80oMleweDBuMmkqNtT6sIVEYZ*m z)hI+Dm$|~S?^!3~>NmWSDk-EGg{IV+V^KuNo9tYOCTa2n-G;R{sHd}LiWettQ1zbW zv#2L3>GhI(rR)kn)eqv5))EmY$>%4;xgzUTkKb7q%0;w^Sai<@{dGDLfs#xFzG+c# zz5WuzXq03ku%;pxqAd$|4&=03fMiNI$|pMLqAY9;Fm6O?>%tc$MBq21@H(|kSov~H zw0v>L@QC{1;y4P&SG0EW^lOsmvA$GC#<5r50QUGzFWA%9#--yJ7stL$Mh|xy{~{}1 zB!i!zOW(HJ?&3QQ=wz|b}jhSw@B1MgUF{im2uDv1`qD4Oa294EP`g zcB>=tTpF;%L@y;xMvrlpAL3Gtr5$G(rdG7qb#Eh1`KhMKdWSKus^T8lNQCUVMMS+( z$=Z8XPG1yJ7INXfULy|e+i!wOM2i>QR%bSf9fY6?E|p<~f!H z17T#a|_u-&^B>EZd4p?0wAgx+3n7tto#dmHsunU{E71Q967jHAZEUTXCINCZkU z{c@sBgeFbIFDJ?;I;ijY8Rx7#&vwVWG4sR61>MU7$2+-BGNSd^aZbLh97IVzKgH4c z_;_dZen!w7kZvX=4wPgf4&FDx>7EgZKuIP7%M#W>Gabvm?c{#;7yF1GerGw=p()3) z#o_}o(bH~Aheg~;M>yFiptW;o}5}NR}KH&rDyaxl|($9 ziF0S1d7E+IY#V3TXhUu5rXAh+wRVJV`|Fb7ms8R~GSNq;5r;7r#da6lV*_E-K};=L z!9*a}(PO*R**AT=i4FBwWLHgh$peqM5504@kq-1J+HCnaC)dIpzUtc}SNBpiH*t-s z{ADlQaxz^Y*E?a4n|c-Sv+8cadmeY!l}(AqJter;Bx)RP=#$IN%vPMY`#-zv3<>V{ zO8d@#|8r*4;53;jN3OAJE;~u-{1qTN!sNQ_R45e-vGZS_Yj2|~PU-?42Stwqxk8^@ zagzGSMqJgWi6glXZKgbTi)-3zb|hkNBVUq<*w^>E(|RYb)}ihw$wUxW+>A&BO0uk6 z=_5khzUuAn+EsA{=B@0rqk4`z-7KrqhOa^iUv+g$=bo63r3BF?!q+Qkr`X*+uel@_ zOCOcWiuJiv`usG8!}5w;h_6py<(JtXccwMwX zfphw1K@LY$(8V_sOP+Izb>VM?wQ8Mr>TPAK=e+lWYI@5G|P)&%WS{o&neVoSLPt5@ZR-JAEINBh8rnu^vn)8u35MxFnhP#I47 z^G7f1wu@6bkIgQ)q zgjhQLx^w#R$aG-({1~V=s8#E_lh_~KOEEbUkavE*Zkv8=p1pRrpHF700b_>*)zmo{(MCUyNg zp3&9sy-8gSdv)ZL@YgsT*Gi8}nh1@lCOT-BThd!y=)kSo@hd$%zmtS0a&ynH-$~0_ z9UADa8NS8ae}yMTWW-9cgpN#k?UC1Fi1WGayb(URmPE?9Spd=Ha+FS|OPAruu$wVx__lmRrXD+XZKuIP7XLMM?vE-Ww zoNXi8M6_(*#~$$E5N{RdMEgzpyAyvOkW-D6YeKX9k}u~E8N*Gr?7XRHpe=9 zzD*@fxj^skLz6U;ve;?TePnrmg@`(<*XFuPesxRew zfMpkF7KkEGI@;sKut)?-G7+OX?NGT}L?Tdy#gP0DK zWFoM3A_67pJxOqm9?#1QO)}+7JY8*5ii)K z<8Jrj5KTxH-d80wBmBQXxAK>kDm|53hKVJw>D`!2$0b>MJwWDaZ*1;mCsrRDT3Y=7 z($Gr*jt#M&_hX=zp~>xCJdeGk71obp4|ehV@^Z?3Owh)fg9w!5^HUrs3lSTi?c#ZC zj*J7}6uG&RIyZs)+KbJ*c>aDP5nU&A_WU7dPY2e2%ewoCj{<+Gp7yVYhNYoz8r<<~ zo+bPF-VS!jKlgiWJLZ(o6d!s*F;l*?PY2H*5pm_K);8>cX8&hzdb?OT9e65UPQue0 z+UX=7VkMmA*MDSD;A-*iZo?K2rKA4}Jb91v1Vbwq%MX?-8Z*ir*(PH6oxAk;F>c{- zmh$wcJC!Oc;qQH%;$B~HTY#`tek!Z|2y?CpP2tNnmDLvEaDDD#)RMrwhTZI<5B#R@ z&G3B%;Yk9_8}0vUPak{A>p?F+(C%U%vYXHpA3iiQ^4Am2r|eQQf8<(J;(#8V&5|1B zV}h=^F5;u3BJBE)w)aj6ktPMJzpmEL26x4hw<7~(vKIPBq5URPJ=vj<(|LbUcM zS1fN}{niwlkXT0Xw4~4xaky1lU)Fx^IH&53y5j6V%BjU()Ypl6oFkVlHDpyJ7nT?k zf$bHdqa$#PgPI`Pj01H?xro+Zu=W+O&ux6cYj@=&BiTe~l8L7sqa%<@=m_iDr?0*p zZkMo)!nVsy$GIYU?3^`bz*0FRxI@p#q(=D|+B>*K-!01du`J&Xy}l=0eyQ~NDF;z5 z#%I=AAESK&LOsX&fs*tN{!;Zq=hOhZ)1fx$=(S2pA4$X=X?_f()v`_hXs?c}d%^1e zI${Mg2YpP?MXnMDE;{S8TAb(zEbYjJXg>zhs_TU9dgYg`ijfl7C-ftcjGM~`h;Fnxfb$z362a*zvssjriOFy=o?eRqZ$e4 zG@##e;i5OlCE=Wo9_OF4^EWE)^&dwrm)CcR>|S%eWcfux6W8bx6;x&&Nk>jTYbcI( z;Il?f&T<^|mLFZCikx%`m*kqW_0vC`kvB1YI{CD7t_aT=EbD<|ued)v|DV^NU|ypLESyPsEcbcz#qgqF+6xuDMk;{Fc8DB04$(xe&dw=T*elV9C1m5o&5XxBs6jf&2cb}{_55TN;sDMb_uVkDy_NX`}|(J3#9~0M#1W5 zoY_mHl=v9BDb_DbPaiqqwEC5~5bg6*9LSY-&{sZ>Qj)tx8$Fxg z(tsM8TIJ-qh2p?7szOJ`fwGSDPYQqSwc}pZcS*?^MUuylK4SuJ7H@o2-_^-nC`q{K zH)talrpZJ+cLy{yZAzKXQL6RhE_9SGTYz@r}=FU7qk}Z`=k|Zf< zlO*v!=bkyweSbX9^PBJgzFs_M?mh4GIiGz$$C-3GKZL=Rv&$KBFh7I^To+gFa-z2- zs1Pnk+*hc#vBpIg}p><1ag`EM~SKf+%u1?&#rWP*DUnIy8YX+bQ)J~`WF3V ztW$sCzR-g&y;XyJJU{k~{r<>LyxBJ14OW^{c~V=HAS?T*f;5$y2HJCuKhWS zQJUkS^<2awLcd(eg=o`?`LuWEhAo$$TQN862d(Ymx#3lAhz7M+m{XX8J*Otq=v;Hq z$MDR{?}aNL{*~GliLDuPL<~&)Ik2ENH`^q1Y{Z$7e+2p* z5U#Y_ex`j@p{q~$J&>_bXfuo+Cw>al`%}Ik8s~;40%c(ssI6sn-&@_Tu`AKbbG5|8 zIfyF-N~3(HgLd@^$xh26lJ1xXm^YU7Wo9#b)e{xHFw~|jhk@ZK9T^77+VEz#u)it{ z8&SN|@KfMq-hLFOTkN#5pa;O7VPGQL1BjL%Sl5g`10-42)C$AwpB9bxS~$IZ#xYl# z_11Pw;t&ggTuQSf%R(Ebx_Zj8&~B-|7leew{|D{i&m?;`w)hLCD5kW}&!rv95|%_v zX#+vGWgjEo-H~IFm|+-NA(uHW8J3dj&aWk>+w|@d#{ay_RHu^Y)y0%r)F4!`(A83|#BORxS4GAhv_Zh3ME};CgG>+8@!4 zI&DRx-R@^Oc)MR|l+Sd~*qgw$EOV!!WEI;z-STex(UK#{rjvhz7;hG97!hqE`H$Ki zDB4d>zAt`YcfeUBt1Vjw?+Da=CEBU>#$H*q2 zlkNhSe1`Ft6+7mIeeWVd-%A}tgmUp0CgS)zUjzx{)hrl;rb! zZ6k2)Y2A%1dFF>euF0jkIY;ySlEn^V!mO^&n1>_fBHGld?RQxPJqjldQMQvv&3 zGYtHKZ4^o}5y-`>6nl!unF(z9Ohku+~>{>>%AtW*X+%BlByG{*sPShsKmYS4@217+_R|A{^6qBnJz0p`lQV4=q#Eibl=A9opx=6_W8NAWWV)Iz{)!tgY&^f zWxffoYmwjAK+7%PTu8Nl9D8j-G_6>sX)nVMh4&!+8ny5J9Zui8_2iyYw>!Bl{j#f= zpo_H@>mAld+6{koc6Qo?!S)?pOJ!-AaQZZuI4v;zl%$(!DWvnYDfa45ntLS??ewFi zbbT**t~zmAVdxmA*zq_)F|4^kVu)Or3YOJ)>E!G-1%`U_D2)e)t2Aq3#sjq~G49Ji zdSN*sgf$g&)92@SVBR7Z)@BodUl4)eS=Ng`rP@RPJ+OtbYAcL&SDN3Osf!5xawQj{ zsTDnYt6R5u8+%fX(T&ii3#Jd|KTFext(bpK)(9l6Fdj`OeI7npWm#K)DC4#`(8?bB zR`&)N2FhLd>$E_j&$XTxE>_3B?u$ll#`lvmao3VaV}#q2kw10|zQeJWzLxlXCNKkj|I*9wadpX59J(Ss@llu@T6H<;_IQ zYVuRmwglT46LI0v4zklP^`muzopgs=G^sveyVEJ}%q6Wxz5QuYyJLO4`k|+ESdP-1 zRs%=P3*TIz)1S{J+7I4a!=1D5pGH`>F%K|rXw7nYH}}Smud=rd>=2K69xZP$4onAa zZfnmpmiBeo{EkkcTZC?OZ+mA*p6zh*+cY>}-?Or+-DFX4bGIq;!Z#{#r&{A7Va(e( z%3blwHA!1bKeSm(1IHG7IJDw}nvqrc_c~IFiYD3R+dJ1>CG_!kI+86kp-n`;lO3H7 z=l6MiEv|l`Booo0VW;r=1tL(Ai9k&-eN3&!mVeRBy>X;hKQ!hnM`=z4Gv={Q4q%?^ zzM*Kee#Nc!TfsJ7xx#c;n!~_+GU7pNul@?%_6i>dzHBS&FeS$A2%nj;tcRu!x2xCa zm3_>8APY+(mOgFyI6TXG{O?qE=4~%Ep8oUcc+6)Uy_hlg^&;;P(BqW*YKN2is?;vC zMj-;Zus;582n^5c)i8`>BX|xFsR`D3%Np9auDu|$VMKml{ZLw@-!lG1Hz{j~S4!~Q zywaSaXras3iQ7cXDa^rT^LGTI-|ZX!^Nv8L<1#luE<~GQ6e+-#xwU=uPqCUY_c1gg zdwe_XJ4#w%y5TCf&(HBNW%*&4=y6Ae+si-g?xhv(2`bHLrIz@lBqBn;aL9^aTHg!e zt&tyO|N7K(_Alo*$1nJxqBHE0%oLkWsz~!s=`G8BRna;5Z5!U|f!lWQKP8CVS_c-d-KVT#J^Qs*V0iKd5sK z>=R?oKWyv)kPFc&pMQfc#t_pDQ^c|+FMHf>R8j0YM~gYj!c^eD_2D`6C91XjkDqIs zSi$SI3GKZP3GM$MBp)Mwua7+$#?P|w&X3;hVJS*;Dbcn|q~E)(jr~L0TS+5Yb&*njpZ$MbOHUs0cl6Zd^K;onFI-vIeGv9I78`+e8+#wD1!fqyi-=q}UNI57hJG06 z_q4P!*y3PIWLck89&CSCc%GN%m{Uq~KCAVA$@5qUEEeO*>xtZrQL8p zKQIr})~tll=+Tijo$mLvLRm^Pm--K0 zoE{$rPVA4?t05Pn&5{UV*nf0r>2%1DUVm1TmUJ^v=;p_oJ3~4OO(y{_mdsw-Ol||B zR|@p5p|r#!KZKW7d7d^RV}8El0rD#&G0bZ5A+1IUeZJ%Vz?ZK_i#g8EPu=-pU{l`t zyS6<)A&Ys@5M44uo?SIg4QxO9Ro&*D-}X~UsGvJ+^fu@80XZFuT!^-;tkss=soi6C z!>0FTX*9qo*>O+z^h${$I83D(Z6Y3@@OaWI2ZwtITsPFR z#Au}@b@M|omr4@EH_x?nd%l007Y6FCeoUB4X%3GcfAySePJ!w&g2Y;cwac<@q+07P zscGl#91!37@3C~tP-^PY8^_U!c(D{yx9~VRv%NQT;QFmKB^3FeRynt9BR{;7ADB00 ze)t$#frLH-(I(>E_@?f-K6ltlYgdWKbdQz_BK`UY``ULsxi_i$eG@k0ttLF*t~a0f z78K>}j`oR{S4OT=YaXy%r6oTw9@xv^xDCg1K0oIR)*P%+*kVwBer<~T<|oaQ>U@;r zd1O%!mbCK|tt~-BwXd6##fwNTL|fL9og=CirvD@XQj3!k1FzE@~jw`_aT9hrN| z3j<4{(m&k#?dnd)$2$G0N(uF2!Mz&lyZC{Y&X+|cP8dHEv2%BGr@?iRT!^L{>$69@ z7kYi}*#uOtHY^8ILEmF3k+U+`A}h^kz89cgKKO5bA`!@?G=?FlUYncKC|}}%aWdoa zAbFNg|9ox&W7Os{%Tk)(qdh5F-;IGlE~O*1ag8_>w^v*;_>MvS+odHkOUd;;lI`+yuC^Pjdp-eM z1H1)ewhXL|JV6(GdaS7!Kf0;6H`P76y%v^nWC7|w0bgsg!|`p?Y&Vr?&_-UEK_NTM}7$AQb~fSaa*dp zbK$DQX1hCOqV8y)+>A%DM@G4K)x9a{rL@kQ5pm>>e*>33{Y+N5!J@elbNl?kmf>+b z<~f!Hb8j2<)m>za2g+xfeJJEQlS@hJVjo`W(NNeAH=?*7^hcJM&t}WuW5`e3r#r(t zxyXej#^-0PaCZs0u)LXw1C@4$cYZKDM3W7PZ(np@Ki1#tqoU;jrxn_cx2(F?ZA`E( z%P2#AI58KW_uw7?Y0)S@1aqk*j04j_8(RRZ+nE1|3fhO3G$(sx(lQ`ACc?#h##*#+ zZ4P;7i_%`XxUYuyP0ZBoI3&g0fA#%Ib0#06)1Q9b#uUZ667HS7_NwyXu}(^-eWCQD z={4{sn!e|hCOf-7jkbSJ+L9RRlhBa&Wyk+4J!f309drsw@*}4IC_BA*^KgyAbc>l* z#@rdXux=Xp;o__aYZ0cpiNG&dA2DxC1ok+XYq1d+1|n*|nd9{0kspG&RFWY4|AX$F=NAX& zJTLoPdcKIiSGi_+U}CPGL;mJ)DYxvcZSA+7nH7&C)-U(Y4coOaN3Sndd%@m&!?L6Y zk1vYH@`I^h#=Pc*N8A+yuCqr@?VFX}yrOflnxs$MBNd(P6%K?BE^J=Y^vCjv`>@;j zSP^eNi0L!omWzRQZKOBQ_Y&v(s+Y4bmS}08YBo0$L&y9$RC$N9p`3*6rxdrMJ6pX+ zCj?|weo>kC0$b9BPVJi&XuDWwIy;o{kh^Em&Gv{xXR=Te^kZRa^}*y(ZnK6rWPkQj z`z)=UTx;>pDAr)h`fp=Fd)Z4}?U{#frdzXGVHjVHhY^Y0W+VoIanrR=Y7uGZ&u5RuuozO^d0W!Y5a8 zZz^SGCuiG`D5b=XpW?BP!gj?xQ^YwZ*vNBB+QVImP=4Nh-Rks8kbWDrLT#0se+5x-ogj8j z+U)rc;x8zP|JEmHta9?ork?98^m$7$@|)nC;opV;SS_pW|88VOrly{opqm zd-Mf>zLu2!-w@2Dk_7ScoC{lDxoNbWyrw{WVq7IUUm`KbePFbWEIP^mLAUbZjKGOv zvi{jUDd#)>>Wx>Bo9Q5p z$ovwAd5&mA(Yj(V%NcXC#2mRQAI>805%OMSD^Am$|3<5xN@IQq66NB^V%5N$81cXu zBKk^%Uq7_%;}Df*O|X3wZTNqFqQ!#|`X1vU>#rzFY35oqZ?4xKi(L6%5c=gxE<~HC zovpG0i62QX+`mLt!1*>>@9bmv1f9>-AtN)OXWGsF91+O1y+UT7{MAw_V1m4As6mbnPGHj*eZN01;azM zWqtBps=cP)S#PaRJ@s%r)GiXzB0oO_bEzcMh-uU7GP?H&lMYYp?=t2@ zu>;P@gfF}~x#k+bS2`jG-QCA>!d_w~+>+}QJ}oQTJ&T2$lPPkKi6!Vm9z$qNZ)qL* zg6P-?lshmn*D3c^eqkV2sYi2Z$1Fbta?Ki-OZQ{)Lo~Q9NY;>=r6|OYndZ}f-UZ$>n`&SLU`hgzgl;)VL=RBz& zu@K0mv}p5Zq53xc^TI%Hv=}Ed9#ulAZoIKCRivF0`$V`~31LnHY17Z58s|Tm3<4m8dQH zx~E+~@fUBM8f7WXny9Zm(JB@Kxs(>IhG+D#Kj`>b_EWtIHAH{HsG+Hq;Q0TbJLIM< zo=0H*tJ>F_J+A^x?`*Y|c9i{bBERE@EDy(TbrNY##(yCfqUpF zaZa>~g+MN)MXOl$;qb1u(t?;WXM}yOMf>bm-yBK1WWK*$jFTA;!SVk=7e{wE>cg>} zWxaC8X#48>&v|1w)K_Vat@_uKn8!jOm(spILXlmmpn`wo!RGpbJ0%xZBp3!s?BsnQ5V`7SX zXn@=3j}Bv;?z9uf?~gw<#@TgF=OFlmvY~5$m-d&rG^E|D1R;|7am1czcA{4Dy zo;+mh<&`a-Un>3^bJNVhJa+mCx+hb%(r##E$-s8S=V!zp^79r}Y~{sqnRz3bga{ z^$YI&AK&(BZM2w2h7s67UQ*MqNovxn#%4c;bJH*t(5{|k@%gJK-oMSg>h9&47zUaSnW^w;&L)c>EaJyOS@-g=ouq;@TGOiQw(_dq2&{=YD-3u9k(jN%jv)IN!#R1x0 zUGwKB*~gq`?CZ7`-i)&F^j6Pe^QiBNGKRAYtRCaAZ4s`|mY4YyYt8Y%V3$_>-uQuCRUrbRrTWt)_L|_<*j*Y;P43<8#RQjRt{SUwFPWvT2a5hm| z18gyTe$Ef%!hMQ9(=r0}>SZ8QWURF@J$f2r>O)JmI=@wo9A6vpaFGk=Z8!@v5m;Wa9LFAo zOvNixms2{j$H6rV+!@6+4$Jzt_+56HG66fIPmOro zwZPqsS=%Z(2XD|_0NgRbT@s~PZ=A96-};cPduY#xWsh_&&6Kts%d~RyZ_viFi(EaY zj-r!1T6TX3y=eE|HP9P{qb5po`lt`T$h5+JN8CBabThQ_cB*yX8B;Vi0(U2|<;1Yb zzX4dr)0Gs~cS@d>Jk~Rc7I0g|SGD;s^zV%R!<3eo=Z9b}l@ys)EsHD*G`(K- z(%MaaCy;iV&=*%Oqgkq+TN2&){~-AV@VQDCdM9waN`46BDlu+p;M`sLA&{%n@g;%X z74t(N*TFVR$nHyi2;|!I=i)%WlM>t5F+a3qabVXb;llG?F%fo;r$*75Wn`V$^yf&r z$0qOnwB`D7{@_Tb*(UjdTv%631lBu5$3|dXLoU?T3L||>UtbR{-aR+=L7WmO0 zxt2bbskD^D{1D8gk_2&clfLfJVSAI33mnhHxh&3J%-QxWFZFd#JhUf?L!WW$C(fWe z-}Wk(-zzO)i-cl=Es@#D_ED>(E+O9pxACiGA`_a3e6_U=8Dd~+zm=R&l~=U+N4vMnQgYiHP{ zjPM*Vd1*#?#ll1&*Trg?o`);uh>eJwoe}nFfLw?+!$1Ubo$r|GdC@{{rh_gbkZb(2 znV#*P{~Li^eWqoG{j?y8ZG~le78m(#`YRb>o2ju8OV?$3wpg!3pd>SlE$NwIxfljY zG7%Va)CAGAp7`>Bt*2g>)9N@g#Tl#8B0qP@P`hgFiC(S68#U-lMrqWDX|n!J@!<1` z{1!O z2l{RiIx%i};KCYA66?5DL!uh9VwI6yW z$$GV?-LLl944ebvT-}@xqSXkDq0*dIMaIqo55%z$$fY#KfoZZ|0{c<88i6GgQS9wz z6#2A}@`GgzC7FmVgDF4A6AkN*2$WN0>iSu@KO=2Q+CLh7Rdcb-WxHu zH^PwRh{6YLsRFHG%wC zeC(C>VqZ*fT7E3__HQRR<+pzv>iYH7t7&iI&l~MQ>pQx)?)V}T5y;j3!USj2dmnq@ z(TS#49=6NXzrl<7j9bS#`x=N92lZgjTIqYH>E`tt8NVSK2CmxP;%*kJ-^{X5{rK#^ZJUaU^VtLZg zp9Z?Gp1xr-rf#(GB#iBkzMuH{c{wB8;l)>+-ET{-p(aX882KTXOC<@y|DWxV4Gf=2 zW&g#x>Nu_6Ksbd|=j~OEKm1*G$&&AwNL~X)E0xQ-D=pIVLok<0^7Rpl=23@UqjeZ* z0nnbvsXHeJy6ub7GPw8!5w(v@CJ%5?5T1{9jV;G~%V4ntZpPf#E$B`eyDogYruu|+ zqkN`Kt#FmI!0L73dm)H2)2ijiYstdA!~^4G zB2MgI7rujmaxpy1`kl^k^1laOEEAu6$E!}gymo0--B+D)IZ|s&)_RpjuTnxS%iY?; zEmh;M?2@U?bk*5Q3fjUjM#FHcKR}%w2s%;0yxM=KSMuy%pJ$J`&%{sBe1V zSDisCM0cgfT`c&n9|Gq&_QKdZv;F?$UQ*sn1m+a#j&Y*1&flcCZp{YXyjE{#a+;u3 z4SmnIHZ5z?^vvu-v!8cAZE?Sm=h5O65f2;tj6XgZx;|>`2gs~)|-51OH zx%^;zV@0v>r}dm;d-$I+bPGn#6e%rn$Pd9>DoGFzooisCj9js{EwmN?`>bZi8U=MGdFMEBCQ4*HlN z`Ka>0V9Cd@>Ev0@k@g4Ut9zq9On0R@6*|9O#@RJgcdBC{kV|P_AE9{ldS71s^*d`R--VSeE7>B2ZE!LQhkm zCX7Cp=cmO)pj_lav}KiXRwoWEBkS#dl-TDKs4ljU#s&8}8Mh0q?>XJ*JX}gCn|!iF zv_8k5noc(1ByY^Nn@#Vfd3M~G zF7@bYx>qKnDE?cYpxboPtiZfSWwn-j%S)fiSOj}f;-3AmeNTCrrIs!*q8-CaalM*Y~4!CZTbjU?Q47F_2b-rIg?dA|`@Xg#Eo{+)<9Xi|nEe z5#+)>X!6Lj|2cc`iy^N~9KU+MbD)x>6`qsO_mby+OlWuMi8QD4UKxKDDX`iZm?*7X z+#_i;KbQB^WBobG-qZXlZwz4o(RTp?k1GH=Xo?Q}H)l*1cn| z5myI?%y=s>ue4gt_i^U=#FLxT!egz(SJONnz&>v{jAE;ti2<2g;s|L_pS18?1W`d3 zQ}G0#G1W~3u1(^aCB~0#pTW)#rl{@$MW(JFO3*G^bC6SUfaDbBV9b4I8@YIGvBP;m z$3`HRxzFwIjQcuqTY`N!%EcC(JUfg}wMVxdvGwvd%i{O_eVObtNUxu8^fGx!6nfR~ zmji8kMw@wU8N49Ssj`%YW}Ds&d+NawV`ycpY$IaV(1n41Ril+F{DP&=3?q#!4%H*e zLmZms8q%DDv=%_8bhSqLdhxoCzg~hXEGMR}b9BYi=cm+lacu!tRdBV%M4-OMM$8Wk zxk+M+X-KxbHy%hzef1eH9<;AXcVeWyLf!Q}$0>Fg7gtUWsE4b>xH*BEFGSlnIJ9IA z`R9}`sG*5y)naH;aEpU& z38FC^b8QK^5N#r`RYNWe+d$Cmf#<{NgFSNEZO_w5jwmJ3&bYn5m*ju(EVrio=9w^B~3`-hF9#10?D9J?N_zJ^7NhSjG77;j}GZ174GR@JItzn~A zlaDGn>&)+WULWnWNYhEP$)Bv$TCA!4OH2tI=@qS>W8KC&e`e&|z?XaTLm=0fH|7P7 zU5JLjRt>{55oM>no#@t+l_G63IL|M2+83@9`X1AX>7Y$=9lbM%JNd=F80gICA|oe6 z1&#A16$fnec8-|?*D_56axLq+k@i?*E^8vD41Ly4|K-E%JD=zoU#!bzS__x@(QV3Q zI@KY)<-EC<$$pKLH?n2?!dD5C=;jNDrryPo=2XC&vxZhi%TKW~1; zXc{4x+>%|S%=2CtZNJ+ao=qf_mzJ7kDlLeq*S58v_(ObWYCM=rY352edWl8`QQ9ka z*rj!3_akqtPQ57Gzhd;9GwWiUJS$Ofy=PU0xwzj|1WMv>`UKt62iAGEwD=9mMYMAB zui2ktA08WlTo_(VgnhiqK@&Ac$z zl;$+SQptboLze4XOro7C8D$)O=;gq|{z6||IVteji$W_m{{~&;;`Jo9#?N13B8(9- z&TDWS%o6kox;SgV`2hC2%FVxQ1?siHlmd~OpoS)*ZO_*N69ZCS5gi+WGdi4^;=F`* zJ#T5{zIM2*R|kJvzL)%rO3kVA@?NJ(-icOyFB&02x%dlm>3gZQeoV-BX8Dy)i6$}= z#`Nj@`bsBxsjk){;y|Tofs1)Q)_WG49Vq*&q+73qsezVsyH0q&XkW)3Kev(Ft-+eC z8Mj^zWS18%Yz;QNc`0zFvAp*~;QXk%eg&O9mz5jT3bi#6IHN<|5p5!{WZ)bSC0W*c zAGU7%?S)h~v!a`+?JlPxS|%{t-Cqj!bjuC=&8tz9p4;oN2QWrx95e&wIctW2YgV|Hg^~<}JuG_&dH$9b4qG+s zcTEJ=ZA9=IN3%^)dS_$x1JSV&s1$-~ol|SMlkiq#>BCZ~VeoIz?b2v^;B;|m zMR6^Zy<&EINZ!Xr;BGIXO$5q9E-ceDcOIJJR{PP8NUN+Cd!0QW%P0)f%|s{{hm2hM zUen6AxTxzTj9|L{LOJoR?-jw{`3bs(m79NqcIiS-)BO+0=d+`F(%Pcb zm0{UWJL7^vn}}-NdOH)Z62#$udeXUYp;3~7AZu^SoaS#wYK8SCHUf2z*{e~z>+9S5 zhbQQCqv*b7XCEiKe3bUexEqe?HmJ`#)M7|(8d{NmPG4?^hEI?sE|D8MjI;kOrMa`n zLfvDA;Wn7Klx~v?;`FrnVJoXB$|Y+1WFi_3PII!4%8ncE_ToI+KzJuJwJ)l2&2!-~!;w3l3y-^EBamxP>*vCAhS&(? zx-j~=@Z2yq0=cGyo(s<`VvHObY7Bj7koj9Bc@p5b-Z?+dR+8ezC6|9v%(b)D>Uk=*c|mh$5#lK1Q=yM2b0 zG`x>vYUPJQ^N-Zl=VMtfeMJH~1FGh}x(QQ~JJ@XMaoTbxyO zBU8ad;GQ7H98<(ZU_4Mmv|wmii8FJuht=!nm3B;%v!nKhXW05)7d2b{Diy_VPnhk>x1ODO&1FI-`@fer<$d^ehrp#|%o`KJryj6X!`fvcwhSImE2+}XYl0VahjXGtaEmmCRJV@5l&RmRUi#7ej9B0*f8Mj}^&T-UM1KV;xe^B0=@nGB9 z>t2vA$c1S0B%tz-dn@n^jxAhH)xdpFU>h*POdb|G)qY=1ac|ODPn4c zd28O(Ea>fjA~(})pTW2BingNAh7_WLE`C9KR4B<}|GP zVYE_$sf%36cjVF;uV`a9{DS#`l1#1e3+5Wr>XX1kv~IASQc-GD_T$^VyPd*u0aCZ! z)XwhZ#n&5o&Rm-3oNG>xf4!YwK_f!HT*-xKLCOC?yU5tCPFlAkUTL2(rz=@K6?$Tz zyQi&`lAAvnX3u&u=&f??6Q^6v)I3Zqx+;5 zW!(O6U_0$jGZ$(rDEU9=4(bzfR_u_Rs=qK4PPaL=a%r_KS`Mz*krJ>s%3OBc3oizC zwi2!8&E0r^-z{al@XRoZtZ#ysqwjnLcD&5sQZl&!7jEI(Si zE2&hQXT5$9$JG6&?+LFb>3d0CU$3CM=Gt7c?;@dhX_QX)66HoVx0PCYe12}P3aswo z?3@(28igxt2EuhJP%SzgnYtJ@-CY8EWO|;K<)}R}ruBV9Jd8CmT;GeW73OU-s^{JI ziH2Af%oD#DI-ZWz_pBj4L9$Kdk0x;K6-OT?0@rkL{SwzpV4x8ZpiNc`%?Q zk>6;Y@6u>=;ExWXA-~rrNO#;*!d}?;ts++J7#65lC)%iQ-```%!>Y)_k?fUW`(a!e zx`ttfji;kEhMdoMl33rXZ2e{XyMqF&e)m^WG>jAb2L!rJITWRBzcPl3xKffI#(_%x z$#=V)-@H<;qLrI}gZ8w3UFoi!%qF^IcJ<~|94DOb7znb-v6W60OX^}7ThMeX-Ia=# zgV@%hhAcO#R&)#7pL1dkV$Pacp)Aa2M4JfIy+|m_TPb1=Gal<}pG(>~w1+*m<;;c$ z_Y`+#d?9yxYHld*oY=oNl=R)gcT27*?i}5xcYdOsFlD)T!c=K~kB^+2&@@5aP0L0; z=1K(4&n{4z;(z=M>-pK%w)5qtx1PY{_o#`!moXe7l#9P0m%e97_^?(iW*EWfcVC%M*I!TG!NA#RELLc3?*}J=3+M|w`G0^Mcq#5%^`;=q=v;bLPM`asy$X{jaN2YOajJb`9-HR#t`PoYma4tiErzvX@mU zYsbai*NDea1GnxCP0iZ5I^*`@ln2shl&xLd8J4a6haXDNEmELhPWP} z3T*c1Gb3l;Z`TibG+DE%ljQ!;wYpbIELQZPmbvgK!=~!V0-ChZsz5CA0#gZm@6D!q&E|$J?cg-YA4j<((vS+dLZqb@Tqokla zoV>mBjVgZ2smtdln8tkKzx83?XMV0B7owG$f7v2XQqH!&R^;GYrenpAcEfLdjBbob zYk)Jo*t5IIv#GcID6;)PNf>_6Xnn9op(GPg?dxRc{7rf5yP)B)>||$FL3wW?c(hdX zm%KA@fWYuf1g;w|n%C8dn;mTp*~hTQ^o}{ZhAzwT-w72f)yWF&ZCgC+Da_Y*3*|pq z$zvM#BWb(~r$6zOr_zkjlb}q~Jr?p3yW?!#q}F%Wjo(&(j5BMSv~V37jv>#z(xS3; z($rPDzAHHXpYNX!&goqAogDt_;>v@$N-vu~JvXJ~NwQXIK4#1jZSwhl1#R>Qjb5Pn z{lr`u)%g6Z74FMpx|wO^W4PV*Q?$`w8b_0@LH-^afi()b5bcM+TH%*Moi{tj;w21} zWFn9YTTTcWS>WG{0|p{oc^C_ZJ|mT>cZZcb#kROfDt0Hf3ffXWJ0*E7G@ddv6Cb zvQ6CZSh|yvcc$ogm2|3IqE&~CjowLMU%F7#e_Wj&-VMbYfOsC<#$Px9#FA z?`AQ-MFdJR5t{~OdAFDOZQ6ni?_?g+D2e~p$Lym}cZ`!czQS}@n&n|WseJwo+B;jd zcNTA#Sp$~JQFeQ0Qku>td<@&a^2=-QA38ecew5ZOX4$oqd$o7!=lM4{JF2}`b~!gO zY(E6n3b|0Lmd5usnM*fw)bF15!y zNzQcYs*k;s_V}LB{I@yA`kr~nBj~_^_EYz)^7hZMG$_sJl4qtlO(#X056&x{6}HKdeKgav?!Z!8 zw$AdbFfffz&_%9NbJ>H3^f<8*$aSFS{}Kjr#g)no_qDOZK(6sWXLu)xS?|~gADQ>MD+iSKrU?CV{3)I0dgUl!_+6}VlRVzMr;K36WEiXB-)WoebyeA z^I>-Drxqn}iTkYK{?Lm*wpe{lV2rbZEUR+4+EkBbmQ{pSjrcd{Vm-&YU30@Pu3f)r zly(;p$knshk8Z-zgnSUl#dP%vKje@i#={;~e{sv!{w#-IV|cLmbuGm^( z7}z7nMs%A}AZN?qHeTphu3{r_1b}E0fv2M}-SPaczQ>gHQkQHeba2?yIOi0PgE}3T zIj&v5gEN8l4I^_9*KwN--5fp#fa|`NwfToKcC!Pm+(m!inT35S_O)0aQL~_nI~XVz z!!r>$#zAy!1dfn!_XH)GVc^afrUI^VTUNX49z&`8v+w>o*Bi-PxHQdacY~}zqCG=>A5lN-fr%YxcOkMy;n+?zlmE#} z4AeEOgDA;BxD!5q-#azOZ!twHcYQxRa<;4`)l%(`#=hgV?HF_H`}DoUWBQ`6k`9f0 z+8ZZs?=hWbkg~6$G>3OGVYcn4^X0#a(9vSmne)J2E0F=HGZCPh-ZI$>WS=41j-NG|y{X$}>G{3iUraLuX z(3pE~y#M&-{dc*#2hq22)li9Xab7!LuimlT^BrZM!m{!^1Ifa!jJbxwYp?#gNXsId zy!+Ms71vRen}5j{c7qC`zFY3_G(oLOt~s9DDE-qMp_qSrhktu8yOa-q}h3`1Aau>HVR!bI$B)ye5sRl>mVVuvw#c6&$t{EW-# zNM~yWUFDh%bk{b@2!K{t{4h}N$$vZ2P5&q{cMlIw_eL44E4EiPUq~m5n^N8^Yy9&S z?LKRpd38`*8Akk3VmeupmwKn~wOsLceuD0*-%C=t?d|0)<}>C$`Q)p&H|drReZ8`~ z_<_;RoHHU(hrP@DYwWo8uH5`96MHS7~n5whZ1KwuC~V$3P&L(h?8<|DbU`5zoD1O8fj= zYw_GP-pz@PpmTN9@?4;{*KFGnZH7^|aGG}T z(y5iP?pM@c3Exq5AGNjsIIRcr)uHMp)79X1sWVNdYY1WHnF{-u+EOa9O}=Xy+?I60693M}tK2{*?IoX;W?7o&TzX7| zez}qh(fm!HpnLqC`Bc|ry&dxz^WU=8obT)Y)?l}{6Bq3+D$9#~FEye3de4tDbKy9l z>hEhE^}WsC^a+yfwu8C4@{bytS}8xLE^|dgpd{0KHjWLHW{K)a+n3Jr`L9J~rhAs_ zIo*}!_gl`+48P~U^^tW3U0Z0Bemr;I-%Y$)gmY2l=3hGR16+x5$8$e>QFe}FBSyK! zLfx|_M94+78AgHX-{qEEBRdUKx;|6)Di)B=4 zFQw-daaR4VBVbuzmFTIY0UoU+<`| zcr3@U%WkiP_1>*EmV>1o(PkK>3$1t5?>vSVGYmWKkyZ|$q30N;lx*SEBBnPDYULEB zJIWC;w_Co}%BlCKd_gX~D-(erJ9qwG8ty3Jt{lyH{~GP?cq79bWpvNF5NKOT#y(1O zOgkOF5Ezo@p~Q^&HCL^t(=^hGVk*Rr`LOI(bdN#efpIe9v3+!^TYPfR)2hLBBb`yH zQZg=FN^wfo5?bG*MoiPayB`~cCfp(?$xk0>5<1;pRwHmd08v4Ecg5C@u92aJrdB?N zt>eq>BQFPMcBC7JvMz>_e16snSBkJ)?Hjh<3mdtaX6s39LwdGy!Ywz3Hk7-bIJt%M z%`kAiy>rpyG!Bvz(YVTOB9gyPbJS-T&OgmL=iMI{u{ZB|*gmmzR93aGi_^SM z&h_C9H@x{~hOzkScOA9gjbUKeCIVODaRnM%R1<-*MRe?#W2wf~um25!T$ei42@T6W zo`V`O9dxnuVR*4iBFe)0h-k}t>u{=DX-HwC_2Ce;_2E*I{nmxRqGY{2fC&9^B^RPu znm%T&#a47fx#nT7^UBS?LGnlMe?bJc+9m?q_7%-qIn{SYw(}+exwsD5o?sBG;a5eSrIhH=eR3877YrbHl6l8HD_ zsZr>+{OofOaiBxAT`r%z~7m0N(`tE8v0dXYs-RX)owl8{@=D32k zEZle29Uev~&2n)Mn!oA8yRZK3{&OocP{C;8ol<<50Tc4nt(R~fAY)0l@xstFVYJfBRia=)C;1LZY5t}Ut$4i@4C(lDPwT0nl51}EdY>=b219x#gw9Od zQrrGB6z?8-VN#7#ir(h;FwuH-_ruM=w(_sH<@Ot{F{l#rfb zz_!aoAXnS(MiN@iFvLbI{B;za>yniMQ=*oaAK!H}Ni!qx{{ zB{K}Pn$5I&fnW3Ba`L?%CC}|+Bf8UF2`O=C--)!M^@a<1t5LXruBFrh%l`-2vBz50P;PNi9{S=wVEkV|Q)wSH=uD=b*X zaK7vFb4kQ6h=`qom>-x2XwlpZV^0x3r?B3|Mszs8%vtfW5$h_ z{~NY2-gJ`CoX<0E{V$O4mbRk4UbJgT-qLpNmT_C#N-ODhjf^bQ3$LJ^tthhz!>$_E z54`p5hrs>$kQrTR-1d!EbFdc0M9{5}=Alwwc8{3X_Wq$|C^7Ca@4XR*J9&29&>x?K zyf2#u-4hyqYNtoXMhr`86FPE-+{Zw!8FSi(YHs*FN*G7(Xcp=-?FsM8iXBZtts6Y$ z(YPL9YK2_5QV<(~VIUW#2+b{TdNc7{lI*4G-VNt-mw#yA;JnPd^}T46n4DtY8t){1 z*K2=#+HJ+jlaZ|T;eB574zC{*_Vl+rJU?IX(sHuJDl=^@c3e7t;shXIT{* z?n!DfQO;Q=#tn7O{wF&;!%q!y%3m$xE2S|GOw&0_*pbHl!IHI#h9*6i6Qv~L4iE19 zpd`zhQuSbV)dtTPG3OX!%b+nAtq`GH`~|u6y`s`my*{t5X(eGWC#MttkGqpt!u37#;1hIlhYxrDFbys1 zrde|mziJ`tSLq-A72w$q%Tby&DO&Sy&*r*l_2l6{lO8PngqOM+L;j*PkJT|146SUm za!!tObrXT-({L7#l4u_=u8-a7fiJvs zWbAXU**U3$N^>gc=v>mu4<+bge}^>{>%Y&>`Cg*QDyMtAgn?XG=S{@;>Z`+c;INgz z@JvL>=T}pICuxOTm?E(e7#^bOrp2*T`(%alTNu0Q_sfB{J*9BzL=@rTY> zaQ>0{(=s{}B9d5wK70$JKJ`nld2PnrngYKnH4MP`3 zkBi_!w28nkJ9pm`>U8`K4~>#c1ac)DtxH4!XlIK5Y#++qz;#UwT$;@-jTb83Vw`!RGG_O59!mj>p%j{({ z_r&AsAll)HX?w>-ts3lF?lc-KTA_yIH{K+~$PZ)Yl^EAGTz0V>n~19GJJOs} z#|-`%4YRaokiW1k?MGyP*=BYo>WjYHF@BcSrb)c}>cZOIh-}j76tV*;tIkSup67m) zLU(v2r46lIoS#|2dZ82hKaOaLOvH(HjY1XI9f{yNw4_0ZYC)7b*yBVz%^<&xK%gWu zjMSeR(@NU+5eSrIBC1?tQ_p!a0)diDMAfYaazK%gWO(YtpOT8EQX2HRbfWFjz4 z5RrDy**jL?07$3#Tn}+I|%W|@Y zD6y?_&2mS*(PAnfD(E8DoHNUu`nL-@HUhaYMNPzKR#Er73NenY`sm3srf8!9chux8(D0hHYRUZnTs<%}bn2MAPe+ zd3#>`7SR|#%PL;w&ZLF&<&I+9BQvNalU@CVzs{h&G@v`71Wnt?u zxB`tDvIKpCF2)1Vu@RUimuLEXyX^mO_XFJaEycJ3vd)=Al&U0 z8t3M=EE7Iaqn;$nCEuUhCDVVesGwW5?keYSrj%EVZPnjbQVXtY`pVB`7i%hVA=>9> z1b)FBL`eq1?p(A=_(VdH0{4-%aXAffa8FZb+5D!FcLC@QR>RyCJ4#2y{K^#3aM%*` zDku4SiQ$&PGsp&n_*cUep}wfaUGBb9_q#*aUXIs!4a>lJP2Vjk&Olo056kveKKL)5 z@z!4`UPGB)SX<_R`d$(GYvu=Pg=uJ>_lb7Khvn&ek^L)KZ~f-e_v87m2G^A!D-NQs z_O)D7l@`RJ8{ST8eU;o}QLB#pMe8=wQqS{4FqcXagoMQZ%eubi0p{kuzw76gtfe{o z&DEprZ?3+y)qZqk7S2X-RTXF6<_&4RamXR+4QZzJeWX^uJ-yl~_mz|qJfFC;)oSPB zsVMcs?sqca%(zv?K6D|1{7edcsIpD|K%-f8>@#f|S@XZyD?bwhGw&bs#w{LwVRWtB z1x@9>d3py=g(%JOFi-E)_~-ki8vpci|9iDWR=s+c0*U00gSj@mc`0zFvCzjxTnZF% z^sF=9&cu6nnm&wHTG9$_f1-^~&1Xg^9g(_r+n$Y`@zo_w8l^X;8J(=EG`p`cmC$Hu zRr&A(x!rCo>&4^BR!vI}hpA7{ZMJFl{RbA8@(^){=HGv8#H}9v*^BjZmktnGx%rnY zt7g~E?fiOV9$?;>2+S$8_J=uZBC1blo7<(4!~^5RT+!o!r+l#_R$H)|e7hY9%^mpE zYPzrU$?l}`hx)p=&RX0E^R{!*OM!!JB%iS?m=;OZ8X{|<7D<`b_YpbhS&r1ZmKa-1 z1(u+XSVPniE8e?`jldJ8c%l_gF{^z3C99`!3L4W5PiC74Jl~GDk1(Z81cr`sF+9`O zB-)hJ6lIx8N2IP@^xZ@!W1-Xu?E5wiN_39DqbrcUUO~I|kwhnLK_mhtk#DB5O_NSK zDeiM;v$C*c;4KKVltk-4SYPckn8tp^*NO7OPB@xGcBmt@LTwF%TeRPs&eGq+i{tQ9 zi^=oNL9f;>n)jxY(cSmksHmX3V#nNEt>-ja3h_9BzaD>QUaq#NbUSnK?b%QNG}68H zn_8Rk{s-RqK;PRcpMQgH$ukR_1uu!;*Dd!hak@jGocVY>-n+xQcBwxV zcYZq=r607*eHCyP_Lq@GwXXxT3n29Fiucp%S2T-qSe7tFF{Lps?6GiCxR2s5+AsCp zG9@>ABH;FJqm+5{}wh)>HF)*nhA5%iG7H8}Bqu&@>U6wZk;zzx5#t z{Lp@2y~D7Tn}37u>1p%nc7^0%gNaL>w6Eoy6y~gnz_N5@Y&8{3#L_zRY41)-__FyQ zc=H)c=M4U~0!#UATN?#G@4O-t7dQ#dP&+a60 zGtFyDbG;om{tL@h^S9?xIo4TftUD%nuS983;W-dI1 z#^3az8*aDHBP+C$ikPC9(#p-hLHFoG^E~TY{1>8QBO2_QN7no$3`EC77_}CAhSa~{xwq`pw z=8kXJKdbZWDNgIZrPkt#Ag&Zfw=KdUW4bBLDQ#K1f4I-R{`Puap3f^?$Qd$2+SdZB z3ppE(nDqaKU@ny;2tV#Y7kid*_s{XXM)LP&_r2kid|qm^&(EpbzXW|*BVUjU(I%pZ zGly&vODj{d)*GSY??_9ObzP9oAVh0@uvbG3u}`EiHQAu=be-IT)EIKeN^@+N4k$>z zK(xn+g+MN)F%C=zjkP{3eK;aV6kApC$LGj}XanK(wc6`j_rF2sQzfM_MN9;8b!hm8 zGyIh7%EU(CC<9X$bMuNY=nj-xz{K2)orBowqb8`K8AhkdG|C8x?ud?!*m?aM&bbwl z2$V!VGGAwAd0EodA&i2alw$kmf@!sc`Nv8ChgZm#vD_`vN~q9vA<8g z)oV-e6oS&M$<9`Bw3ZQN3`gF9XOgA;$S^Q$Ung!EhK-&{o~9!aD9J=LKQ@YN6Un$; z$`_Y2_VhS9q%!~$!S}ZP{Q~U#6Ti>9Fo|5Y$th)c7B)R35+;2l}A<1=S%skF^Ns=V5gybhlN^(z(IT}Ng%NQc}+(IQu5`*(_ zG$bTRk|aqI$*Yo({(C)Z);!CnDdy*&uD473>5!~c@c{#n}Z1CIdkCdz`3U(;`TOV1h2w+KnV z`<&?4!qjo5)-1g`$h8yo#a(z!`&ueeGo{Dr%REi{EUe7v~!`@t*TmgR>) zE}N#)CIY#z z-eG+-5l8=?sn_v2CKyA19q2s?(f$bhf_LIjl4Vs%d}ZsA=lRYkmXy#GZ=A_k7J3Si z7Mb#0XhMkB5TfxHv^(FHpuJ!4YVE$g@hWFA(@lFPsP3C&71zTAZRDz&9|H^WX$mn)IPZ8aG_k!^8 zhvw#nf6%F*^Tzw#?UbRfBxD3isIrf-m6X1_gj)AF#|rhaH2yc}WQOOdHItZY)y{d^ zCm_>{=goELd2{EQUTz%va%?&8{UR5lO&#u67&ds`D*lA&_hQp}FdM>1YW2(znDs)%yyMGBJJru|mY@ z#CdA}U(sfeC=0nxJ~dCDJdK4wt|2M&RCb{#>9EhNXs%!A<{E{yXk(S;dZ&TI&2-fM z?`z>M8^75`q3E;Z!qYRt#Tr<-XPtZgTFLoGb+8MS%F0Fy$691sJ9>c<|30KcXlgmp zBNTm;U(m+*Vmi7_Yo=4qy^m#W_+~>WUzNf36NMXPV;$Tutb{%h&H00|75Vg6zd8BQ zY_;Y-K3Rc#I!kYPUG;iM#-iw3MDFtuUVH!$E#Z;9LD83IyYDm*SJt4}?mIR_qdxk7 z`P~pJK>H&~>E26TGDD9*=qX6#)8C+jTv*On{w4yypv7!|bym1;uY=o=n`l}W^?chQ z*SceK^u614F%ZaQwz~%ptPIUKI>epbMJsbE11x`xS3=d|#c(#@jWl|clS8lp`d_+`TfQ^SW|{EsWwUk7p_ z`tA3phU-mwAxf+oR(?fYdy8`vbK1foV{~+1I$s(X}ueE?n!%0wKH5Ex$DtveTDK) z4wOB@H0M(+1abMVYE~7qqu`NK|X@<^4?c zeS$tG!F_m-A6uxZ>CyH%$Gn!HJsU;NGcO-(p++S~Lp1%ag?>kkzaW?YY}=U20@@#e zamN^Fs(6}qJ{6bCbA(&%m_UFHa z#@Mq?$GbHOWeH8OGHb0L0=b0dSe-s?C#Q}a<5UPgk{HeZ6>*^-X-4tCLFYjAW#Qc; z+q>;j{Q4!@A3#T!9`VTH@Zt)OL}{-Ofn0O$UK}2DIu-)CR`+^C`)_|Nh7RPKIcJfM z6?=j6PX}_1y1p>nFEN^rcuWU!<)6J!d)6HiBUZ>2KYU?$X1S3u5XhDO>_Y8xIVA=H zxn`#=)VKzkl;iBzY zk}PS@kw*DMYu|>AUe)V++_v|)^HtS2=9mGw))#zL4O_K0oObQzcg^0w(?oZXJ+AXx zGW05Ev~kI~TdpUayM<2`WBtH7AvEXJnQu~@Gwt%bwYKSZx2RJkyxhf{Ci;Iv5SK_| z1g4|Mof)d>qR6;onmm5WUF<{!0G_ox{skhKD3g+(!S0`G#ovCcmJAIuLCldeuyI zX4hKjj$V%~Z>5^{W?!t8ziFj+8oqYhE1@|Zu@J;1k{D4)-@7ZM@7=wf$2*Uw}92uy&aUJS~7|6LIygQBL(S<6LV$P`#DP>BHl8 zi95*@n$r;rL0lq<5#8=MkUV(JGtQ15`zK(^2Y->J_dI!h4|74UjPHNOxheR$8>?$| zZIylhA-4=fF2zb{))5OqTq21P_cu&+Mm@aDE%SMEcdJ7$@;$govv=#$5GGCf@E3H} z-1?5LQJviWZg%Up!YMYtURPlIo8it==j8Q+f(knMyR8Wye6WxEWzL$_dMv_owFMJa zg#Y+wZZ6{4H&dPS_dV+F39s4JO2>r#MxpfyEwbgW*{fkWqs0g}{nck6o6b;uOYjK+ z>=XSFh0D%RarZG9J5^-%U`(ZZ&1Gs8_#@CV z(8f$F@w8~4nP73qg=mkTVzsK>3cWTNnGQ^!Wv%+pa$5d=3>lS9}n|fT&v79XHmOw{aJyOBh@>U=lTLWw*@EtTy1EnMM#JX^g zeyMI5T>tWY?KyI+ORu?gmG-{JJ|PW+jdL}epOwBlNw-8Y^J=*1Vf*>dxF5t=VN8TZ z`9ueuBgIx~&%E>9nDiU7QrGPTF1<8;t$rnHm`fun=*SBm6e_n8h>p9Esn<@T?SCKw zxnxz1(D>8$okyN62I z&p5Pkp?*ax-PMP)B@=;P+KpVM%YbVw#@2j=PueB2aG^=2Z<*Wi>yy-|acn>1XC-O> ziA?*}4;#No?IL~oze-YVa?fQM>9Fw&>O)DUZ$tEFC^X5$HrUYNptYl2p;ej)v^cEi zSpQALsS?}5XKL~5urvyzlSss|tH?G@2O{f-J?1rAzeUA4ig`8Xysf6bF16Ov614aK zm8?1~jYOa%k6+hXyW!)>dX5u`KuIQ|SMg*$uVn<54aU$Pfn2EDKsd%L5xK8N5QofU zn0v}S$<9wb>$xp;zr^WzXY;Q;GhGeY%I$}QNA_w5_l3f1?rWHv@7~;Cef!PYdvB2_ z<$<@e@Ya^}q7gcb^X-Vh`(H-NZOk8(JEn9ipZbAc5P|wk9cVwO4<%Vv>v^@D<-L`W zyOct#ACkK$pJ-YI@p2S%dc!a$yl-!`T>wNN*RDNIcy{Z=$nr3BAXmFfPPlT9$gR&_t=;&Y z6V6wU=bx4};+LP28;|Yh)(?!Y(3JA_C#L8*oYW6L1ab+DF(O)@*-o6HUo7KOtXP7+ zV+K28du=uOIkpnC473=%7M4@iex?rJYDDcqQ#uY#ouYUCBprST2z|kPqyLwmo;$xcUB79;b8p(9n;i^gVOH$A&^UGOaswD`{EZ(^lO_3-F=Sr?VG4Z1-X|Ay-`>DB48hT zhzi;(D%Mp6&qZ<}TCYqD9k+GRcpfvLcA+U9G6EplAAwv#V~mInI@o65$P(KaL`EMrEX~Obhu+{Kn@&B4UCu^wLdbj^}SMwU}=P!p68a{jQ0AhgH&13;eKnn4s=!y)T$rl;rVK zjl$Cch(>)T0>9w-0+eJRjCBUIaI}0Afh`%XIrt-_#UVMk>WGpo%Q=2aXu>|e2f5?V zRMoIDpH-SUCsn@=%k7GUv$dBUT-X`?=%a26H}A-9HTfLx6$nlG+FjbMPcCyf{lEP5 z8@XVwXvc|9Ob<^gvm?@W9kdMO!g?d}=`X$8=bhz38#C?d*5O&9J;V4FS4`ogGC_S7 z+)O#kPrtjdv+^r`DOOVJ=E=6*x;>A~1v6HaznQ7uP;MBI7rT0Qs z=catbTX9aY)OJpn!4D*2E{Z;qd7<{Fy3LPP2aj%=9iH?2dl7PV`x0KYb8W5;FJ9W$ zJ;cP|GGc`YY-O;{WBNRPDi73wT!_{upY!*$2TjiJwmv1g$E%Z19d>)QIp-79jt{?f zX$fcfh!8INf?N_F*-|$zv?yFY|0`~u;2J5es+#Gj*=~`JMMeYyC7FowA1?}L1g1nF zP?CwLIQR|y62-I#1WGay>)S8Zd%{@}2$WsD@GQ>BH>;|pxvv*mR$%AD&bH0GVGovf@E=zTF*(HW1Fbg;VwT>XK{i)EmfszG4lg=~q$t zg9wylA`Z2fp(b_Xxmx=Z)Ah+!rkCF{L!awq+C&_?bB6X>7KzBrH$%UQ84ZE5PzOq~ ztojqjIj3eH-MVsT-)z~*C(U&i?AGu8@<z(8qh3hw2 zkN%t7D)hM1z2xcajvqBq2Y%pFFNF^`QFXuN-e=E}CMx|b(-bRO(dM7zF3BQ#PaLo4 zNH}vX-ICvF`BM)U)&9hpfSmr@v*edR#!cLZOE||$2N03e?#|!2VDDEGii}NX@AhAS65oBUkv#y(ykojnp$>|uEA`T{s`piqIX7f zs(l?p2Xb|JabbARl5b)mfa^%7g?g8r*QxzugdT;%fCarnuF`h919oommMg?aC8p>ESkF5jM|O3dMNmS`otif5^lr{qqY zhY31}xL7Jn_3s~v_V~#XOWmBM>^~!|5A_)c8?7B%q6X{AsscC3Z9oqbw6RVg+8=Rr zbeuYpyGw$t8jcJ+4ODBfU&2@++C+@Fy?WlR4054v6H)r^8mjQ&$Z-iuG7)%s7dZx2}>9x?M;3*MCl*8LLt|-qdG&cDXS@ zE<~G%G;6Wmar@InU@l<3=~b>H ztlLPfq5q%xezVf^jF`bhjA1lP?CYL(fTTX6Q@pJ zWqTcxR6`x>$98NYCY6a(10LotYcEz)=f^Vbi*WFiR;kwS>(g7K-Lh%)$;xo^3|`T* ztR{z2?45t+ck3Y5yS3}atLv|^4hiQN{j-0pGvSW7(2PG?$iee>?UQKbnf5dV}WT+iuB{ZjF&Z^dSt1>n14&S%W z-rixnUZ>_VFRI4py-a%iq4C-WzO*I1-|y}0%edy|6~;I9m+5+@XQqR7c>n4?>XB7? z&x=<~oAzE6-u~%Iw>++$S*K^n(e|BpG+h;5J9Mebg=l{S)q|wMoLB3PP1iTQ`8;)6 ztLZBK56*RccXn%ayT!t`_Ql_W@oR^USDkZPPRs=}vZkcmF;!RmznyW50{P5Tn7&xscB!x*V;b!)OVXQ#*bHv zEAVIutpu&dh?Rq_44x6iR>?#l7g|1^JT(zm50DGdCIZihVhpi;G!gg(Pf(&H{SNP( zMEl=HciWHj4JTlpT%I{z)o#RPfO%-@kamzlv3_8^F%ULd36^u#pc=Z)M>+dp;}=A% z|D>8;I}t1Kv;++Va;0sn=6a@JE17j>ptC+@kK69{D&Aa;xSd;^>s_0x!%y-`s)REF zb(Frlva0wSx34%_uH2)NN=c0pD`&$8EA_rp*liQn?Odhb82Q+x?LSwBk9@Yxr9BPQ zJL4BbT&lB5BSN`|QL~cmrDyMQ`>1-8{u|i1h+8tDNoMGc3xTG+qqT6w-py2{&vPwG zKbxt~8VW6t`C1>7+lmhI8Mo2jDt%ra;BKTa#* z{M+sk=iQeEY{nf0+-X=>`BPP+qP*4k=7qk=4=o+-mIvPG?mYF6z~EBfx-0Y&9${CG z^vOi!Kz*wO^MC;Shds`>(X*b4AxmXFM zP-v1VRuYkNx7Ln+i+7H(&YQK?ix<~(K_C~=67KPnC8A~QX!?QP1>%(nvqpXR=3k*N zi}!J@t*jVj{XttDts|SYf7r)`+0q5&3z@c{}QHq6))lXnvKw5BUiZBRATp6Uo8G=;zYeo()WDL4HAJ`Gu*fa*s$ZL<=|l)i>OJnyQZH zzFs!$=c%gSm`Hsl0=ZD1zmAT7O;a`7NqoID`KLpDKP_*s2+{tCja8N03Mz zD9J=%USa;AJ_BL*Em2Z6T*&nR>jc&nLYO;0xZ6WCahN+l_Npn#gKLd+``V)UnyUO? z@z_vkvT)pKA_?--H`bxY!9E=OF5#xXL0evKCAru}VQXh1@XN+3CAHru&IOcYo`;Ke z299J(ILas5JlT)^EB3pV^;PH2&aHb(yRB&BVL_D{=DmWAy@M+8IFBDBoLdIJvub!g zP3Xv2HMlrU zw3kI*tshgmq&l3tW2)a*+R)QJ*!N~PxUYh{ z93}$CI~YSVR%>49VLK_exUHzHE|VOz3<+nux@+(_=hwPFxE=&#ltEvFCbT)q_&YJl zKKx95w+>3ZYr6H#cZ4SNyt(7lhTpk9k_7pgGcU{^%s1htzd@&a{M+Fz!~c%35`V;s zqwnaOzI=uS%NZq^I!-?Iu09w1-v}Kj$wXX!z4=wN-_~xb1+sY$wZ)Cb(pe3 z?|JNuurU*XT)6*b<`wQ>$xao?!MUu2vz1KG8fmY8Kf`UW&W}AGxLAsJNs2Z2EwFs8 zNpp;1A&5&PF~a*dXk)7{FDFqQTl z=|JtM!+iVf#n;9;zddu<^%TnE#a%U5`KPYDch)cp5 zfoE$iI8Q4y#T!?qEbEOQp0TIY``j%-DK+8}n&j3v`kP*vlG%hG0=a}HN&2^LPSWf5 z^DPMJ=}E58#Ik5`Q+4hDkL~;s$R#wVoMiAXXydszEPw2Sg`55cZL|jLkr8cK3n%D3 zz2sud_l6ENrk|i|9s@~3ScBRfm+%^%{ScN8pzTC)#Xv>=0ySp>si-K<3 ze(kN@O6r}^gvRnW5%Pt!3&M>_uJyQR_=nc7GWDrqUPGku&fAT6H}u4nX=+Q8Xe+|_ z1rY_$(cFi}U7$j@?KmPsE^~bK+6$wcXC6p%%R_1o$q|~;vHjJc>NbsQnq@^pAQ#dY zBcg-OpxT+L^VGTicpL6cT({9U9bE00$SjZ)XGYAQ1gqIRt1CaP)E*$8a zsmH<5tOR!yY8=(O3srbWz(hPCT&=eCqrRl|qN^oggN57Bpm)l$7 zExOEnj=nd+BLmB-aCL~Yu;NNrhwS!{oWskS>-i_gN@z}bECg|hBt}$TI>K2HKRtA% z=iCI0JDw^v)A7^I=U93FYEL>F9DQ$sPdA|@mR{q8)3#m7wNTuHMEjAwDUvARo^+;yPD*Nq zI#P_=KD<+lGd)C+S1a$0ZG3whOV+Z`yO^YwvImzs8z@nZS2@R&J{zc9fyW|UPyP~UQ$)rkUP|Eg^_FgAk>gIgz3KW)yDGB!nWlxZ#V0lP5TM6E2z#l)S`y$?GJ6i40kw=SP^rAja7(fa9$cw}(c(CZ9JG7(rF zSOzFbuZ1_d$NA@@cz0HWHAh-!k}2WbQX@jR=nHa5IL3%*nsa)u$m1FSzKgE!RQ{Ig zBvj9CIcxj_30A95)s=DFtKrE*|I>!Luh;$4b;a6UI=8>Nb%zx!w1dMdmK^7FYIfCK zXAm8fj@}8M>T|Zay(Q?@{SP37%asLO63%&*|6En)mecpSxht&i4d=JS4RwQE1jN;m}h~pVSkfYF|`OC@}s^pyp1V z{o-vNJUi^giL3;z0pAVBH$V_wiMogoj_xMjhC;qdt0Jb)Yl-z`oGo+kecC zmJW(Lo=(y)xMbIHrc7*Qmf4ZOkX4gEr;~=9RA}Xnmd`pXyVSXA@Wl%~c#v6Yci#oA;D* zEE`07{5n<+-gm@XbX{J|3{RR}Ah$kx7JKRE`oWd{>=DOZ=ZcA!bPOv}kYaaNia0qGD|I$(pTw zoC@{-OnzyXW1MHe77okb|NO3;<)t{_2{Ft1_l_dTvlge==fB>SJ+FMKKEKOnprj9{ z(p=v@ReRWwb%yDGr#dHAJ=M}p5437VTbmnBXqPYdpP+*;rsK=srSG1leRY(MlDqgO zC!+lk_+BQyqluEtSfRzCEukdK`r^~KLv}ZQ&kIl8%GpXvt%P&Rub-)IZ+x+>+cL-* zQTlRz?8U%<+%qK-&M`uSaM2g!lJJP!wdI8@`XX;%5$*qaHge$`W&Q}{!guui5y*va zT>2yM1#9F&v{8aKe!+J?QId&3E_|ohAAwx>rn5gn-tnd~KrTd^v7#@Pzu}Xagg#!J zcYb^kx-FTc{m`>tXPgzKwaQTKF0mIlLx+R&DC9!4iNN_Q&VKz7y^6o84%guJRXmuH zOn(Hn$l_6s5GaZMpMJEuVp$!V?^1HYuxP$d^Jobt9_>Tu=X`d`zdc<9)|@TFe-E^~ z#A7%Kk3=Q`N6NLi=h`<+}Ja(bA)NNYf7p&3CT{0lnyo_#NT`H@PwZJ*bF zPLec;O9j8koj$SKx8olx?%tU+$9Fur&? zTEaOlh!8INf?N{L>4+QG&G{r$#Cde}t^^zv;W=#_Md{tJ=@*jMUj0A2_TokH7~k^w zQ}qcL?qwEyJWl%@kRFHQM*o_ACeTv^_NX2|A<&l4#&9%XBG6vZ`cRUI!1{p}j*=`3 zZx7(UlhBl-*v?zlD@~rFUjvf& zqqxlN9s{<%IGb+?iFT4AG;v`JJq=VIG|yb$zD$I*AN%nm?OFBq+;9(LBFsAB)fLOy zz9+@`pmc%ID;c#DDmFYD7+qK9gJ%N^+AwW5JsYUlFv=>Yjj5G4ktiMVavrA~V|8#} zc6eaRViD<>R3;Qo{pFS@=|F^;T1*G(z_mA1E+Q^Y9PHFyx87ZE$5nZ`JwS1n@Q8HS zm_jUP%n^wbx4Tl#6pC%FgtLzIA3f(R`)@cjdHu_qaQ=#AgDcRYgZ>5$+r^lmg_;O_ zw+$`a7h$x!^Nze7-dLqdL}?m2930&vg3c73Ss~~>UypOz)(g9<2550~-bXA^XwEC# zW5JTbeH97E7!j>kofn0|WgpA$#{EVs5nt|z(kt7rLXT*)H~q@YnCG3A)w4r~Ub#6N zOAXWHUxHW$ST^z!QKTgz7rq(hkHGmWav|DCxiJ$)^oL&D=e%0;R4=x=?+vzy$}6EM9Dii>$+|xLn_DOP>(v%nZ@sR= z=s)vE2>fzgd0Z&TL%BLMm*_w)L^oO4*e=|sncblI$ZRY%^kC&%g0}3KP-^8|AJOLB zrAg2Bvy0U~o&3S2-3eQo=$rQ)a__N#Coa_OrGl(P*A~bhT${!m@i5v4cAIo{=rY#{ ztRGl!Jbu!#Yx2ar86?&sM4O0fb<^Dwe-w&bsM|y!7p@`W+OmPLM$PFi=7ZSb+|+V7r~K;!W1QPMws3XeYiS*(v{GCC;J!q{xja~lzYhNHT7Is+ zziuAtwECGn0^zx7DQ7|m%{r#6NwME~sZjE5DOMt;Q0|Np7v_j%4QuzXy==(s_8kew zv$0RbeklX|9hfIMB!v_cp-Bh!CG?HzH~*sb`t-#d0pv34KS5{r$TW3&4A=9e>96Y> zXS`;eel|_T59e@wCUrtj`|l5Lwlj~;N%wpPcKve^6_Cbg!B==@M@? z6nE@JC7k1f2;o9B(Gt$(!8rPtUM5Xb*S}9;_nKmR&4 z)xI}>&^@t*^RLO`pCw`~INz{Ke;%aPJ29bSw)`6-PTsO!_698hX_F2dC*W-^k6)+5 zmh%@kB2bcvSX|-gwmzp0xn;iL_glC3IrXJWcWi!STc4*E<=TRW(YrExCaCOMN`=6mf% z%~krd+jGkR>xYL4+L7d*WKE z65Y4DT!^L?LRtySLLa7>?|9Bm!o3tQ6|^z0Fn`R`(H_n$4}3`i(fT|D+zppAkR(ND zvJAWn;%Ok;9gsCaEtnA5GGHw*5m+bi)B{?+iD>h{1YPElI#9QXzf4*T@8|^ug*Fx*v+D3)m;I#z_XT+7bcFx|4 z)$Psa{!GAr5c@tkxla1@9?R5J=bj2f-QHQ&tSDS)l56&Zh>$Ngav|EX8dof67x<#P zn-2XZxIPKU>5#aSKA|}sRQD*9ehrTu3+&RbI3i746fS#gE@SRL+IFFSV4X1QBi@$9 zRt9g#O1Q*I^w249_Ju@WaE^m>AK|9IK^wX7+`Ks}@-RVrX-*5Z_Ff)kAQz%Ne$s)h z52oB~yF5(Ln5$unis!vNe$s*SW#mG%fpBnMb0v9V-bqmZSrN{okgGr-U3;^Vk)@}_ zoC)LH33Xf6JC43Rko>7@?J|QPDL8|W@W?X9-T-rV%xm}P`|dk(TWyGywqdVD#_^m# z0#_z5M{z7c8oc^J@$Iyft%PXt59ptcw};%WYRu<8>dcBV+B4oqZrrzbD63zV<8aHm z+T%>9*D*eMR{5JG{VE05+PHg?^m`oK`bc$P!i ztnf^U(4<37@4oBV?!tSU<3K2{-)>I;nR})OY83rif@0QS6z=noDTZRKW&l0D(d({JT57pzlxgl71N!5sG{#pZHZDpkgI9$ z%KBs`kCyxq$Tju#O6o**ECk9z#DS$1)ryKvlvp7Gxh9pVpmt4;hQOM${DlhIgTO{t zu0JAsx2+l;<64V3dg7(lYDkjgF5Z@vQ4!@k-tCicE`^aL9&{3H_8}xX=!@teTEe*m z(R(YNfIx3%5>9A+<2}ZmIO35}2J1l2s7WzpjBc!Zz2vatD-%UMy3PPXCg+QsiKC};Ta3Y1SOe>Mg^#RILk+baM2g!l5n=f!)=E;i38qrtzFhz2_g21^#6t+E|J6tlEFV3J9y&~ zoO3R}kfa)1oEpV;u}+{||N0>-UZfql5G^sHzd;+nNXiL~l1zlGp54fWXcK{7u>C+u z211`hPS<{Rx#w(CWTHC1h3U9^($$(>vi2Hl^_*m4`m`^kuFu=s&t*rfCt?pEvEmrv zDOMR9P|D?e8gWTDBfNjVv8tF2bQ^{19OBB-Y8to z6i|)|O&qe9O5v7O_V}jczg~IX9bbu$PvVl+0O|h?L0lq<5hR0uLH#Du-D-Q`_uTqC zAgh6zcZBI)#T%;qquz~D&ylO)vHJR*hP5u2KLWWf)UL1nSHwaf*PQ3;=^gj?W9UGx zjf?KmJwq%6at+*4S7m3!LLgVh_B!g=@>mGu+PJ8;%6c~z0=ar8)KVK4#X=z0i7Peq zj!7&8aut5Gnwm5_R(UkfXsC*Y-;O8)M2k()U%e{>avQ9xtY0`{yFy8(r(3j9yvHEn zD4%ucvpBSx878a7IcI_KEwvwE_D+f@JtBjc3`sf|IFYc}O_LcbYoAl|52wOrMLlnKF+)%AJIwvnyg7!yX+!5`Mz%>g=2c-$;>SnBD4tOJ% zloOYrhY8yFMXZvzP?E<_d4&j!iR5mi4*Y@$Op~btts1#7M@$5cYcs=1YR{74xt8IL zgAIh!x5Py4dxgggYi>|h|TDQQONz?Ocu(UC;*F{OD87ohVzV%xyU3;NrTSB`!P(59( zFBr|P&=Qdg(Vhls+mQ>=I1V-uG_I8~E^;B-vT7CoC~5WIyvm6=iZ>30X8E|DC{|4+ zB{XqKoo7U^N4q&auM~0U|0*`j(c=e!si#k)if8kK8)0>%Yo^g}i>mlB|_cTzg zMXmu^Hw9+zh=n-%cYl3Tnfnja=dT0h78qYXu)7(f{SnBOuU_N8#wv5&H2EWt>*5y= z2GWA_Vj!@ULAl-Iy9F8*obRH|yuvRP8@?Mz`FEMig_2AJws6RWXcK{PM|~JW6OlS_ zuN`-Ra}>+Q7vbz~mZna9&h~@np&C|B(<6f@cIBj5X{!6C{ox9==C3aH>&vR?qW$6f zCcIJK-x8PR#H(RHbDbDmDnT_W$fL~VFX%G}8#&xe2XZZFlc4>2$3h@i`3VX79rsuW z=7td{zO8K-b@g_bJ)9j5(Lj$8%nw^Rk6i-kb0F{N8- zKMJuB9g4=Qpg$mggqzrmUu?T(BX`EZGvj^0JowCewdKgiuUh6K`m{|;U=PJc!H{$`vnmw$wZ(m zY^kvY*M7F|NwL4|TGm}v8$6?_UajNZKJ*DF;oKXX{k)EI|FBj@k3*U}bj;B;mB%~M z<3#iycK7%yfus(vxNQ{9IVL|>Bhck?MwAxrOs#r>14}2nUyuvYM#}BeSDOZUJ;bZB zi1tSmKHNHR;q)|D2crEEgG*Hmgr4Ae6mntu%vhxktPr5HWRxd}KuIP7$5a?YM4Jfw zf-@wPr2RKPI@HLG-_Wi53k@n8Fg;eUmwJz<8aB$9na}jf#_};1! zcJuP6kWbbKP5RC~JXXy)FP`hYc+rcu-mDtSzwD8Q`V;}D&*P`Dj}=VR zZ-#Mu-fwfFe$^_neKZl+10dSJZAZD-Gx#F3x3>wZUm}+j<`0g}jaV7uc5JUul7TSF z981lde|ngpUG|8#`+{6Jd$p{UY1N!IgYI+QdHlVE($DAUv-zB(*hXR7Qk^2nTxu%u&o~1EJ4GHB>uB z-0#*Rv_7;-eeeIS!Sd7-iD;DdbTPAr_#B;iCGIvlLeSkF%p+S7G31omXu!}+G%L0PB+CFwiWKR@hrd$pAFQ18dH&yUS<$A*-;0^@VE zX9}*5Xgx;C?MAQMtK#md=jLwJ^l7SFu{Yh;Cu4gvHR$v`xoww+2|DC6vC%8sTcZEO zqnln;rSJaAJ-z4glO>|8nR8yx)3LZhf|~Pu^GF@`+KctIAK{wrmn)$u8u6AJD~zqF zqv5fpQLX)C&1ULSt{t0*VFOyK@w1X$9bK2r(Qjz1aCMliwucEir%FuLt3&MZ08@^y zN_hM_upmc;2Zi49x?qWuv`uU67t>+W(9i1tUE`n-XD zW1Z~^xiEcZtX5ZQsOws5R|g_cl8I>gT{Hc*Q)KJBC$72PV~f_d=c{)|;Ck2cu8xae z>vu|=-*|uzS=Pm$c6caX)<--7mNu?G7*?p*fXFc1L160 zKS8f8@ffFf_5?Lhzqd^sp{f)0&7LUM?kvrjp!#-Zt`P?(==L!d0_7sw)ba6$qnsg0 zboe5i!jDc=yNYnBVfmZoalPF9t$SOu zkCf08IeNs(Gsuq3bM$^Br$fRc#te3ke$Dd6Q5Zw4n`Sx?f&CJqO+=TCO?5grCODT( z+g4lU^x@HcN*G_%+a?2 z*>N#@N(|bi7|Kj$;v@0*S=uVu+h5fES0((p3LbRuW zMj6As3vveeDg;j7oB33*LM98$VD`rd)Lo; z)He{uZa9trPhY)LzwXJlU?MJlQB&W@_`j*avglIeqhe?U&lZoSJU421WvqkI0gc_u7BAiQ1-}IF%ZbL|F8H! zy-BeU$fZvRYWcAc$knNDhrpF_vC@HD*rJ+cjx8s)sQw6S`>^FiN%{`&)>Nm{!!6vK zFC%8==o@Zq?WgpJqrp1v;e?KC6FYviOlfOyM@XfgU#RB^O#55=>>aiAekSWc3rEY> zr^K7bhYIZ)Q+1ceA~6S9e-A96~F#nm;TQy3u`v3mt%8Y-Bm8tUS4Un zo4fdGkI=*=@7OTnX6@~L^45v&jyslzjP!_0Xp9lj`b0{jy82w#*J0wqH2J3kzsOoR zp-~d*3EJxx)>Ts<<$Kh~g=iCjU+|tNN-`1n1rg?*Q4d4wqILD{iZ9)iBNyH{wX6oq zhS@JRT$oJAGb=`_0s-!e2G1C&+ULHoD&brnZ;t*XN#*d~wyaqZ8b?kN&WJC2-EF`A zLZUm$I68WyUX5TK2Ok`%j{eR5mnEDLB#(dkc7NaQfdb<Rt=<3&h;_EuJ@5_5J*i+e%z`dVxBTz~Pp4#}}jQ*-r-D^4R~; zPWS#0$=S1Hr=H_--Ij39tE>0*bdHwG@1(5D-dy&`*MU{-nCs-5Uk47?;Fcl9J`$)s zBwEW*Z&Ii2iv}OjBZ~5^x}L1LUC)YKn$8o_T5M!_;LGSlA05qd`}eP)+cEYmV5Yq0 z#07c|$SK6sVlL=YW$^Y{3oXMRfp&$6Zqq90wMA~nOdVrN|FSJ({4qCoF{ha;itRd0 zd(T&6zT`TQUT?n2cb4gXi3`+)r=yKUQ3uL3>xvgI_r^$G&57iaSlv0f{nn@7=ee`& z0+1AGIg#e_h=m|7krY{iY1^u)(Qk2op1r%8YF>zIZPuU~dEs6vs7>tiR1H0Mj^skL zKLWYhpQr}2?cxvC4h_g0>)Niwm*fadamSq$QwRF`nBBVK_Ql^Ebj#n@?}y#L{|2XE z{jXfEtY5os>o$#Pe*|*nR9n6+et0ZIkAClLFZS-rh?rmu&2$`pXyf*!IXhhhqWuvV z6ZC%Kn+|8pmrM1Y7xywH$}P)#FLT69nvqG52W?}hp;OO{LXTItVGpMkJ1b}1L0owm-+pDr!Rh9}o& z`q=Wa;RX3Oy5S}Qa}n(rbKOK>8Q@tglw=~-RgP0z)^k1I^+22&e~9V+{j00}e=%($ z@Jp}E>UsX1P?Cv2uBAEE^(_dN>yJRL-IuDXjP0=y=dV>$ef4QvvJ#YwXfsy$WoEe= zs?VE@MoA_Dxw^;K&?_Oa5ZLyiTtu5Xu-!$pKcam8>e|l$#{|m;OViXbdk5{~Fapv3 zh)J{KRO8y-=Uhi>&j)O@D(9a&`Mmk1T4(LLe88ma$zib>P?; zxe#q43O~9)4er7*>HOdVRbV{RD9N&_cRH5TD#)|DwL?=>w@zP#dzD%HuFzD1dzPfA zQ=PdzHgwpR>eNtmE9I`Y!>y!H)jL)9zj-`wS&tM=w4eBdN3Z=hr|7ve%MyPwq(kWT z154F2b)=sy7w+=nM{aAGv$%YCR}rTDTT$fN6IUS|dSio&_D3LBpQkE>Pj!xkK(2i6 zR0#L|{KFVJkZaAZ3gJu@3xQnm>nnsy%;6i^{^>xjwL>d}Q~v!RMy!x)Uik{)n=N;%?9Bt5sF`++9Ee;gp@bP<0%|wFt*oxXSI1z)={YO$3f)aP)#` z6M^FoY#9)3BKGZFs=8d}lNHE?XcMt`-a@spHzN@Jzad7SS*j-GzJh^hLx=VnQC*F$ z8`+j%8)G6e!*_*~3d=ak8?hd`+#sCqS*~3sqIRQ7YIQH})#BEc)33R3`JcX8NoD=Y z;RZsVbXuTCNIX+4aMJ>Hpn8lMq=Q@=zniaLRgQ%~uK4xy)w!%#h^6WCRlm*5g>zir zd98zT5$%hxSM98&J@99{?FaVGX0Cg=WzK{VePe$v7`hWzPKhu0b{op64tu#gj(k>G z=bQ8&!cVhQEGb0$Be0%V?omY*em%0z>z1X=1J0DA6`h>-|E~p}3d56N1)tM90v9>g zy*N><@^`x@Z=`Qp-<72dD|^>lglR^dIouKc+IZ2Tm+&~pFVR} zr?gWdr?b;#^s$HNI$FB7OF+I5@tG(3)^#S=S>9aKrU>RENlI9 zNlv>a_3hsO)X8pCFhzU4gg#i(X*+!4gDUFc3Db z8mt{!GJJR$`@$*KphS2;NxtuHAe_dH7pgdY(}F@#2kJHv$hBqoOq~vX*`eTb^WD=h z%su;;%Sm@u7-uKkov}&AhBQvZvEi}hDcVm}w3Oq{80y2=8nLo*WP$T&bIjmjf;P^u z550It_{e9RKmLf*W9|q~Ud{anav|E&K=mBIU>Tq!6M?0MT!=Oi_yt#r&=w4YvEz>W zj+pC~HK^ISFANQ&Ke=4g_ z9x^@pnTqPjG+vpsth-7zati#=!a39AyM(5_j|BSZ88*o|_4$#&<(Z7|-_5|*2V0z+ zYWIdaPo0q4cTven+%C`DsrKaVa|umjMxxt1z`8A~=5yDQcW&x!r##tc)9f87s&Wsu zYRoIlLy=B@gLb=S_v)IO>}oe-as_*X6RCFMf3Lb;nngRs{vjycgv%+YJ-H^$k68h+{StH~Y|NfEh7vyNB@;u1-W=#)0vUR?aC z5Xll>rAQN3+O}i*RVyxk`X;}ieQ@7x`pr^4jd|^@gWF|)aK-!?+sBkXox49c@koj@ zZb*K2HnD5ZSiR!)MYz+Fn^w2mFgEX{USAb;U$Xey*JMRLG+jtVJlvM9iC8PW!BgM4%)Sfumv^Eu$pMs`S=K zXHmfnBX=nm)%Ro7@l5UqC3h*;O&v6Dqc@`nf#W&UZ6a`Nc{+tGwIAFE#EkKOZpOlk$5>#@Q{+ewfw$z80 ztqc!Inz&8spx090*fOA}U-8#pqj5^R|Ru`*)E2QJql< zCtjivJ(r-Id!+c{*+}hU<%5FGpF_Ia{{-ILgrhqg_04XbqAowemQNDoXL_K)`A4^D z<@5@x#7g)nukZ`T9n<9T6Jqs(2I0x)s<~D@epbWq;tI7~dUvxW;TlIX@^V*DLHp8x z72)MCq`O~`3(>+&e}guD8Q8K>xbR`V7l4vX1lA~Qow3z65m@gK?T=V7fA}`3=d)Ws zvQ2KKpd`zBtIW;L?=I>!Y-4+lZ7sIJW?o?{iZfwsQB4Hy z>f^pV>NXKLhC?nyn+UXUY@M;yHWBy*EgvPB2y8RZ`p_y(1b)HM8A>t{bMAguuPx1V z>xao@jN$S?bSAZUcwOZpZoR`^278}8c1;A0S1R#+&J?Ghl6N``g!A@)=jnBI9;M>S z4W4Z<5&7Pk=k66zC?ZgjiP$ncx(<|NA}-aLr&m%VV}+7T#E^gH>a&%MsQc|)RdZq_ z+C-oZL}Qvv1j@|}&sF)m#nORXbJon&@5aYMAXn&(xoY2D?vedtg=lYgkn5t(;OiMT;?kh6X{(S2Sccd>n>lD{(byHOT6+AAH za7GmPH`Teb`O?kpGRh|6m`d7OLgT4W^F*VZ1S45^`cJ|MZCPc0ALMj8vL%%E!$S!; zhQo0it^qQN{|!30et~Nw{_AiZKPsu({~wR^@h$WrNhR}kuniq<33?^vmEh`LRI-P1 zOECNPb)l~LUvOr97ngv(MbI-z*~b#}4qdd>!QNLws(1EwS5||29aXHsr@3kz8a}D5Ztx)ocwSye`f_@Ezrf~Fe;QoK^KS6u{s4aQ- zk1=<%25ru}Eo~xJSK6v}{JAQ^!c9pY#`H6RI!sB3nDcyby`#kSym>}(B`0&S&U^ec z0>Ceb=(oAJihh0<5y(|Oe+eb)W&S#lt7G#LN^TYSBamxARtbG}Fxr|7#tONP?=PX- z`Dh6Ig6$wmGGm3UXx(p1>f4}^Evkt?S%^4&wWMxEqs0ml$dy#Ml%6|BL(mtRnNk}? zXnMPdXcK`am*s>q#Z_oRn0=JrIb^Kcat;|&O*EZhnXA(DjZFGKtf|<7(>M7Aok?HK zb^QTQ2qI9DaMRzQb7{alcUOk~4-qJdzR54>G_CTw+V~xhkZ@hYT+8q~>x}1{g%;WJ zH)zj$Y#p=!UaMBV&HLKV40|lFtjR6fI&Uq$a!TyRzy?h7wMaNP_&_-PgSSZtCmv@_L!}OWxBrl*C8QuXTS&x@$0>_X)l6U4Yh0NXEu0 z-)Wzj9Cryv9lS0h>pO&Kx+tvm@lKqCGot*z$2ep5O zQu?i5jtQ=K6s%uL`-ha=C0_moZA|Uqlk@UM1C83hmzN8|O@D*V(zdg73G(P3t#a1` zv+~Z1n~2Mg%u=iMDMkuKE<~FMv~c7?-3G!=f3{R!j$&#t7fb}Mcp=(E9J^^^=!M%y zxqBd`(vAey7vwUZS?=pV`#3H^36Cs~b{p0xyJ^_%IrsmyMz3=5SnFiX`6@Ha<3}@A z_@&|F^YccQD9N&_wb&QBKVzT~E7E@O!J`4GMG`BPj|kzSFUTd~5$SM3Z|Id&eeaGC zSRRc(n5Az%$>^27@{dMKrS!|KY;jn((L#lr{+cZVB2bcvz>-3_i1tSySHok)^?e*J zO%rjcPH~+={(|~U#Np7cEfdc14TZ$xUjDg`Ky5VKa%i>+_lj%u-;*PG-HKd z&|*-Mi9os7s$u;%5jbu`MCaSqs#0mZBjArfuFEsm>VAd2>i8p&%j|JHOwcw*sa_vt z_Fbbo{I<1<%`G+7RG~?tZ+SQ!rp!{k^@(5N!nO-7pT5a2Xw(lx`y;UCAlK-OX`<)+r{@ARsKZtbq*_I(^K-11kyEvwe(7d=n8txv;+^Yhj(D16rgQ@2N2;`UDq z=$1ON|5(+oh~9UW{zLDEhxBf^+ehJEv(OX+ymzbpK(1BYh#c{l7aCRiW!i_#f=ot7idhh{hVIvaPr<<=I-!d-IufB6NqU$vrol*;w| z;DbfAr@F{o5N`Ub_fmliV`#?8!{`~y4P_u@)997?urhJ;`S#AAi>uhvr|mb+$>8qE z+y5P_&X0|@7T&3EvApv>7?TcDiYaQ9BFlWl%-i(IyvP;~C3&f!lw)3@4wPgfP&*>9 zbu|&#_hJ6b?7l|Dum3V~4Zy}PX~8wBe}7(+K}q@^OS>1Gp{v)rt7>8ylqW({EbwNi zW&M2qYUrM0y`6$pdS;8KTbBz~hj7)%NQZ;<9P4&wc$OOUbd;XcK`zUhqkSwfmoLIT z)M8$qCr7hA_u5Cxigl}z;w`xlX(es;%+2#0iTbdYFjKyG-Yj?LkV0{elQn3TJ5ob# zqV?!?UV%Knz6bXe(7qJ84i>CmNFC`Ur(Qf9d3!75u1!;XkqgltKj~Okxq#YziD#pz z578z9cNB1^!5@KKsLvmPxrjdD(8rn?EBvzNzPVA?XG{c^AnG&G59aS}kNf$$TR-F+ z3CSri{#Za(4TNU-(GXM~!bLPnGGe9s5YNIpel*MV@J1X%w$9R?uA|it{9@J#Pm;e5 zT!%w_CIVOPkPFe4m2g`Sly z>z7HDTYh^j)qbYx?+FK|PRsKFu>6H-c^-jgcGvp}xxN7`>pxFdw(mT7zw=M6Wt%Z3 zvs-@~kUL*yth}@b9jvvO3s{3aeqE!CwG3Q`^+({B^{>3GPxA2j-631w)~~KGZCL|u zZe~Ama}#IP#hr=kKlwJ$UEiH2%@}t~lYXV__NVO4nLoO@i@7K?q0v{hiI6Whav_?& z$&cP%F09`T=C%Y|q7zpN=#!u_S`vP0efI60oVT8UekU6bn+&tP$zMQc*q1f?@!#iIc?j~KbgPU-=)PApBW?~kPH2?nmQH@o|?Bdi5^@15$F{by~UzOT2lx1 zwdesAC7B46g|WieT2`)i7}wgPSs&S}X^DHLbqlG7U$RLX?k@{Xr^<0Z+K83De-OF$;tUESc&-$XI+>!UsNzUclvsCT(nG4@SH+7&a z1thN(eZq(k+!{RO7gVA>*FmeQzbsZd;ce{Ow(gr-U}(3FH?KiVA{}O&rW+a zZ}zjU@>o^7Q5km@Uu@8;c{6ep#wk1XsuAR8v>#i~h1G_PEdI#3ptAnHa`Q2W!LuCfbp zzN6)13@r;!U@yp@sn=dO--RZb{^ilPgznV}Mb4euOfKMaJ`rmU&V*5tkq#TzWN`HZ z`xO&`{StB^+C*RNwOwhqE_|7ESn`M3f&MnTUu0339$5jJdDUfg;mw*nV%+Z$6Vmy9o6HDEEv+8kY zq$OetZX&47kW$0;gVq*T%>OvIW$-X`8rS=hes-+BWq7k&{wT?^>ik{MF5IrW-Kx~c z1j%=ja`B51>f#rCZmHq15o+pT_Gad3pmgBO^v1I$au&<7N>-}j^nR$Vo4dF(hB+$X zk-3Y$IPtc2<4sc=s!=B+DA!Z)@`K!+d%N=QTo8+;KE$S^b-Jaek>#%C$tvAJQx|A@J2E%fi!!a>9_f z@Pwg+V;YDK+L$LRdhS+|T2*bUeoL%^nm5?Lfy=F5>fK5R05Yq!T!-P|2idxzS! zXTO_5p(zHV#_iA^p*Y;KQqHH^m3nR6LSM-GV;K6+In266TQ%ripqY-Dc&c$-|fk z*~Pwoba6| zp;^a&v$lsm|Mxi;f%z^pmEeWigSGbyv0Xm|atX~k#@D}+)cB5Z_A^75#Ya0wL|lt2 zd>5cQSd6Ic1e~Rh)VAsWusz>cWtx6@k!LRw&Is?{pgsD`m4Nnl?^*-)OVP@l&n?M` zCXymFrF?elU-C{si3Uc5do{Z5{Ji$+=%zK=YlO621AZK5?|nGr zt|xXqc`QI@SV)Al&Pa2tVj+l2Brzg4O8QUGzWm5P0qJqDJg}TRenMca#Zp7G9)EW3 zZ0Fxw+HSq0Q9{P{5qbrZ^8`oXqe3Ipf{(dhF?2YiLYeCAAv_j6^V3xAyN}mM8-0?g z??gtM74_SksnoDgeI=C zk3A24tserpgvK-wt>=SZ%*`8lt(y3Wy8b2iCBC_9pM3Ksy@SQAPw6%J^wR)rm8R`p z`Ik>p6&l_nq45VJ)E`NohqHGMZ&3UF5!x#>BZ%HNC0aelGRK%;4E0WT*}~4?%tzf; z6wkfMh>Y?|!a3y?bgTy7y)toT^*IUg>qqEUBDm(<7{lnX8dJKYI((Azr(pe(>heLR zm%nhc-ocKR4s01N)v;9muOeG*EE9b%6}T{`&D>2W|9^JZqrWF_xqWSRG`pgd|MAbU zfSe;WbU3Fz&r}8Lb3HiFH&f3IcqJ9rQA`B3ABZs9l1_6|>=nNhNxrkv>)DvQSk74I z4ISDixi{M?xccY7fCHSnX39NG&_=GVCx6!OKg2>H7q-Esj_Zw5?1$=>O)eb&h>;GA zyV>sEQf08c=^K7cP{t(`)5R6M7lp7&uC=bk0%xRCGTAv|XGgayHT!I}x%2aEI z@~LtxCj(()4>{=cyu5h=w%{fLR|i*3)V^@qGZ>N)%iB2 z6RXb9uOc$7U#WbdkX`@EqxQf;6BBS$gm!G&?wt>evHvryb!b@Y^P5tf5qj6cYd@qd z!5VDD%EoyO&X91_ZzAvu&Z7pG3Pta6^s1Wo#hlCf%|8aVd|$td%4-%=Utg;RZsFOl z*}{483fgTRn433qM=or^EvxNo0lQ^-ZMXl(YOq~Ze2??0clI_F--PF!67ET7N}tke z`){COWgdT?`uuvJ-mOgc%KSHAH)R@8v>X0!V6ndYNLGzph&B=UrEah5fqqvxhA7EI zAXnn?e**jUD@vr#AAww#>iiRslNBaHpQlcy6WED0+PA=QJ6fe_iC(;D9qvk?@i4ya zvj4At1C2l6T@lU+s&fysy<$&|R>^w!-=K45#dMXkII?B9 zd@xJZs3^0d$F^KZdMkdMYrB|2p(!TK3q7Y#w{k96mLCGSgyy*XvuSE_y?;lzI`E6o zq@!<%XZ7u&|Hs&s!0A+e|C>DtKPgMe783K5B$;XEx;JD?w!~PH?Ag~WV_y3flSqO?J&w2LqoQHMH zFv1~_OK6TGw(t3v`rUfFao`uBDULmr2G|#)B#v+hN3LdFp}qI?dv z$ko!1fgrE9`%i`L<&JTN33fm}sT-?BF6wvXWv$knIMEvtKEUU487wg%zj z!1e>V5UuBNJ+mU12+>BgU7$X|2f)Mqo%f0!J1&;=sAOvSO|Epn0ZP z8@GO-Rtrt#vEhaOcIqstAK?(lB{Zi9&-}=V0SXt-%E)_049nNk{CxB^*P1H%+d1}> zM%-HpO=!8fmuSP7-2HL0|7l+F!ajA#JLz`OS9oS2?->#5)UO+R?%D~`jNr7*$p_kYOxF0P$mNL~yS2kz=(xcUz7kB==#TiKEOD4APQdZm{^n#0cvL0l3N zBl0?rLtJ<^hf)03XC`Fauzri?TU2VzxNX(^hPAzG!VN3pDW-KqX3IaV8}0cEqQfIl zt5Lcrbv+Jjk#XdW^+reFsITe=iv#;yVf$JWxmHbkFR-c_9)VmD!^y7&k4tp~p2v}M zHf@3Scef3wm9@&S|X31`j1O}671;N+!x`zX6^I@d&3K%`Y4T4&&1Lb&Ny?QO%C);<5SdY0rk zFeDv;Tv+eIBQOq374}_*(Qxx9C;y=Yw{0I%YJ@%PZ*JQ~qLd1uIp=vHh)Y5Wl9gt8 zhGVL*BN+{WT(t_%S9iOy9&57F5Xhz58vOU{PJfxlc&GZxj$O9>U6#8Ro_gY8A5dZWn7~=oWElg zN|$@E`tIxJ)GK}}*VcKsy>u#v3GNmPO>v+U z)cOt}6n66=`^$uv+#=06&kI3Z5)vbD#~pJhG;zuPFvVvWcn=odcO^9O$emb3QKpd1sLF zg(iDN;Sk6rG?zKYM1OtepuTeh>sO_Az4bq=U*X6C(fTN3P-YQlcJEHE?n)~|u?y{{ z0i|FdLX!z_X=vl~wPnraz876t^|&y|E-E{&xpJd8L~fk(Q6nw$sZVQ|&#&-pM?{uL`RfS$ z5@)^@ST(|sbOdr?-42iVW9>M#4}70Hi!bx_D5Z%dTsmPgot5D=YdwxjYbL1uk9%FN z*58j;XIhH7v~C0GVSMJn%5nA|j~8*d5FH-zTU?y-e^k&#G?*2q#?*yE#BruTeUtvx zs#$n@sa^Bb=$_k>!-wW6n-bQm`9IH7_fJae^ZmKk%wMiub9WS_)TnGr|9h}~xD|5= z%{j;mL0l4&^XyU^4WI@p{c_Z;wvvH*mHAY9Q9(vXKEsg5|)(NaD zIs#i{Y^kxP>4?mh*Q^Aa<%0-p^L0eELcdsJRBK7nMFfVVBhXF;5g3w=ATDVQFztvo zjOCGUn)O~E=a#vYEu|{6ejMA^2G_K_@m)pK=&yQeQM-+K8-poET#kJ&+98%8j?rWtFRwoqlKu{Va zBuf@i+;b6zRc zlMg~up2pmnr}h)1E%9RUndJ)nVqIv%<8#!?bK`!o_Vkc(`;RZzj%nDL?=qFK0fjOq zW{|o&k!2+`#ivf*=Yb$D35gMqDrZ*ue7oH4VELfjJbu#MV^zA_=RaQVmIt0h!*bFQ z7jN}b=Q(!&N@pU0>og8A%C+DKw`lF9dN(NQ_v&rI>Re=Vf>FTIa)^>dr~tsS=vvSiNbdy`iwI zY@ez+()_;25;s*%pXsAkws=iO!X<>z97kRV;*yXUL1FOYGqJBl&FFFAs$wCv)`yzV-l3r1Gimx zT3(N%_-FH!1r;L@9Uc*JeZDPT>yRt)oB7J)5yxj39p}Y6e@+`3U9;S*?N@HU;m%U2 z1WV-~XkUMadw?OO2D)byIYl=sPm1ZBGRD>2GMzJAi&*-C^JRI@dW8t#qA$oL?>UZ7 zK8thSTXb(s=^ndMF(r-f8>ns%36-ke=MKcYdfyvvs;bMG-fe86> zHj;b;Q?;P%b*uDXX&F3>&piFnMAg?uxHW3qh>2=F zxvop&Ni?<6{9R%5yFYkFFKq^CiO>|Hln3h-BIL{6T!=OdtLH?g{O$|xI8n5n5JJ20 zY1&R-`B64^YbUDPnQFRe$2{rzn6=lsM>|1VX4Y>2%xw&%+>?zGnyk`GSdJ#6#eT;=mKcnA@h?;*=-XM_q~N2$U5f@QkuLB@{Wtxu?mtm}N%W zNX%`$dIRk%&&kRywmx1gK4nLipl&(l9iCAq=BYgvrdwA@uq&JiweyqNQrS9x9?XSk zF9vGc@yoJ}^8$ASU`RRwxvoqgZ!H{Ocm#5FeQ9361};1TxmG_pPg&#U6$f(l``0|X zp1N_2(j7hyTQxe%?F2U@G6 zJ*19!t#EVag6~mt%h@+lV|xv>*F4Pa`MO~P)%cn<5jE5>&Yif|`6cxgr{-@{Q?M3c zJ;(8!mMRnH19-Y0PyOo%@(5G(bh)6G6VW;XYbVx049PHZK56buPO4xYh@6>B$R0ToMu^Ru4JmFS(?@Ird+rwqHIrP_-W% z?uZ`;su2>m&L|N=c`Ni3R1zL^TeX;@AF+RiQ*WjzYh2#p6Pogo*flLM zFOd|{FP#ndQnKdZzIN#ip;Bcl+w*S9_WW>}4w0??oTse$xfbC%C3?b8y}`Fz{V%*Y z#JoRssa76X2BFIv<9Rm4kB}EWyl{^8y~!C4BnzIXHL1QbV+1yshMP z{MaWk7l)ls(KXKV$$}cHtfL23G=FQ=%$-eyDi4ZX&w2mnKT9k4V7%G;zJ8G?U2IoG zx-6@Lg$J4)pWo}wgik%2rf$gNvK5-*z_{uE@~AtJzzYn{WpR!x-1O@+apa8%3`s}e zOt@O1@#?ns#j4*FG7jJZuaL;zG2J=x~03+A$d&gusw=#M0gU?Zt!N3_@T? zgz)wzWOPq&iKe|&q(yiCe*|$!NP?hH=<%7cz4oegQP%1UM~_)0%W`S%7`fNFR6T^Y zD;xS#*4IaP-3RN(_|hk>6=%e9nZxHe5Rv@omw~f{h*q5c>TBlo`NkjD>aA4NA8bEF z$Aa?V#P*u2?00#c0c%mULUZjhU(2cir;6nzJJKSb&^y(Lz%mdteV6x~s*h^Mn+GO; z?5u;OGT=R1M*zADFAWJq61OWvC;tB}3_ANQUwH zJ5QTYW9qxDk65cx4srEVXbwLw1aV17dWz_O>xS)gM*tLuwCza$ZwL$t5k!+O*6Q}P zPrh(#ZIQ&+luzo5E`4s?>vn@#qg)zMJ`=y>tbSeXv0QMu4j<~J=A2yrl~=93ZKMDB zlr2opjq4sb1yMD-n|(HmYmv|#eqIRTl8`V(MEjgc@4lsOZsmGVD*x<&57Qg%-%@A$ z^!mXFtT~9lkPPGb)u+=oFJLP@oHYndm$qw^DZ~M+;*NCbMn}ym_1LOuHg7|ucrt5b#Y$*A-`Kc#`s^e^Q~t@ zk=i}o*#xCZ-g6xEh9A0ru%EiAiASubtM*qm0YA8UwRUembx#A2wS}90eP+Z{ui5K< z8SHYQ9;1fpi0r>!RsNRV2tr^;I>M~q#V(TAJqUpz>4;+aI@$+Uz8r+WkaR>sMjNH0 zZGsROl8&I=Nwa?IAOwb_A)M@#*>;2Ge0%PRxw8Uy^3wa#|CU>I|Eha7rb_QV=4($c zi=6Rgy7CIk<=izZ-HtlTx+FRlT!Q5ad}D3f!hI@^CNJJPZ52Hos^?VNZH@6W0?|4m z<7{JR+bZ@s&@1XSwLidmCAvg86`E6(7lODXBu1zaUkr`>D6CMqrT1ZT?%XX=k@V)A z*~%kWh!g8-6rN+rDdno!IhMa7%O~s4Y|A@WwDW-{oM$_lZjF-kQc8rTv>!gyL$w$( z{|JXbE}RyxF&2E3>cBDh10t_6K$P@G6%%N`z7T*Job5^{pkh@Kwz|YsoEq z33gdk3(B|}Y8WTGB>V3_IoOr%!O9JkKXq=^@XlJHIpouKiaLjGc64)&=@puCUZ7(S zduaj5c{l`e3C(eg=~~TfI_P=xv#aGaZIAzNx^4c>nvYUY^TA68{9oM5yBQdk(3GkT znXlUCFNn5>Lm-#XoT|xxH8m?eP}zK{n4O9#+4Xz6$}R7C>ZxKJ{q>o+Q@wiV1?zOx zdu&Qo)h_kyG5$~7U2^5op^?wO{7c>|!q|l-gjxSpAdpLFPS242UCcl2{N}JxP9$pk6eGi~P@ZkdQ1c1p%?FpfA8VIu%)J58SzqWh zrz$T5aY;ywnDWRNv;6jHF_&+|ZO4?fD3+o8RE16z=ZEUxu~m`EBBiU__6@pN&90ig z(WQIzZe*t)XrJ3A3O^y5z1CFS&ht_b7ot6Wj^jynV+fCmkn8MA^_2}m{}6HD7eruc z4P)r0LFP_%w`HHcwWCn(nDYkrW~k9eh?`HGBA3<3BKMA}1z*hy+~9M&<{Z06Z#e}K z@zs6Kl0hBK_wHLCi5`=1WO?e@d8)OO(W{qtU3Xi5|G2XLJQSqI9zV$n+dkBGMC%A_ zMG;+bT8tWz&(Cc|J&fM&BA0GQ*75U+W}&S|+4%Fja~PrKjq+#n6G~4GbT&QbyK50ZuK{R*j4!y)@jajUI^ln zkQnhm(|B`Dr6th=BX^~uE=kU*-i6P3;v)mq-10|Pt8pc*WTyf4l$|{P5li0RWBsv~zaST))e3$4eCDlJy1L^Mkq?CeumUH!!()xr$AeYeGwr?N%s9Ey<=57f}pFv-QrZ}WLf)VoNZZ1R{MvV(4 zoS5zHgYq%sp>(^*Wgb}yO(ErtARd$g{a+qs$pQ0atP{8TAX2HNG_)< zC!%OyR&1S#KAXV#5Sn=KA#WsmmE{SK;c)F0*JRb|V!;kEM@Ecu+RF(%X`} z5R?iD2~$M0I)x4E3AhV{>ksN=*oru_;6wNMf8RSLa!;k_?E1@i&m8NZ?j6XB$*1*$ z-N((b$JS)@%{#L~-___~Z;5q9uOAo(&MlD(>!!!Aa_(SCu%||}j>!38j=B*xIPDmc zhA=n0u*X`lgj-Q;QT0}INUbmZSElo6C#eTi=EI`Xm4yiFmCzi1UI^lnkT69=>t3@_ z^RccViq=KPsrKrhpdJT%A{~J{hp0=aW6F2J$oc+*BSyI`gXEBMUZ!)p@@~sH7n;M* z3qf2G5~he~wI3C+Tb)tj^0^FbkaCWzjzV*K@dQKBemOqtKD|7{z4Po+Oy z1fs(ua1RPs{;`~t2Z1l9`xo9E?d1R0qfsl$tCfO3voh}OUpN9KI^eljWfdqZUS7I< z4(<=3tWbs?KS@{a1)#U6uZY$V$}5+*zl?R_;;rM>x?e)1%EUExMBpB@9tVEGwJZ#Y z5Z(%llr6m#TFP9`71B3(d?wCJamI?}B;53?+b!W-7DH0^#>Vz>qFz1gFM76oWpmukpMZA>-X3ZFf2rpB=bQ6h}@C?wuX_9!0f!?v*W; zAhxJ#ANW5}{sy0ncM{*3o`SlIdD8QNw-w;61VWSC<>msS4I_T~(6o!{%)!))|Iy;W zI&tGf=fHk6qxi4SEHvpmG%wb8rrTT7uf6`>aWtxx@8YL$M3(rtqyD0sl3I(df5$ zXMM#FTrW2>7QJrY{*bft*da;ug^jGAOb@&jPhG1I^Aj>ar03;;&tVzkXHvJw-lDp zob$X8#3dmyg2Ld(XHI(X3Dv93b9FaqUk!Dd=Y5x6+cZM0y-p3G?a0--XLWUsX>}e5 z?pWEo@IEGrhqJX1(QR6rRUH3sd{M zd1@t9)^|IrGbYX-oy^7)i$z_YpJtDGfM-G%$EPW)C+3QJHBF6Iq@Mr$Yn+)DeUEF0 zysF7T%U*HX?E&^wI%LT`#X@tg?;rlAGyLb#Zp%=4Q%}3c#~iMdGjR#cWu6y;xFn=t zEy0$o?#z;ERP&r$=GgLa%J?t!oO_i&!{8P!U#&grz5pJbtCfj@`OW-Sx|qjTABsfz zWVTFG`;I)y)Md5wmEq=&e^&dS=(8YIyckoQxUQkEW!y7nptJJ{zL^xQHzc=&7MgRO z7lODXBt}$g5^tu?UgEBWw|lmm8lQ8m6`Hu#HtlBTdxO^i4I>-^xrC+^;-gC!`wZ+$ zbOg?dunhh;1aje=D)cQF9P@K_$0OVZ%dHETBBFifg*Julh|M{!F5R41K;3ei>C#Jg ze`BQ|_}!&d>DZ&qV;APQ^#kjH&?Mc=mWQnZ9c8psEZjk}hC8i$%FG?zJr!H>_GQ+v97xERkrGUrTJzT9b@X=m+{ou&u8PzyKx zs&hrawRZY+weo)^qvVS%{vSm4*y(Ebjn}U*TtsVenA?6mX>}}q-IZd+X{Xh!m}!)> zvS;}D-)8Ack>)!CN^ZyY>hwox_O%+^u3+8N%0#A-q+Xv;dS+ zp(zgCdgt4&(GEPU3tQgnYXtRrl*QV0CDCGH+IYuqICquj`1N{M_?TKdH|*>VcaJ%KV2GT zRy(m|yOb@JGnW71R$c8fow+m(BbOPC*Ud^#Uvk?-(Gt=h)Dn5mh;>D) zIy<}Zt%%cp%CuxRTxdcM*paC`(@4Af*vRqbGZh=foa?ta62r~kn>VGf0+l%Pa7W&D>;-g~Xus0CkJrKbGqmZsjqU2Iy~S-r81(|ql~$ju*STHSAQ zD=PV*(DZz?Y7u81U0m9g6{cNiLWHkTWDj??1j~fz%sHI%=%P8+!$YJ5DGYw-9E*oQ zuBfxw){u62A&{$8gKSHDFG=|HtH*&{l{cNSdbY_c4&=&gdB*ZI(Thco1Gx?#`r0Z! zD6cq>t52V=tp>T?w7nSa&IfXB`}Hg9RB~Q%AXmTnUs=&bWklx1qUQs-N=^C78Wx=w z0=bB;aE3ifdNHVcIIX^zYTxv;Chka@YA@T!G>!~(1aTG1;5Hn&5UnGOom1^0$2nY# z579be{K=_y{D046l=-49m1h~56 z@ssZ2+zIW|XdYE-#(#2caSHaGX!aZSgS!TRd$c&mk@p;a{?iT2Q8k_l%DMerS9{6( zxpe}hpw2Xui8lAnuk9jkv_EQnJ&(`u2~FXST99Q`h?TQ_8-~W3P8@sel@qeWB{az= z>accZ|2_Y5^C3D);jA8d#H#TNw|4TLbKa};AhSe7y4#;i z4(W@`0g>hu<%J+F35gN?`V4fE#_>9Xtg+G;p(&2jA2m?-Zix;3f)cjV@ejU}HFM7P zz&)%&6XJHw?SZ>mm96{_1W z8qsg7TPM)kFtvHM6+d88ZhM8^Lgkz=;n8!b(DdFg4orO_x=tpqI{y}(5;#$HxL8Wy zUKyePHw1A>NR06Q`b-)drZ!Iu$_I@fcm2LLHy=-*igWhW;Dl2!lW_p-Iw)G57T&F>x;pFmLzvM`F8#bw#fqrN?K*WZgR;SKB>btPPVI z+Kck@*#lj=h!8GHJ95c;P8EISN1fqSs|Nc=uL+V>(!Qf=HR0o2SqVRB2}%*Q1VhsA z$HB4{no`m=Avw&AI>JTqBA3vdo)1P1asIh~YfuR;-A(q?!KG;!8J&B_6pQEi$Fi#@ ztz%VKqC!)+=RZE7#`FAMm3e__F`Mg;am(YkxJYHW%BM3$qJ$8drJEOmxFjS-;5|Q> zL!pW5da2|vHvMzdjS&4Ac^=n>qsR{}F+$l8(R=JlKb0NIIfGN1t_b;x3CM ziW-jb=?Gl&#_^m!hVw9VTY)#SKrT$7$4@oN?vY_N9Ld&AefsROT8&{E&kU=3&R2XN zGjbl^?Sy43H05c zUZE-Hi~s!v+}aZkfm}j!ie?tB?R0%N+RcZgm%`ofLKl0^z`XKd)}LmNnaKHQbN}>! z=g?(pv?O-`8^-7-nmOa%t7z6-xG6G}tVljsyObxLn8*C%H%@T=ex!)99Eh~fSK~PF z){SygBkc}VW!&z`*XJy&GhJE12TS*l$ET@vYJTtWlO`tZ^Q+zIVD0Nze1}@0=l41S zb+qW|j{=^1bj0&ZsynN@^R4VS#u1t%T{SxuZeGV$@>Gn8iWg#@hffg5r@#dNY|K=}7Ig>wrI<-vaNV{h4d>KnK zYzZoh#*$uTDXU&RC?Z&0gmEvF3v=_V^B=R-G3;pQ`R);dOkJ zv@YFn_sWR2Q##~c8KUu_QQv8`W*O_s1m&j73hzJ16FfpwnEL(a4Toj;%OB@^+(kZa z%plR^M^oV*_iza05}NbyYOQ;m>jil&1Eni8l^{x*QT*3uUYU@hRsmVMN&8Z)%1<(_ zOZUu-?P-aZxoy95GBa=|{IM#T?oI3zhrD++#<;hAasQ7~;#|GLG7y@$P>&4*Z-mAi z3QdSsU!;V&Lt3~fU&tji=lAs{z5NY*W6c-stqoBovTn?&3a5$3s-#$r=dgTGZn~^E zCi?4hE|i>ZCx4rrQR39kiP)ZFJMZxmqVavx)h#N)y#e+ThB2~Pyfe1e`|d6>T4rJU zA@4c-ebe7byT239qfjP7Q_f{pM6_XqLm-#X9LM7`I{Tv@ALsUiC{dwlpCWuc$GW!Y z`9jJs=S;Ug*6*IJR#H3WwpX6Cd`_8i)9qzfvooIl^sm||U6i`VPkmIm0^eHA&auw{ z8XFcp&F2y^J{^Hvm=YbatbGlqs$-c`u69nnRDGW%7N$c=?X&i)-jXC5XYNzIM2NoD z#IVqQBD&~VYsbhJgL7`;7t9leq~$}i%*4?W_VXG-_2*t|fNgMCZCAH|r(0Pe#-BJc z(Br_-D~<**BprccT(t5>w2nXvL*zoVjzC*Jlr)x&nxA)g$Z7J-ORly{4?yLNC(-0R zhd;9T^G@?a)y$_VEo+$Ce4jNo*UvI)On5CpxnI2XxkX;`C_ZeJ4C9{}W1V&CPL|1g zl2S1&yT?B3!clHlFm5g94vy}yMqy3U5%>jl2}9BlX6v3i8`ba5s|MXuk2R8&f(u6t zD{0?)i(8+rS)-h01rprRQmA|o7v@QiWB$*H>ds7F#j$&ARdIaku135(^TTS-)Y@Zm z93F;dUXEIOWr+@p-V>1SjrQr*oem-Fke#k2rrXgiSfaSHi7TF74AlB$|CM3QkKnK{ zT#PR~0=c4#?y{!Ul2n~Lmf)}d`6zQpr~9Hp`I(?F#j6g6Pqfd$o(1!PJrSaOTF#LR z(HcUvS6+HCA84bYBajQNH||Dw9x%r*Y_GiZ@;W}zE0%b4<2kaX|LMXw^Q&RSVh)#o zN?C<@9`}mnO9<=&$=ws2q5>T)r_|vZZawcezkxcR&2vkkNtdSVY~cD{3f8MNAFOC3 z>!PTAn?L+Ot+{W{T_c;(wuLkE%p>M=Kc9#!`BekuS(Wul;-Hj-jYFFWW4*(6UX!ka zU(gN}y+i2;^wW-L^i-)M<_~%|Fb_urdV<#x5l>CBAFh+5?4b+;XYq&*k3cSU>|R-j zU2sFv5N4aXY1Xb$Tyu(^P6^a=tp7S<&cGd3#PDDQhGZDeUYcmWuxUryx%#srBd*W0 z3p~VjeY8fF-H`WzbAFhoJdev6UN07(dHd7(jT$DM%w0+S*^%9)S=QT)N_M&sVkPyD z#}_v0)8~NurDw?-j(DKKkPM@J?RYcu&DHMyAjTdlRl&M@aAjLHLP~JQA7=fn0h->| zEyA82==8u!|D|t+yY)ln0F*bODO?-}slADN(%*Dey<~HE?sXgSe%pb{;=NbG3OYPjzH;R&JnF4 z%o=r5tZ6^-tZ4qvo0Vp8yE~#^iq+7zW1&yi6dy_1n1(Ij1UA~3hswtstqdHtCh ze)=L>f;4H2t|g0)&yLC0f3&$NZAoOqr=L;hkGZ9;lT+Vadnd&CYR@PSpRz(v;ql`$ zF};|NcF8H~R+&XOHIwAb>VWt%%p6{eQs;lFfJ&yx>I;m*lq%k#wMVSH+S zvE91epQX6$YN}PI8`C)A@c5}bkPFK|M-0o?)BJq&wIErceDpHUxo3Lxybrcj+Cp(nkIL1A#Rs@_ba}Unz}v-zCN7Lmoh1Q;e7Tzo(Hsx|^*Lv=CfS`* zj<^!Nd@Nqsd+G$L|dTZ%oJ7g(kVlE!P-6 z(LS^Eh75H+;$3&tr>%vLOShI?UEsbqjISrPbiQx>u-U8BIc>&*Uf$9ByYldKsG9St zS^+uP_-ZQFLDU#sOT4uClqb(g%2yc62W5pa^!OQsd~$MBAuUTj5EoBOS6pUltA z#}j4a&DD=2x+|%uKR8;J_pFItEP6f1nu>DSl3W9PzpK%wPZor z$#zx))+G!{N1*;7SE2OD_QM-RufpA!j<=)>jo}kba~##{*LyptJg~Pzl+WxHwL_f% z3r1i_I%3DjWOYKCM-x3uZn4_D$aKH?$yV$SGRhb}xtdw&MYe}K)-%CQ-OtvTn0BGL z%nPq*;XF9%5%XG)EKS>SPL7(dJaoMHhI3%!ez&E@c2LfbQn*569*EX+j{1svj3`~( z&p}NMkH9a8z&fwTF>1kXYfmLU^+|f%?A!-#Y2wPgj;J*wf4~nQa*bNB$7zQ@P|Pj1e0m%>ABZlxMa>6Tm#$1mwvrzez499o8TI#WKgQ^P&h-1pea4QfnyEkW%=F4QrM$7iBm zVOv7;YVLAIqX_Yg>NR!-jZ|t~MEMrDO$a_YtZxZJm zxmDU9v0zJ7T>PxSEiqCC#FaF2mhE{dKxpuKm>-QBkEL8H??H5d`fnjqE7uXjUgGv?eQJ_a|iJ*GVW%G_EDHZ zvnJxHUpd`NT&Vf-o)KA7M>|)G&31d|P`XQK)MLX~{8@A7x5Nrg3{;oSGkmkrlE+}8EJMflM(-Dow zH!$~3d&;ad_fKtAEtK3?R(mF|itb#OSBk_=o4z!j^QD!q7E4#&bBYilT;UMGmsfUmB5l)|d@ueXPzH@%q)U=rw^IGmoE2u*b(fb>AIpM!RPxsXJJhUcJd@ z*Xb6*uFpwtF-dua4dz0$vX9r!F5&4jJjcTCF-1iC%!CYO5#3;)+x89Ep?2SY-tW@7 ztUR30ocrExr5Sr&E<}e%;K&luI- zEYR^|DL-QGK`0Ot(?#PDd*;6%FY`0o*q2Ul#~!cXiimL2;!2E81e4Q zrddbb;?g&9&32I}eL;z$RvO0r2Qy+mcK9Y5OfR0Ok@u8B9U)xw9=YT_r)q7!UQX%# zKe{8bx-%cQ>wL(*`6cay7W#if5SN6+h^HPX>$Jc6qB*B-!_?pYo@Fnq!*wvK;%vKU z8K%3Im~Ee~Dtbk+@}p}zYWSW?A6s+_C&wZDq$TAFe5`h@_)E!7tE^G&nNED2Xhg6RlyMF&()pYUbx7m#}N@R(dnP>H`mr+|LA?zpuXIe2u*2^?X}M; zWXZnTkf9}!&`xX32Z!9+i8Ym0*hcg_m|JTRfn4Z^ zM@Q^=Wu%k6ZLvS+u^mykQz_D=5OwKR{_q*+M!$OI1E+3A7O6d3-SEyDhtfr@^wLCn zg}o)F1XE}jlV6?S49%$JN_X>zHSM{toOahI1xM6FQLsm%f>w-rzIKok_rWu@4kLcz2c|b60G^nc6;emE;T6w3R7s#xfhGisn}$a z^3lgyQmxP=yYoLh-@&<$$FK6?oKmCWQ4a(oFeDv;;Ubzelvb(U{IU6Z9f7O0IH$sp zbOfdsxey)L&2a0vS%0=&_adt)u4 zv&V|RrQ8SzsqIbRiYM+ILGy&|;!(=Ad$osgz*WE6%p=lqkG4X5>FKD#76J$(_H z5bd6w5tt_!MmPj=2^}n5T*t-Sq7*1j&#HL-psMbDEc8}r3YX}ypL(Mu53wUDC2*%R z(X}#p_ABo>Rj0lg?#xq`lhtFgQrkT{Tb*j+8ddt=vsJ5o$kp(5b!RJYfBc@}mxt`g zJUctItkSKi8l6#h7901uy4$Buc{O8U4>ae-mA9Kd!=6Kh=CJcZ5SN4`sgYlG|3~6P zyX05g4z6l4(O!Hdm!9^X-KpWjxw|s8V~0AG>`nd)kB(OMSF`OEXE?W*bCif-R9P8k zmMLK@g(iu%{=U5Oi^LWl;StCsG^anW6K=$Xr`-(W$CqozH0(UV?VWGeOi`;i zJhHgaK1KQ5W?J5J9EcDu`hr~YK3I3Vmax_RV~@I;f#+DrZ(QjO{GQ|CzdmRFAfHWl z8qoi-ZBL)=vpde?_lEJGZ^t`MM1!D~8e3;9C$%C_-S1ziZcKaPWQ#~VyCN396rXPK zOKIX)KeXjT8$aQuU!RFz&?*o|gF3>j-%)uBt>DTE<%4ola-TiX`8(oFboGIo zBPF*K4(3+ZL@ySfJ5H2d7$u65_V`sk9OS}LrarQ)xAJ66n{#|i%(ORB?9_XCTq2r5 zVG13jC0eR54vd>Z@cNG_J1f~;Ut$Y&p^1ZNY~kn~JwE4hTc7gl8?0BTZ_LYoedeAO zuPG~w@n=ym{@# zTWhTDku0}a)*5TjMXryC@+nVKYn1J2Fjv*=HCEyy{9Z@k7YrBU(-GAQePYQOXXL`v z>InRTvuzAXLpZ2$ST;Bs)DcS?$Ge_%=q=j&;10iz(Asur&oLw&ftE*Tw}kO&2orl` zq9?t}qZi$J$HVx{GGC9f2ka=}>JREt&JVNgW2?&K>J=i2e>Pj)aKajwFnP9WQ6F@< zFeHzk^on+oBh<<^Xd^)1RtT@$g7jn4}ts`bveAOJE z|CYbp-ubDUKWuOJ`xmzil{d9__lGFVH6OHBCt^a>L3J;{cx5-uh*~q^?97%-H@J6# z9T(5EmMW+5oC)^fw|Gp2T(@h+*?ras;`?*2nZI1S=Jsl0n@!;eO=W=g+gcnZ-Z=DI zT%5hOX~F+rR?eKuJ>hBz8*4>P;#3h8e6& z@*Yz}G>tG*E%F{u2-H5*N<`6pkeuMmg7ZEQl%8zEfn+ zx0CH1BSlups)u8`=zGa_)h?m-qbg1t9oTm)wd*a_u9SC6aM_UYf%lSY`c=Eg5Id$F zQ>!D$kI$y-_Xf!dWvC-?SD!orJzVDj7k#OEDPgZ=_z?k*sbk<5F9+ zE7`tNnoI54C&_B|!tb>>9BcuwWx#gDFn%r=XP!%X*uU)CsSPK++uklxn`wMT=%|Qfm;S5R3hdbM*`4ZK0tSe-z*tLXc`?uL;oQKP{HkbEb6p6B` za3)#ZyA>=qwc7_fakBeHaR^PRz}*^270+#DR!wgaA@3<9LC~+yLAhf)i0z|}Kv^Ld zN?Jz@*fCzMw+7EM!y`J*8*jH#XNf2rEDsDxj{{qPL4D_{I~#Z|iy>(UwPv}{S~P|G zyUdpFSmIl~WT%D7CSCf$nU!9Ou93(-IdFGUXp-oli|y@pH+c75j|0QPI8ME}&}w{8 z;_xu)mNDf?W9{B@L3%}&3Z?(e?>&Ah!FpRpDZlVXGg9}CjEFOb1^i299vo#~SH0c% zUSH~7xwQ3mZ0*QP#pRa^~D;bSISSSmqW~X z`hkV&WKW2kJJ*k^^d9G0d*a2>uD@5x>5x(*+|_!_HPL*x)2b#sHpF~j8;mG*o4L9H zs^h%ektMYAbkCE7q$4m~Y|q2TL0{??;Tsl_3wsGY4zz9}?{eK!|902g|38Em2XY}= zi^I8AV~Fd)h~AdzJlOTnLv)<^mVLP`_oDQFdB`8~yMenP&W)RIweHF5mzzIaXwjX4 z!DWupC7&FV-sO=6%22|mU-n2UT8*u!u8AIoPQH9?rMKW&Dk8Anc>ELxS>Sd`2}WQ@ zIs)5pi~~c`5tv&{d)S;iSUa)ShAj_g?4?n5#e!TvbTpRWgU=6e&WG_E5wa{8nmdh5)e9`j@trNNDG;{=h!FrCZtBx4suahoya74el z>7pgGtc8ta)~6$yZhNnh%oLFe(S|YfVsEGQGv{Kmquy+Y;}4WV*ql4XKbvIV?$5GX z_qormJCo^VD<`R&vzXRW<=VsDSs0AKkTis9cRy$p7==NePQ6(#-D`I}jL)P~%3l5O z&^jXLljhFkqzdMN$eF2_Ta*H};9d;We_#$V=d&hFvR9N3m2xihcP9y5|??x!UBI@LYjL5d`d)0Y#%?*Fs#=BD+ zKHb*c?IQ%*@}UK#9><9nS6Q)-as5Dx3$*ai5%>l7voRzcf%(APq7-xl%Bt3kHH}Uj z4jw<~h<@|utDQaWId4wMrUkB(@U zyfV-aV%jfNUm4oY(ri&|mGZd9T7r2(boe+B9SXs@mz^pKM`)BnP|lq-ACQ$N+uY<^ z?^CuWOw%s1v}0PTv=)xI%5-Kcv})5;NFR#zj)$RB*z?usm8FPj-}dX-h_EA=-<9${t%8OgrXDM__tU3W!#S9`R;EzJ%xtM|&w>tnKYFe(r0@Pm%a0LZ3yU ztrI9_u}B~qca-V>^7u^DAI$BvpWX$%!kuaiOLnIzJb5o+%P;Lz&$FVIvvfNae>*@g z-TiF3%rXq4Tt-{-^Yl{gE?JGbkJyP_xtxV2UAlPd5jB21lRN4|U-#(aRcK0qq>weS z%C_gs7Po7gH{Kf7@bpLR?FRc;n@Q)+l)|eDdpaYSi`FwF>j8_|)b; z+x*?DQBqfgn|{@5loq>}sugFlz`v-6(XG6()Y>I`^+ug`xOa}f^J}BsyoiM|h4RPa zRh36t_M{^3gJt#gk;4Js5sU6RETYqF7t#d6Xsf>dxNO6Gu6FBj7Zuy zOZn>yMjJ-B{Xy9)q%_F>pqCz|d?waZEN3i#MCtV$xiEhk!rAqEyj`aP>n^tK3l_&K zZ`1rSnd7eQDb)i^6{glOK3`kbT<&|(tx=eBp$UyrQ0r6|mPL<<Wu2&BDQ=j%z zCAAaFbx__1*Fo$#&HD2#`#aVv>>0dVP#r|B3bFImod!HV505}DoZW>-AQ$#a;SpW4 zjsx$W?Ojv491oULGFCXCfDtW_ZMuopY=Ux!%7~3VP1-ZzycO@la>C zMqR32G2lP3LhPe<(f8O6UET-P4+mQVY@@=r3|Lagg=jC|q*vH}U`vE(4PjyqvCOe& z&=H%vWd(dwRcvx3Fnj6IJ4>DP54B@LyCf%GWPQaQlgn)ueU&e>_foqG-F9cc1d=IKyiF zeJ~fI4dY+V`!S>IjC7V=&u)mj(^yyZ`cdS~cr*LV6xV`Ia!BcwaU0T{p1csmB_Vk^ zV2b>HOb?8sam-M&>oluF?pQ+E`7e$#i$3*lC*s#_DVPsTJB@v76%IAt#jr4r3rD9| zO}BB+fGP8tI4;o3h?@81V$hF~xIQx3Lyb#E>X?UF7vGwtct`BYcEPNcum9sj|?$svr zsW*=V_UI9TA?b+IuOA8Q-Xj7-(h+lCITEnmKm>-QBU-dN60rS11csy|hzoUBv=7nB zpV&_m%_?7RPaAxqTPj)(ptS&cq1NKi?3S>9#eP>qs9j`l=NR`waOA8baE}G|(!wL~ z3+~F{h+L0jO_%-3mXllkbK~~g{f;qxX-%3fyJzag!(Wg4&)NOWd((<+$DQ0UcaoKF z0A8Qb_YFR*5O4Y)SQj08>+gnluaSu_?{Q+~+WwXKQ|UZm^(iRlCQ>JmVFbxc2{1#h#Te z>u_G)eQN$O$LjhLTOmw(ceXX-A*TJyr&}#rg|bdKd}yv3$#C0Fqk(3xF+Izgu56e> zl%Ppp^3~$l5gi_ZvBwXXu6Ex!C74<-7c{Sp*gVJD{9!N`qICr3wnp9A%99F@GBIu) zQ8jzI@+liEA4KbjKOUc!FeCHC5`9oS9X0evg~(ZqEq^InmYzw4qYU275Ptx~ zB{XqiU7>ICsGU)0;VKW^VT;XGx!wEh=#V#=VLq@O#5_s(^y_nQUmmS&isc&~xZPDl zn3vnWqfQWWEka8d>?I7t+8gJLN-7lde9PL~abI5KLt&x}4Wmwz?&jr!cl`8aLD}hc z&yrj_d(z!SxxVe>J@L@vt!&f%59H^L-V!a|v*bONhl|u~aj>l&;~!-&&Glhy7=Mnc zS zMGX~h`t>;|pK=8TxhwSa1<}k&f9XN(BO3QoWvs=V{MTphc=~|4+lgDz2F(xHyMAYS z#-jadzlB>n9pSHdAh6GY=oGXLk?mKxCE5!fz`TCm@)_p7u~;nv77?bu3$j|0Qi zxx&QZO#5kwyYEEuz%sy+)nw(MtuxyGAlfj{+k%`@r}`{3g^8XQl#k!<;>`6Q<#*c> zvN3PHibokj6S`*CRO`32P@@c#56W#t>;dPk#{c(F;O5R59S;ZYFI6{V*f-Q! z>tIB}bU$=AUM3`r{w6LYAiJxrOal}_&q zB2Wr?9QXxy$YD6E7~ZVterk*__ATv4*8d zdnNqT!eOt5T!hZNPY~bdobV^!-`}b5-VM!TR>gvC1D?jvdltpu^--dI^j1zj2u&Iq zq$N()n0~I75Z4(0Yj(c%+-h%lp}(td^d29di6clHvEqnR)u^Y(nKL*34Hjp-q!Qq`Ugqt&g` zyIwT^)4HtN53b$2Euif+yKb`!O=5`(&3VWRL0l3N=9*~QSMz#+2KNqhcX;WG-j>iC ze$@HFV{=rWQO+GRAQz&An|^%`-VB21@Q6K?2Dq&O;St@l?Ca%gux7rDl#m_`I1ab+@se1jLr_HD__1#p-UC|V- z)FPz+Hw1A>NR06Q`b?BNmPa9LpE~I+<$;Jb5ARcc;P?x2A==|7O+<}DF4P4bu{kGG z-Mhl$D~u1(Is#V;P%Fb~q8h!92zYzK)s~C5Mz}Ts7(UTtM>fxzvXk4vyVn_H{Xwl? z&FE-Getw_RDyKs#wy&8j)rqhHsxO{ArP3Nd7 z#0%_tz%rA}Ulnz48opE=UQx{72qV8U*DMn|TV zN4m%%W`QQ#Viw-qkb*W|xKfDrUWQRR=U-0y*V?&t@M6>y)mrkIAE7CZNe@o3yJoS5 zdTCPm(B^6=AC#MpAnjW_oi(FW{*iXiqwISLwNgjm3=+9er`0+9`XBozv>xt!nen%} zv$(RFb8xSpv%0c6m&oxg+Ev*;cT+|({kFxMw~E{DdP&0MP3~D#%5BojO}6-illNMR zO!}W$KP7OdGkUy`_lO|cXWn>VuRZN2E-9ibHeq_jnca3&sPi~x)}L=%JAUIjnA!4e zb+;4K6=L7E;@7dpU_5j`XM);m`dq7P79(&TjrB%HpuQqnM+`g>Z+>?3 zq3FR&=cl4XYt7iCR#mx9gpw}B#j+Clpyn^zxZ57TP_%vR+Skm#_x=(SH}r)_tZNt_ z)=fPhICjRBA{=XL2xnIMAa@6g-nO}au)AYM^pJN3x%*T^=LXYn_b^{=@>|Tb0`;}>z%s|Oxhsx3dtDF8R0px| z>r;KNU9NzXc?bK?=npGRblao=+T%q?P)I@ zZExG(NpEm)-j^|jhW2yrU$9uU)LvLK7Hta5;wvoK6nYeHqI^>C-5rn{_6)joQHqET zkHB^b+bg{eda?Lad*x|Hhx2!=Lg_p{W;}FT-Aa=e0=fLlZ&}^P z<%K}56EEIUt!O9&e#v>v({Rk6mkVmI@C(+Jh|M>xUbnd3ghwFP>5pz&Q+(`oDm((Y z{L61zD`KTLc>l+hF&C8AtS`6Jjzrx>9Sf^h4z?fIc8&OPU|>H|PgRRz*VR2QEX8{D zep6>^xy%=oy>68rEHjpTmn)k2FTUW69aAK-&D>3PdQq+im=e@{J&pkrGHqq;;I=ZT zyFE*0+F4&n9rQ3hw{c~_F|wi$8kGyVQSTVF3szjUnV~5 zT-vrj1#^zMO`WyLy%&VrD=!x1nH$#OXkAR!%Sx6#6j-HDdlOrSn$NY~;BWuM@ks1b zF}|>I1oZ}?dIm2|)PD>qHPF2Un%<(8U|*sm>eU+ z9#>vU7_Y2jv#~aG`~pe#0Z^tAs=}K0Xt3NP8g%mDK~qM|%iD z>xd$?4+d5_5FH+YWzZ|?k|p<2g)finzph&6Qn?jvy6u`ZU;@*KHjKpc@lLZZukE1p zRVcan71y zZT(44d=z!%_7+=iHpUc&Pu1Z=S5!a2abSFU9A3KAszKK)*5WISK(vmi)-~R_vf}Nu zcgNS+Ug(~rfKORW6{gmUfpn`^)N6Llt}I<#&Bj%3wWGNE4d?vCO#i7l`6GMX+M=w& z7=iJHkK^=5ecd*kVlGmVym#UF|FaqU{ z@R4?g`~d zi^Dm5sE4~7MsHC|P^QfeF=iJ4{u5`t}Acd2JG`mZW49vsKHaoq!Z^cbIETwYYnIe6`5cOHc~$J`2yc_5nhaSqtUf96&OC9020 zJe<$O(F>x(BXGYCcM)+vQr{~QjU$=JI{yE!iB+?^*=N%NaFaN1!etIxND(HXOAbYk?MriE_shM6_Xiu#?Q5%FN=}KSAnivj& zTtah-%(ZXD45`j1Cvo-c!cle0+zlQF$$O6DNZ(;*ufFSI`p&43imREp${E%MQl0JF zqt=kP1-M>nk3Hf?rdO2TYtMK{S_V&AbZ6L2)r#_pN6(VC)JY4bqt5|9i$a^k9FBYG(T)~<*|&lKp50?~#scCXrZt`KR~E_zQ?!;y(L`BkEj4!)MC?(Ji`PoG3}XLYC% z+1&STTZdb5na{HRR=1Kc-Ld#>b?dyWQ&V{S(A}B0lyAdeE<_7A{rZ#_k6Qt~LUecp zY8;}&Bb2u!>vCI3)g9%hhG`5*PgTd_9@n{XcdTM|UI^rhI(tX0K<0%&F05VQQ-wLi zJn4v+)yT5w%D2;Iw|-yqUxJz;dKIMYYNcqm+G*fc23w+{W%k;K1AbCHX(><8&_>-@ zu-9(fl5>8#>Rx+u1!=EJ{PjajiNAW8i5&`UM@>ZS%bb(w?m&26My_#r?$2F`UMPEr&p1DKBVYX_p*R@I~ zDPnF>3Wo9G*wJROS7*miUN8;}SLopKK)K@%4VDwi%V%Ogfn|d!)DW6o#^w*Z+E>)N zF>ws)8>{vWnAQ>a1rb#j6oC_EGjxxnpV%qh>6?mD%zhwf2@5qE!C;cI;yu2gVmR z4rfJq)lVo-h4=O^9D$=Sl%ba<$_JJpmOs|VyAZHPhnj&JqkP0?#GCIQe=@pyYbRw? z`$XkIm0Nw(z9H=r19qf_5jlK%^vdDv+i==XQ|yK#`Su~9sWkVGo}w(E#D>L77q2?g z-X?J^DEqfMNx;&r-`!B>{A8u5$kuqX`!~(1OC ze>Wle_GB)Pi`x?et5MOj6V?5LylyP?|Artg35gNS7Pe2Dyq4oYiH4Fimj~nMug}3% z$Mgfe-IXc&GGo!}YS)^5FBwLoZd+rP_8H>N?l8SVQ@A7gCAw`s#}N*JTtZWP%1d*X zD*k(RO>pCA+;XbjX)Nn*zxh*@l{f3FybsEUI<@7s2Ip=X>IRQIS_XAWsHfUF=2Jqr z-ii|8l<{Amb8J;_wFdB|+t%(`@w&3nU|R!}n_(P2)Jl!l%enc$912ZoUlE(AY;13LUURMEsuB3ywKg9(bxQ@nQGyn z!ov1LXnL=g2O{Ll-CT&KZ}Rxe;t_u;3mR^zv2`x<^$knBZ5T$0V?&+VNuT+b7g!Ow zYEQF(|3pbUg_n7-nR{O@=cD}Vqs^1I`PONaqR_;3r$eGWM|mV>#NY=;IsI=eaOVaX z7RDhoBP178quM>&P5G+jb^NtWyV?2PV46lahjZT>#xHja|4+ZiITNO2Hk@<0vhpg; z>oDaCR8f66x52u7$>5`_{hz-&+^q5H*@jo1BMTay3GIJoicRP9DGeQ}PEmKk@O#5J zm#ucHe=n34kuWt1`wz)EA@rQn8-8eA^rrH15nLXP=iIVRpO@Z1_^BVnFIa-umw5by zz%Piv8muGmOO4MByTTGq35KL2kPCY<9nqkp={V}{%-#dGrl4M7`S;r1OpQxKOT1Wo z=7o~Sl-(<@n9}K0vO*%wP#&S}S}%lHNp*0g5m#=pRq|pWy+ST*ufij+UBXrkcb&92 z%%pw4D{qLwWsY@IN1&EqO~uj_1dlAyx=Co_kTn_RMtis$?OO!w-mbT65wMLb@MR0v zHtwr#?bMBI+m(m$4CP_`$*YyOqqgI?1T|mnrH(A0_WChC!GkrbWTz==w#~IjXvu^8 zs@u74AGPfsUYYM0$txkP{yeJI;X{B}zdk2tb*z2x9QU<1PQ=>N)-$cQi5`ab2R*KyCA-+GrX9;@|9O0^@CfAE z@In`R&cM9lK&~|(bg{>G$P0m7t-tT$T2`f(xV=7ns*vk&tFFr9VqS4zScouuT?4!1 zo}{Q05rJHV?&)Ul+RE{TMEB!5&VF+o+Zi??nAn*BzJKIk_<~E-89? zV_;k&H20i&A&5&tVnlOgfA!g;??+LNupS6a@3G#{|K%aO*PpBk<+!p}hy8rG-r{`1e`5%0(V#5}PHoAL|6o#aP=)>y0+-qtWXsd?xV@jhI0sBO( zD~9o8Y#;NPSI@@$TyT2i!IjC@va4Lr3r$M4XkJTkw3(Z1UAw5)b9zs{!hTXGx`Xq9 z`6EtwXnf`67SUC+uUbP&NgO!#!5m`Fh34|;v*(ci$yEcKX(jKEYBRT)eMVmUWE zcgc$VLCW02_{>S~Ub4PE!e5XJ(dyh??wFy=t+*7_5|rE0-5X)dfHuOd8nke2go{mn zZJp30QC&;^Q=q;{|5`qjo{Cz6njt$ITppMYJY_~c`ea<9OLy12j{b&i$2pbT&Pv5G zC)zsW7}YRdx>dtD5@9)SZX6LgW#>jUdgU@lDd;&z`(4Z-)(?>aNyN(qm0+22N=q8B zHWaCS+)l8W#yqL@`gcJK+ZHZ!EPuVs zy>#*Zx$K!EIy|Do`TtstbI(*G7iy>%1LYhMSho?aBk;>_fB$SvdXUE@xWlg_kPCP6 z??U{$l>bnVICnmXWiI0c(mtU%{4LHB|9=aObRxT#O+{^3wsB)%^#eoFQsv!; zo!1fA0w5Qn)m(ODVp_p6@y^_PYDQt~m@3H!*Qf?RX8WJ6)7Quwm`5`Rd0yw61E*{5fvV#|Q7K~l43fswOeY&(56=8xz4n6)oHl8P(acTOg% zGo!pqvTaME>+6YAL>AbEhw=@Q!bM+@OWp^!)FhL0sUpRjs?WIT&LH*v(aXEfEe}C- zk36ncXt|zKn=fdOpV|^^Ik5)_TOKC%A9_l{#i57g_2=|{z->9(hZd%*nzRU5t6~pu zxK)dQU9Y;wePU+JAG_W#8||L69VLo;P*_gN?g^|WG2Bq`1(%0^`87-3K;o7Gj*x~$ z|D^mu$Y{xvFWrRsleM%le?cxpd;C-$I0{3wju_j2aaxlWybg!Di~1_GgwHcO`X7z9 zR~7u)twlKU7FrPU>vOQR#5Ndj>QXCBaBc$aH_@(BXqIkX2;!2E7*Y41N6on(HFF9N z?UcG_h4Nc@iCcrBr>odEPB6V{PZhi7H!_ABzIRK^^IL{Enahj)A7j@7UvvHbzez}9 zNhy+BlH``qHrvCR;lq7e{b!lm|9ozYCNA84>BqTpcNxDnY z|MPsGJ^Q{tJLk0jdA+iy&-Zyh&*y&sJdY1$ML*lV@D)USe0%2=#N5E|%2MIG>NN3! zy5COe7X;E26CEKrMJ3q&HPt&8ya#Xa@F8n{FR2;f+Q+5Izj%fmVy^z@zV|f!mFZCX zC8UY`rJgE$8HQX79{av9T$RVr*f@Jdwsb2!CSt#gJ2j>zgp4U{#`e_#Q1k3 zF&F#Jt)*^5aE@ZGyXmG_U0(CO=X{9sI!u#}!1!W1P?Cnw&N;=d7IENBU>$*9u)gAr z3mt*>h)@T`kSxq2H_=|R(MeV2qd0%C{PlkKp%?BjW4`X9dQh_b6a1c&>J`52Gz@&x zfb|vo!SFQ^>lNm1&X`}7?*XZm6dV50-s@-Tt`d(Km?k|Pc$*5>8M0eP>5%Y3*-mSs z|D)TnGKLFH2)s3`ziGhKVlH5>EqdrL_q);~*KP8H=VC$Y!X8{hsJ*493(p;~{KJ;B-qUg4&{^aAu}Pu5bdKbWJK(>embq~G&{XZzQTMoBsX`wXnd zh}ID(3%L-jd%-|&7ebRv@rc3lQ--xRk3V#uS!d8;AC}s}OBsbPHN%%6p1ulAWh3LD zmIkMd;FnMcEOSDW54XLZ^27PIdd`>~o+htwPqeblUXQh0-g#9|9AGxwe<`+lUh7Cq zNB1ikg>N%3O=|c3k%!FmVKL@Y|E%@l+}YQzr(>^?Vp8_0p3aR%PRHLj4>z-)&5vEY zWV;XLj=!E^r8VX~L|sSCI<3u^m+vztJy$Lp<@O%f(`t4vw+~&1n`6p0l}c?ni%(xL z<(MOe(R|l9^H|+iVi#{N5rwfzF+Q}!H#x@8)j+wctXV4yc$}lr)eopuD$|$Oe5yR# zGp!@8_avLL!CZ*e5%m_ka_dZ2wqMojb;@0oq$AV|2<10}xt2CMXbs%Tw6EQ#*8Y7m z>vu8AA`oyj>vzz)IIlPaa%H}9Q27-q4uM>$8xC5BY88jTl`zH~(QYcJJk zA)>=0&YnA{cFBVgC`s3W=QXGUB^gFTbr-Iox)bMM3)Y-Y>xA_u)?>rycxsB7)H~0) zCsXmp4=wRWFEmNQT4@;e#Ai%vpkelScCQcDwb&+f9p}`lDCLtEoe@VY?UP)gDTe3& zuCCrYb3d=X+%(0!aMSge!POi4Fecb0^jNK0Hr`zE?bM)@%W9i+2u->Tqd0`a1o`It zf5AVrDmtm`mU3MxHT9(O4as!HlrNOU%@DO+-C^nG?8K7@v`2-rL0t#7!IGaJuuA;E zI_CHKRC&l_T1U(p_Ni6!N&bT9@Ca<%h(JlY4vY!5`S3chU&8(ab?Z8?-Jx96XBcCi zR%ehG<~em&%9hG`(wjZ4ZDYBo7MkT3hafJIgfSvoFLNyC1&@8@8SuK4$JZN9)*RzwKfD@g0weIHFRl!tVjlegNf_>;VvM z81(~H&E54nnA1uxj>LU9v^jwKw3M51D-KvzflnNq70uBZUUwGEGWg6?T%TKi(xsuG| zp1KLeA&~3b!Q<+cNO1_{>e%8-Puz;)5Xhx=T09fi6^FnSA|gKigeQJAqxF>I7i`-o zNk`xp91T+PPO9_IP&yEST#ZhiR34Q=AnfSVvDU0~-qXhxjQc@ath9EHy@br+nfvZ< zZZ~WH+0{&07pt{eY+ZVbLXSFF<8VBeaMpo7U$7q(nz+RO3nScrX)oiXXWnpbQHML7 z@XYv}>FY;Mdg|<8+A!+;*w_B#i5r|1%ejd4)=Uqd_Dy4Us)<-?XxTIw+t`S2U_e=@5Cimn2zzWd#qU(gSilG7|;LM+V1j3Z6_U1&RJ*m zPUBm15)+arG{>qq1aXO^;B;Vrg?(b><7&3NGE_Qn4^ijB9u8BEaO$S*__B;W?&)4mUSSG_ zCLMJ@T&sMd$!T9W1ab*2@o`6IJm(Ucbl?eQ@h_xFuAWz-iBgW`fo1U9KPR0xk->GB z`axB!m6lOw6EzF5 zXsIx~&E<+I$32;4cvdw3t}m450;Vz74FmTRrB+i+s^8S1a6eJPgKE1?`7S3vSO$1I z$xSzvAR>?}tPT@xR$%^MzG*r%1ahHO3=Lu9JtEv4#`>ls@J)unIq1X5ewpzVjxmvs?)6=vd@);rr^B7dhBYT-Uo9Lb)X~-VPi>Q zIb->&S1HHG+RgLkIQR79&M2Rp{rODr(zO~Etr|(Dgj3mQI?P4KcdI!A=Wgb%z3MGl z!QMjNZfmSMT?|IF{bP6GULPV*l3}Rc&7?l-j@^qR(cTW);pykg@~p1PYZ3KoctrCzKbVHc)G0TRq78rJx$7R zz1m)5pXYdI&a0t0JCzTWPa3H;^Sy4{xCv&4MUbr_KE$c1Pff$akkm+S7a zhSv?%KClFl3(>j`?A0($;So7w_9|}|oIg00pd?)f%0je`XsO;>9yZF`OSU!hVZ91f zV}k4M{(U<<{?l9@Sc1n^?eLU67ODiXY!QKy^mO2^e8rR&!1rykCL-Wh9Uzo2xxEC%o$;33NTT=rknGZha@gJYI*@vD4a88AD zEj?Cv8iutJ>#@EUjxoU;)nnyq@tQO4+~v7)JD2UsHoH8D4=}y6!+wwK6ubO1cfj2N zJo%$BG2~qXt*6FaitsxV_yx-uOH+@PoE%f=vD&73yT+?xtP9HH$TQW=S-s-yKQ>%x zj+VKx-C=uFZ|AEHw~v+0kL~==pHZbesmdb)%WdB=)mnXm*O~KsrK-6$hikFYc587z zK(AM5haP(d94#fB<=^{pl0D8_Mw>HGUn1)Pr2l^i;*wbgBiw&!e&zOq*w^Yc5fNCg zurA>k?DA9ZgR+nd(K;e+)Tf@hKXdQAd)zM1@WEVn$Id_Csn%QiD?0B|=U&ddL~@bx zM`)6%mpRpP(Ih^W2_e47kQP4q>$P#ki?{s4-~Pli2w6LmJlrD}`SjOo<5?P>v4uzA zsRil_kHGysWrl(~zc zy%`sQT;)paQF{Zt0ub4Z6F)B$E9td`Ui40gcSR=loV^F`@|5y$Ot6N!aiZFeTv&I* zBXD%aox-r|D6J-9?JHV>Zt73`HO4_K1WJ$L-b_(Xvs!S4>#|}OvlD2Nf{8a*;8{AuQ`OqSP_r1 z#0G0i%qx8-m3a9_o*mqsqRs<8Q??X?W2n3rH5p=`d-s_C_l1|UfB7s^+1ufmU<~`8 z%k-Ryl5~g$iWTk9$6-d*O4ncx~MMz+D{KXK;C$()WFE z)fj$;EV~~h0l9GIgnD!x$c6K+@Cf80dS*V~Xx0$wB<`>};o*J>Ya-TA!$7Y+=&46& zk}1~ySbnK9tJzkkCOb%9e4EWMyocs$MAY zie$U$_n`F`3KKlPAuOul?m`ta%Qs}#x)(~dZM%%4&0d5DCMr^lo zeq%a(`#`QzQ@5*KPEM1KSbQMK%y{dj*cn}VZq0dQs$*}5G)rnJ<(MN{tQeC$-^e~?p%`g9$)-P6*{U)8|=sBF_H z^qz~inDLIdx*a_{$=-G_!YM(+*zBBhk@gdRWqRVyNW0^3EoEc4yXO?((D zj7etPRIB5kf_Brcyd1-5KzUWQ(MhK?rO#(xb#uq28$9%X2mZ|T^gqWbk2{m;$@oR5 zIaWve-(~)|xs&;7>6agE-*KvS;xFdH7>13NnRV}W>yNcuGqA?!We(|3YaF+)MRa(? zfY-NM`RzGR_O95W-siJEtYfYQ>UU8Vav@qr;AntoJk!w;^hK;OqZP3KK=g2XWbCQ2 zUr{^skL`-}FM83Qn49avd-L(}wbbh)PPu-Ee&65(`*~$+a>HlQzEEXO<*e7rqfOVw zrtC>DbGl9QMf94g-db{*WBKc4zNqtyvE}ZcXwHqPwiOY`B{h-j)zj}J+3DZk5_`UK z(=3dY9uv3S=(&siEAFIYe;gixxjVArF3;|ex7ASxa$P^Nv+x~)u0w1~Pzn*PBihst z#O(co?-XHPVg3k>@`?7Ey&^M}4Ty6Ed&TZ-xnqm%&J2)qH`R`@FMd(qel63E!f`uo zlTKC)nF61sFkVPtqb2WUG2*qR{pCYdLI*ScH{kj%mrNszA?cwgrW$Z zxe}IowHEDIyNm{uTA^uVAin=b$jC{Oh+EmP`{^b7!}r_!Bip{^!?_{OSaBZ2DE`;0 zENrat$UV!5UMoFv(}}`q&cNjnTlFS8klx!_eMw(SUxX%U4K`&euj3M{a0uiQnsroc zS;Aa5sINV+b-$>tou(?wKb%)*2TfJpRlW>V=sKdo%G9aKej@i0dO8M1f8;s5pT7*5 z_mP_EGmZNiIwI6+i()b$;v)~ez~ET97_zzS^U#`%zFa?&rFK&J&Q(~1jqL+RHSCGh z+whVXVpD&8-d^xtdEfEQYKElVJCj~aIp&D66tFkhp0T;MbK4L{XQ4SA?&?KHV9gMk z(~09brW{)owxY4~w=1h-p<0yx+3m{8B7f1_w%g`i4XW1L*k@o~VIJxT9K$h35p5Wk z%*po9l?|QMU7ET<*QgrbzoA=m;xAXigUZ~-ITh+Fu_4KdSN#gch-j~dK(3oMm9C9r$>MHMC`@62#dAqIJZ! zuUncc_djI6b}rBNMe5lzVxq@>$KZ*NBBv)t&SZ^LFJoe7kx%$J*PD+isb^ zOO4qHqk-fU^^I7saJL1~EpQFk+d|bVEc1>n+;T>=t^;Ku7ov3p#tQ2z)_lV_wW*2u@#Bx$8+Mn< z!j&TS!Fs>zYVq30m6kEJ&_^=nv|-$L|75e*FO6e29qW)iAY!^z?MrU8rz=di=6}IG zQR`c#TbFV|-QUF&>NPxEFN(DRV}dbsQ$hU|rUTc`;Srcuh(Jl24rLk7^`(yWD!gY> zJS9OcMC&@Rog+Ft0^2jT^No*ZDxcn=>aIF-QvK4^pxLWFt99e$HF4(`OB4Gpm!JA8 z{dT=u&iegCWpBwOdrO3sGN&G{^Gdaon#X;xgtPocZTguPZ@TUvs3vCJE3+4-iEI8{ z)2#s!Tq_OZxgFKaOYgLEW_|Kvm2?PA2(++6|L>k=$lWmt6`IgQ_wB;>7U`S(=;X)s zuY`TDK6^bA(a$WAecgYqRr*#;Esj2V%0&akj>cMz{sq~CqcImTcX1>#jLw;sS+e>4cC%qyeb_T#&p9x9s@hGE z7Ik4xvN?77D97fR?C+6w>~SORxtwwDt!?vh)_Qdc$g$eJc8YmVmqWQH zTds_f_>x@7x1wn>jB*1eng_cK_9uTZAPQ?c+8@AMPndeIDfZe(u5OtC=j~57ToBtO zcZ@SnoHcB>XjWRY>zquYaAj_l3_f$X_EQkUs19DrqA(($}7qrv`uCh zgCC!0&Z_)^-&?-SR?I8RAL&atcinh-)n0~c0Z%UjtR;xRRU__Sp(K}|au>O<40J@V zZEJG#YO@`dyFXc@Y*=#bTi9@o6}?QSS^mH4rkE+e9dvpIOgW}jXhz_TM!CgEc_Q}< zh?a0n1JPdlTD>Io8jRbsv|#$ue^8P-;gxnyA^h{2=<5~B9D60enAB4)^(t539Rk7qem}t-P;L?L*7&GHu+A8&?HlO36_rt`9e7=2%;q% zV??xiQw_eN(T6nV2%@|;<~x=L&N6kxmR~0;yUxKSh$R~yapmx2tL>Ixw2r`YCyW)M zb%fmBpwPviOi?zs4?AN9o@^P$dw<*;JL%kHb8^+`QHMLNvCh87<*~BO8ms3jzTYjK02YMmHa)&9sRBE>`>ELZByB3u`+SKi*qWRYbhHL zr#Hv`)oG+zX4Y5P(a)~2%D>Ms!B}BzbscTDZu=~y(XI09UZnpG}5^E%hHmhy4P^;p+|+OZX3>(UX}2K)K)tnsnI?NLX} zYmjeM{UDeN(Q3y_y>B1zbh3TlI)4<#7t^6zJ-z>tyXv1xt5gt ze6{L#xnHTieYI6_oAhvZZNEMC>u$=*e|p4LwCs;^QJ+JVD>GyDL zZ(nY;vNg)|u~jQQySK>shl?SPo-Wt5b*t4GG;@VV++FfL&)GUG7xjflEF7`Yvv5SP zT-4{riN;zyuR(Np1Zv;%>q^h?^Q;eJYZx1p?a!8#+2Xs5MWlJ_xQ^BkX6+L`$Da?!WUDn~9@pc{xLm7#;fV8Hy>D{s zjGSUtDc{K_@g+Iu|6Z-EO&5%CkS1M6df(iTD{Nhdiy==OaUii6#2PUt=vgMJ5`5>MRoEVorxo`O!yn z9V^XL9>{gAo?AA$j-wND3->ZGO_=K%!p2z`u1v$v6LFo1bu4VXQWp4=)ELEif~PXH z{*c=#daQ7#h`8jY6QXqlmcf(dlPtO;N*v*J)Sr>0Y%FoCFoyp>1ahH1Emmgo_xb81 zhR-=?e4elTM=_mZ1e7nj&qADxBG=ov(I>SO-ed5d$IuY9F6E066Ls9r;LzSlL$pafS62>oN8*TFPy#afm=kI>Ntfq+<(( zLKlu0<=8SI8qxIs^7ERNHm(5+p(!4@YcjIpT90_k6K?wJHKU)+wYL1qJ?GflbJV(@ zQ+sh_DiV7 zjrXtg49(#^-6mz$cqY9m`@1g2Yo3Y9RWH*yck6w%Rrw|381C$ltNd_txXVvEI=0BQ zYG&}4owK%D6>nq&O41Qnf+!c$q$9ATGGEEDvih-|<@T@TfX(G!?@P9KcV6S{M&alz zG|80G zW*8lgl<{}SOmfaS%LS%c)!T@*o@vV0FYi)FIK@`ivA(7|$xx7Z|)sF-3 zG@pO4i?g37d($LGXezbe{z+4IE+tm37Oz>mPmYy#nP){fGU(&Su)cY*yJLqtDaUkl zpO9i*Kf-b)oNLL@+oH^YhuOL@+D{Xj;vklF8S$&SQ>6ZL($QjOnyNo6S7<_nPY1>q z$3Yx-4Ws3{Rk0cKMhBH(eEc*uzYZ=<(kVaX5dq3zK*U^i&yvfg)YLhiS?N+9*3Gm0 zPd_)#(Sh~_{nhU)T)jxR$dWqHnV7p?8FP+oZ5*t=q+rQ>o z67%Y6EB=1&yM*R?g;r0+x+!TCD``YaI3p;8f3!2&oE-$4ktABXGWhauKcTKm>B>bGS)8N5r0UFXwrc;@6?{&xur3QQM}iOvd|iygcBNn%3A0vS=Pxko=4|oW?S`l zaxI~7p>+gurDbGW8)p=Uz&3$$5v}V$1aj?Nk?r^*0YOb*)~uTDNoZJ_wa~I zIa$`~s=-`{*3)r$O_tTRc`yPc>4??uW~tj>!3dP3BQWI{ca)?d)LXJd$3q^4;(Ksv z!Q}r3xrz4L%}ym2uDr0Fqa;LmwU>Ixg=igt;{$CBu_}Y(8Pgj zYC+In>fzj-0-Pt}{LwI;$QWf#dtsxKSCT@)3r+gg?pR}Wf0RdR9U)(eav_?e$xkoA z7BlC0CSKsu#2itxMsJ$`;o&^%%Q`hpt(UpfWL-n@gbpeXn?^PA$VOZ^!r|TkeUqPh zy$rhs<6{#X3-k09B?&kECCjSrYyxMKIQ!5M$c5_xoTKUp%%1~)zV6BSjq|E%qj~DI zPi8WiSCY-QULMnuu(;!et9uDY`9yow%VJj+mI3NUl-I1*`*lz9de)9;jG>0GuOAuY zSX`vgdVS*^>sdtiAN0JH5^|@g=BUlq+?RQD?*HCqYvbdGon1WfE5l{}ch7k9=$O+^ z-4#C{^hIdWG4X|}*6}$qBSQp!356gu{?z&$_IGgKXYGzH%KD9rg9A&BHNWfgrW32q z-&C=de!zK!`h;fr#UY4GBr#&o%kSl`s>eGM9b2S%hS%j8YsHi_&mRx)xxCOp?L*t~ z!gW8cqm{qyX&3zKKYQMp^=&MjS~ypi_DC_2aMZ#5RYJ^NmYffzzF5(riA%x}L6XRd z-~!Jg^>&`V%ni)*EO?CR0TFL|#9tM0%Fk==?C^%CZx=3G+V{DBglT;=5Ps5u`#y*c zi?C_*l2I5(=i%oQ9glo0z031W%+7Bs@L$f>SOu>nILETq21P?!R8`j_}Z&vC4BZpURbQkmz{3V&3*gW9+MW^_^IW zc8Yuax0NmNmn-3}bf(nZ!JJivw-c~d;(0xyyyoFftCXEoPA#ItBTz2Z@bC!a!rKBm zV)C(Rv74jEJ1G|~iYwlLkno_oYqy>?+9E%3guoodoThK`)5`-9VQ*~NSmUrJ>WI&8 zT^*ZJk4H{C+mbVLio1k!I{I9iZ2wj1UO&aHpYJYkT8Ts1T9vX0f}8me~H_)WEw zSCYGgko+N9!dV9*gp0l)mxL2qS%S{(We;ju#?gV=7rk?rb@4WiiO`IAI`$TS+hx4# ze7Z}DmGKMD8H6S-eV=;kf<*h{2}}IXeg0}RraY8B))Cn#*&fzyRm`04w?xtT`T4CJ z6Ff7o`(cv0(I|N}@u{cG$|K7<>A;==_je_n^NKamzv^xH!HmLvb?lvwt;$fVB~G7V z4142FyXVckpNM%PG|82ofpvsKAeYc?e3P?oo~>BIG98t09o9dg>_0+tGg`kgIB=QI1EY z|3)C!#X}<yxk_vp;dr?EZv=9s-!sCo zfcxJFaM!)W9UtkqMpTiw~Ww0F>{-yu^`%SGqqEq8r*g)38WPU2LWFS97vz#~?h`+p{-RxHW8a=f8W@Z%nWY!Vb4iFt4G56(+!W(qRb^XW(YL+RhB3FyD2n1iyM-fVL z`6(TluVg5%>?{kp5Up&Oj+ty%-doSV``@EcSc1K>D_bWf@+=ceQ`2GNT&?N{W39|M zo(JLF&M=<(JIVgKhvCeYP_EDS$TBWeb0NycpFdFH56wF=e*jUsqPb1?q+Yf(;J2L2Q4&VeN(fEVb}aS zvimtD_~e|sEb`+`a-|HAW*x;Lh)W~|mmroDmLTpk8OF|UyW0D&+-|om*KF(Ety8U| zSJ=vbvwKsm7Q6YD&9!=|)|JB?PO+4qJ{nBC@R54K!7B*7&tw?;_jR;Y{G60uTX&Z_ zujQ0W+)19$oQ~oU#3hm#@rK#T-gZkpb3|%<6uzjql(X8(-^2IY@vK-`rg(Oe{lF1V z?1B>CMq%p|xfH9mTkf*j?~`8i!nD!$x4*4&_TkVDbtwDP94nH+Kd*`TgX19fuEI@! zRr|R1k)^yWI%6&7C`!^0cElG6jz@V4#ZiVvAL_@Dn`p0%;}UWq8c~c8S=ei%ZXJPr zIC3FcN8nvoMB~VyBV;#%LU9BLk3b#Bg=poQBY>qpWpxvLf*pKg`>oNG+wr#Gk@ z-=Xe0kF5BbXKo3ez2IGgYwKot!~+M#SboaOG2B8x1WHn^<=ZKC_YZTObV!S$_zF$( zFb~zZ9a+^L_C^Q0@v{q}%-^S}8Ch_TgFTU9JRE1N})ni42hnxAVgl->UfYsy`J~zU13d5NFWp{gSgWK#|KbTQCeo##CM)b}OAE~oR(J|wjWV7|^ z-A`4j-gOPkka)-OJ#b zD7BJeSg&uYRnFq`Pe$>-wBPQ|MR9$G>oy&MUvTz{k~D<6b??5~>Hg?*R{h7hSHrSV zCk@}2X0@^P%sPJ$jaoZ>jg`5J(}6EF40 zpCog@t%lPxV7?1Yh%yh|Y?0?1Oasw$>poq*kKvYDuEYj)gNje$umvNE=DR~3ABPl* zxv+P|3y!}-xYB=c=n$Y-Do_kYYbbS3%DCREa zy7E(9vcLV~-ali1Dm8tp+%O{q&Ma`oLEqfor_nPNR5`QdAEL!KwS-d)4dbJzn)dkP zZEeDeJplS5Fa41w9{hRjt5b$K{^2PUce!x)OGoVQILz@=T@-=nB#Z8C;}zv2s|gpbf6@4v%lUqF%PO&jf9m~jg&w5R$szd{@p)XcE4Zi*f&HS zi^7ow=f2}(Q#VwU+@eV8DtfS6970vz&yP4TfZt-Cq#TVjN7%fIXu&%}DgA&_fe^i0o%`-?*$SNE)$o<`M* zLm=0zVXt~pjN%ZfKfLPMbdqz|*KU@&@yYbU5z`BOB zNk`x;47m`kA+#QDOX4ucgE?_vZ>J-W3wK*|#HJ06?0=g@*@+Ll5iNZb$w3Pw*pC^; zi)U-vjk~rr|GnitAFkE#ReaH2RAS~evDC0^!q;{zDda-5^7T98pufteFPM2V*8A|S zKE9B~7)p%juh*Wj;CVHBJ>rb?`FjRAcTMQa_T|r8-LsB{m;qexnQqN`h-U!k3wdmh zbgOnB4mXTveed~qv>0t3Y&bOvV}64+;@*lQExlB zR}-3IXc&w7S$6cI`|b3v-t*xL;sLKGSu^hB_Mw+xsP{M|Nlq}R4;Grj@#ocU@8FG{ zLn{Y4zEhE#Xs?aAh+K$96wO6H2)So@ertMRxQ>W%M@%{z+bWR+SuI`RXc_fXl zZr>0W=Zwy{FM_KVp;`W&PxZ1NKgH+HXfa3H3gwlA6RpL{UfcE=tMf{pK`wfyw-uSm zG=-OVQLG3q>r=~r!?wvztcJ{+rd}CxIk)aGP1z^r@|SRwPqf!O|M#mN-_5}%KgmO1 z^Bgj+FCqQfI_m4aNTPuGEay14g_`V2$OVr(^=ab~Y#C5iG%Xp$*;$nwJ> zkW1*GdZnG1;2F#QeIHmYZkF1P?{P5Sg(kW9Hiu&7&UeuY6sAMy;8Xh!kBUK1^`p%oys%BCami@v`fuFRKy@IhfujX8?Ds=5vw4G5;FhndDXHL?C}pLZRmhg?O_3xUa{3aS-3d)Gsb4z98aqGKInMDl&VRoGnqu`^(rT+- zUwI3F2>DW!3(;Dv?3tc^mfR6O^HM)`^N3r~$T#{ZZ*V6AcU+v?Ku0Wkr;l3epA0-T z_<{Avg=ihox5Bf^TUKzZ4Ud?y;91LmIv9bHbRF0OVC_RmIs(&yF+sGBz_x~5*ej`> zyJ}CFJ9d?|Fa32k>e#Ar%1RoqYjN*gw<6f{^@(<)hXy+PqF92mUskjGO8cKr`fw(Gxs73tqyj@hTuUVDz?$h_yQ=dCbk+QzHT;x%{M~S-+Q+Uu}+6wcf4r zdu3N~+Iid=uP3H#sQvbH2UFB;L@)v+={g#noZ>m%g=L{!jA3{La$)+^TY^`*mckt*lL_&iC)TZ>&~# zNeSuWrmN$6BC3H}~YhJj2lE?a*Q;ws4ugEE$ zU%Jb#L1}Y}`CR*-Vm8+2I-Ssjz?~^Qug)DD z<>@<@PeU09l3R1wnMV{{hbS)*%IcD&=dRi3>y zxz);x2uuUfUi;j^zShZs%;jI!SM3xs-QwnFtWqAP5k)slU9ODLeG7fQghwEkzj|Nm zx1?fq09UWwJ!AE(9n6JjJstQ3dpMM&Bd||IE=22yQaALm$~WLxVb2*J zv43B0b>Al#fs%9`f2{4TW|r=bpzIW)Bpp$74^a>(DHyT89Pc#Zt|LlPx2c|}ZPu#M z#_4yHADpggHTUi4QANU0KGAA)hFz5de@<1m9=QL&y&6P$P2@@(;`Pj5SR6v{m#!AB zQV^}{z#b0!d2I9QJ!moSaY$y^*8?`qSZnv;St{jdlFgPdt{>@bZTp?;f*vaz?{E~w zGX!-jeL}L`dt-{TpC}_I$r(RogH`fQK93NZ`}X1x#3hm#F}~ZX+;)xFUWV8dKROC&Mkl~zk*uZ&9!YJ=GB^pqn)zEDhrCb@{FZ}L-b$s*RPy(J#K zaF^@JIqR(6X`xmuSVu8`P?BLZvPam5c4ldLMKM9Nq?}`e2;rhH$R**NSCR@E2XUo{ zI|#V46n@^_#=4Z!YGz@*lJFoMX5A0hD$AO@R>R#v+%*&(^w(=*E@JK?T0_{s{nN*) z{7!IRf_;pRpdP2mF79Qp$HAV+Fb;q3kL^EmxKpnZVs5hryw0<}(+47~_|;5HILarQ ztS5bEN1u+)r#zYIdA(IcuXA}sQ+WLu-#WDNkX3r7zxtYS=KXh;Xel;+i3|G=>{n2a z*GwDrqg}PpQAgIbb@N(9W*#Zfam6m*U{8S#7vnYM)eh;vG6?^=26K1z+kMro;^3Ti zHBh}8@9%9bxRG}wFh>!sBkFzC+q!Z)BM==HVgB*Jo2}yGIfa)dzun4g7APj=O`G15 zSFXfXhpSwP8}s`4@*Nt{TC9{0H}I~VBPY`}m5~atHo=Ue6nGl zuU)#MIi@9WV_SmMb21D7F6YRztEKO`ZIDIn><9j8mN7s zjlP^Q^VRFBPn>ZO&)C#z`}Xnn-W#U-bJmxP?lm!|&{_}5y*eews{FL9KV2;}zlzP< z_WN2VwU`TfIuL;+xM|6|t*-Ro8WSF|;ISTgyT`3|bf7*Rfq5lyC%uwaL`yj5RmHla z>`PC5=%4WXlRms1EBC1hq2H9f@YQJh-S1X7<435x3f8fx#xwQ@{}}emS03_3j>)ms zw&h$zblBXre@p6Sk-ueiav@qr3~kxX zBCpIv5hzJRn0T{BZcS2-;w=&#fp^l-cS(2z-g`tHs9V>8_f}9Y>eCT&e~;ph=>H!A zxpXfvWjr12Ha)61qZ-y9e9a>?wpzXoNcDBVL7qovDhqEyOL#CsxabRVNjRpOXs;=D zT?v6>l-#UA9gJAnCN{50ncmJ=>uc9DuU_9ih2w5a+1rEnGHk3@liuuUZ5zvPpRrbA z%)BO!kQgg@uNoXH9BXkNglJs{%0e#85gj33xJWzpqS(6{#*u5ucIjjH=KTHei6|zk+7Z3l6_$<6;dKPbm0kvQAX-P@+5q(-nh@>{ zY_U5>p+ZxR5?xW*ccO6m=?MB#lnc>};eW|Sin}+Bd)9Iu$tCDwlwW{?Gi>^Tr_(6O z<)_%8T(oV6l5_-~fFc*7bp(FF(HSM_2prFm3(-0P=L|TW>xe4tzKe~lJzm6@xr==X_JiRO*o&r(YM|B$y$Wl))M;Hu{+DoigHiLpW2Fq zbBh|G&WY|&=SAKoGo$1t7^M#N={h*=+}35MfMf|x-}QIfh>$Nuxe(3#1t?`P%bn5V zY-z@w-7R|GfZRlT?Yd36TXov<7vw@TqLgK$f*o2)2fpo;okGs5njN;rJo_WRe8IfJ z{E=`*xLTCGCAedb`(n7C?DA8+!reMV;4OCj9lgArC+(8cL`yi!?>cR=*{s(CvGjkZ zD@?cMf59vF*0)TzF6HQSP&#bPMXaxw>smUr831x2+Aw1OOg7^#q_!N@xoafOchKr3 z&IWZI{m*r?e*1^ZeEaeq>TcZ0z~v9T>9gPNX36a&4WVp*ewAM_g?AWcE?#dn+>%bPQr-MpPaFnA*ALTugqPa`q!Y6-~pN5}L=gXc9 z#sp(1-1OILHf?$)Uv>%*ts`owcYP`Gt?ie(xANgzUA#Gh_ltBLC~IiTx`B!*Wu3kj zbt~`N{}|@t8qb*1PW=#xeH4y3Xd6PEjI{qL_g>ZekT&U;kf!qZ{(mOMWJ^EtjG8k zUDrV|q1{1}J6`S9`JZL`qNq=;QrEV!v*$lxRvnxcg|`KI-ioQ5y+jDC`6Nkds9`j( zU(NofVtc1gl(CNxLX#}9??be*rr4l`|Laqe?7uekS9T2|tceA-+d8(0P+psO6}Iv5 z2wm7K#-we-gJls?nQsvVK9KLQ@R! zEjoRZpVuC^wY^1NZYkuW+(FHQ`7VWU(_gQRTy2}Tx5$?*eF=|1uHApM zv&4dOcm#4?Sl!N&+dJVA$W^azJBzH|{&%d9t5U;u>TV^cHoOkxs$aUDMIM3wTL*Hb z{t#!87vKLzbbqvib04lK7ozE#{JiRZqKiPTxHECiEluH+zg`pTPm&!G=;xc^)T^^iXrEQ%)Zxw~&Zq(wjZ3 zqJCu@5HQ5`{Mh@V3D4x~a;;uB?Qczc)AL zoqqg|UOa7(Eb+8~G^e9D1aXNZMwD(j#cp-aWv6e)`RBqB6_j;!ZmAN^2u=zM z^W`p1*wutuJNK~4q;p%tcDJ`?N2k?N4AmQxl!@l@3WJ)|MzXmA35ih(2Tfk z+_u>G#lwP9j&Z*_Wwyu2lRFc2?w@Qw{YztKH;PWGlGk%>z*-{Vobr2Ls%UolwVR#z z?i8-C~j4i-#TyThI(grbB4r!8FnTyR#+C z6FlLQaN;(M{JWCOyq$OZ$8~P6y(PQ6W}J0-4W~)`5O6veNB?^5eo-Bie>L9my56&c zCFhZoDz&r753|e5l-I^Dk&kq+$a6D&LrIA8+MBAyS!8dXLeoZdD6~S0BZH2(I^}VT zEZ0*gqQfKpSlglSU0VNh9jx*4MzP=yEX#l7jw$BL4_$S72F#OCdBuo-Z)jsqo>)7m z1ko2EmZo7KLbym4a!I(9hhF9fT5$Woa>nxKnDM{rrqg$k0p-6vXs1B0?Jh>yb?(_X zFy4RGL16!Z?Z)M&7FBd4ll~EVB^`mIC60Y4Nk?E`i#=R;9hh=VQ}}c|S^lh=bMn1D z+@HZ*&|`%$L9~vDyZoX#=z|^puX192_?{DA>R}x-jAwdvv8UF#(|j}Sgm&i+M@xV8 zRApCD?mE9WaaWABoo`^v9stD^?~6(}*Q-5E2b<;--pA*Ts9Q($i^{ip zW%C+vV!eQ4EtYbZ!k_HS;Tpomz83pL>|Hg4S*=RIIvHY}iR({wQ*LsCz4)hfPMM1r zC6XgF#R@&y={m|jH8CK2eN=)%pSfB)aJ=&!&M2xO?DYw4Eb?zap?dG!M!gY`@gwcQ z7wm|>hy9Hod?mVj);v%C9`=_gu_AQ%SYgUBwR-NRO@7{vRhO#s+bxL1RUFS&J8%PaD+pB$`ir&&F$G$n%W0`Jm|x`!u&xGa1tZ> z>ov>0JSiYMzgU``zo{GO`0-r@B{+6Rg#BX89`?%#4@ISon&%ns=kh>(VRhI`e`=#% zhH*Luyxz9ZUc8TJXZDXseah z?52Wp7i%KcK1AyX>>12v`O3O2_Xen2MC+A5x8(9zMO0?ZBsc4^CVOZgLIf!23Y=>BZyMAyrC{(AN(Kn3dU6A!d_d~ zkq}cYF#oRGoIV^!WE`n=1lCcEJElqbaBK5*?y0eSMlY{~NDkiEk#H(a!&o!_9`nf= z?d^T5Hg3gIllMx*6}|+~o(Ps2mZp{t8^7SYGL)nt%!ppOYLATjD6H-0C*`UgNDkK! zcEyxFR_9u$0*9Bzy^oe#V#@YW7A1KvQ~9pBy}JGQ{5bR4v8hr1Wl74PD9bvTmSoNQ zk!zyl70M^tYo0!^)$u<_`O|jX*1~yW#}>KD9-+jlQFb?b#HSUV_JMo%LX#}q>sMa< zI=yJ`_+WQzr#*2#^l^u=!q}1o`FWKW3D;T=rhL+yeXOpxO3Hj-EL;4UV6Shm#+kihYK5kJlYMcPj|llvlnc>%tm@B*3sgSd zFKDKJ^1il#A@iPd!gWOax9tNlWuFN`pd=lED5fs=5K(X)-l*f-km#6Gylkm-!rl@r| z+kq3?eH1P<>8AgepV!3G1w6sPa|+?6zv?z_!}5Vy!}6SZHDf_UVExaZJG8z-Pn?32 zGa>qaIZ+Zi^ciHmzByLncYHSn%SJB`kx#nt#1&6_ad*e%Cj_PtPZv>=j=;RaTnI|J z(;pI6W|Kn8Y*NCxJic#R$zJh%iUg?BUcD^uZ!Dap~;?5%Q%d7othO z{JeJiay``=kjIIQkM}GbEyJ$7Y#dXu$3e8nr@vm!Lj}qWi!c+{<`#M>Ov}i%X1`r9 zvxHWl=`fKCZ}MRI>xg=bb1mO)mW%qrBaTj>aj+mJfDVtq8*RviF*J-v&Q`StM0Bv@ z>n``edlgdNfw^ zkM%*ZLP_pq96oH(h)oNJ{;(I z_N^cUO41P%FEmv;UJpW`BpuPQMKiTq`&tkJCFzI;o0pzaIuzNp-3Cy5fy<*|8XXS-VU%J%K`%lc$xLR^?vn1}RDeqI}2L15f5 zP0GfnH_iX>aP}^rb!wV5Fq-c-mkUg@s<&ZU!Z}u~foGQ$ZyZzj@|rxx$SZ6KXT-3- z-uEy5WR#O~@iR$M&`+_1Gs1eazg_;5-~Au{`b8FwQCQn?e_v8ff7Q7&+-JZ$40_64 z4DA%TFGG+E(Jnvfz%O`bLLZ}C43&p_){JN!VLkezf7r#{29t}dEBpuPu_ekK(ONW9GC`m^c-ll${i-dhu!amBR_nI6-oH zK0n$SOOWQeTO5M8L=q$1czI21Yt#mF#_&3NVDwg}tpp=Fuk=|L=kZK`|3079=ozLD z&(3nZb_FA%Pge?9{VzIsf+Jbcm9WH1?I*(6hg^tOqx0>XVxKBG$|jto5NV3Zq&E{_ zR1*!D4%GriS?D}2Ah6%N^}0g&V@9hbOdrm^y(P6G?~%i5y+K(Py0ah>B}Kn z6vhgj>12`Vg%nurI+? zn2s1faZhY!%@^%C-Lj)%UQMu!Ry>kn&DU$Yvj1SynG|W0`ieBwG5uwj+Mzeq4!whr zlUzawO$a$xrEoO|1cZDk%7tj<6*2QUbJIIN`(N5{^VWI86ADLqOp~4t)K3l>W686F25y;iHc}wMCSNxc~ zFnOYRdHNv#mQP-f!W_jqrqAJi4Mdrr+}X&f?NWD1v(S`U{T@Kdl1XN*EiIireUU|6 zLK7PG(f`X&-Le2X{n4jqdycJQ3y<4tw9LUP^JtPMbWnNN7rVt+^WNu? z3`aQ3X^9d2^_r+1W3~8`NAuV2xVs=$WVuyYaCOFyuD4cH`?$QqmYFxn5;`bWX3bHX z9Uph}1?$-VuU;V+)-l6qIwaY?r|T?drcbLlv85+8={xaPVrW}>cAe=l>U5Ix3iB;| zULE+eS$?I4oF|w+Wsj}$?BBPqAg?a{GgiGCo9*9RJuj-+sl>vzjWJ35E)lHnVLF71 za#Yemw1fxeE}m5Oi^{b+w&1eCvni4wKlL65xNzrRN1$cAQ0X9fVPj=uE=He@wPvMr zPB%IkW2G-qiG_Cj4CAMg&20K_Q0CyMgxo~shnL4%MC%AQCF;KDjMd5l8`qN6 z?=Ej8HRkAqruk{3?kcDyh`{=T=qg0&JQxf-aQ*FMoEzi)-Aj&}6sfxVKBs6V4~ zey_-21WF6H_nTecww!)<<@Q^w>f5=b*0#OHIzET%u7q=KFYZ+% z$;B6rtcU;gnz%}-`jgK(RI9iZ3$BE4U4v*{N8butm3>hj?+_gxF|nT79UL5tKuNj| zT$7;=lw=rvUfJ%i-*A}yYWkF@*>5Knt_P~8CR*#8a6QI4t?RftrD6W!*1YRDWZpbw zA(m;hK%^rQhcwBb`c`G9USX|lyXEaxhgNd9ju`K6l>g+MI~~MK&zr5LzQr`^R^|D? zP3DTU-cBt!+^L+k@RD9LNTyy(TrFM`&pEZTDeaVOS)1*-)&X8^Yp>6~nO5lc^H2fJ z(wp8;+Wu+k)Ar~g-}*2Yu{Xe6cjH8D5Kq8yRg7y~Wj(3Vz5YIz*gxl?T8V{oQK3mD zmZo9!_;Oq9h8e@0yjpmvoSLigZY}1Ugma5};+Mw$8tVO0t%hxVC=25*`hrKCv+pfZ zWzMChdgsF3^R+wP$#=^<@bsYAa>)~eVudjoJO5VI9>qqA-Q%b~F>8pq{)53jyuWAF zQ2n`@aZt)Pmd>{h{3(4qh47E8q`BHLR^$B(^Uu5_{qDPsKghZ9XTH}bDI^I(Q)*?m zg;O35fm}itM5geM*E}>JTiw{=9;fm<*~-fp)2TmXS?Uf&h|wVa{%kAm41Ym%ctp&e zEXR*K#TU6yUtyFKq5R`OO#G+a7@{N{k(QC=cz!1x$c1Pfk-j*~@%&B*M2AP%cEt5`EUdt*iqj=?LV){W2Xfe8>gAef)W| zO;Wk7Si`aAK!t%kI|#e!GAIQOBLi1wnfa(5<1=TEX5ei-S*3S)w^ z7YPrFl{sYItdKJ@oT(wot2{zm&YNlqJyzSa+LzP` zW}|O%38#G15yC}ZkW0cbMnuy|L$+nr;9ei^*;VW9Q@4|3G}z^N%s;2oB&W8^=^Np9 zk6Y@I_a+j~v1++)RcyvQJ|B#apJuHrI1@Y{Jx#5O_ zXQ^&)fU&Cgl}D{P#U6l*QE!(5KGkRN>=kP`)_j+r%6#!BS;{h6aQi6xRCb{?OpcA< zsP@UeDAq&?7e05bCS3Fdxg)vc?k026=l$q${3e|0(WgGuh+yo=8dXtwGI{Bo5wrpE0{DKJD(UCjpIs&<{R)$Al9Yrog zE8mj)Uoy9L?&Rz`W4;Sbr6zuu3ra8y0=b07G!X4IfBEcTb#_z9Y3G;gwhWYfl4tjN zJ9lwjGydCp>P=Jy2Z3lEq5SFPl3zVS$|yteZM$WO<13!Rh30fn2>-lx<>MaZor`+_ ztl?pM1`UC&3(+E<{(7}O5#?e}q$AXMzw+bG`)!$VafPG4j_5w_O3V*c`K^qMkR(TF zO07P#M1*`P%7tjdm^`|#ZQobI{;uQdtu1C&Q2TAcZ35eho{mXxMp<3=^X#SBsYe}8 zh{WNq9;NKY$teKd-od$n+(;pWgcFTF@}W>OFxM*M)Do;QdM$A=`aLp4hefC|ch8zT z4`^wn*5|nErx`chJJQbna*H!h43$@u(|TWm)?wreafwA3inWBhDPT(N4)*!jGQ2!S zVVj@v`Nzsb35P3xXS)J17eD7(B0Vxm5SsK!`(Vk4kS|5K5Us3PEof>#H>$ba`n68J z6eC5wKR*zta{s&OaWzw{MaOv#H}wwkY|p3fq{kiALfsxG4hbh3e`-$#Qiv(v-!V&h z4VJm6i_u4Cq$PP=jFW(4k8yn%&%GMpc z0e6SR#duBpQmNrvfwS*%|LD8ft8T-{?gt?3>Zy@d&sCoWzB#$$A#71eW~8#p_Gw`D zhYKEZ`6-w8?~AmyFAr{mC`m_P>qH$WNk_z#jZ~K7SO>-g(P}OVuWPVu<#i3olQ17J0-5fY7Yti@lZXx4L#UBOd6m6;Isp zlpXD|YAJVKfV+rb0x7EM4>~lBVZ%(YdJreDEpxuwvRa4bFolrC6xHC!0f>9NxhGWWW zpO~y{Q%N|r3&}Bg8AS2LxJ$V3$zQLHanHK9g_@CvN(btY)=4sm-Z_i+03@9AYW#qs zxt-tTJ>H_jH#OW#gOsN8*zo%`7vmrH!}`PfE^oEX)()+6!B(6fcj#>MXTt8MD-9 zy__7D)ep%Knh;XkSvDf%OHnRF8^++(Rs0*0UBHkR6xbDCSe zo4J?JYod$sn)8NdDvJ)Ba?FMH<+fN^r+5#@<)^ZzyGm}pyXDb#OPj!e*WH>ZD6fh0 zPvp{P0OJXhL5=oIVP1uiKpH3{dGBSN_73vx-g#76#lZOor?C7N3;+J(xi zaT|{Nr*9c-&s#gehpTuj8+}DD8fY{?xy|}DvzC4kN=M6YCYt+``#Eca*0UCP7Cp^l zpU{-10TFL|s{X{`hJgtAQj`nPZk(tD^_Jt7pkA^r#;dhKy~lCUEyaQeV4PLGO`4CCbH8|_ik`q-mZ zHQ)MV`Bb%M$u&`|ERsw-3sZNzDmJmpee`_EHvMq!@dnIPsNNpQy~)J`;7p6)#`RPTd!4bUbB+EE}K@>6e2CTCie z3Qk5Bj>rsY8$h_}uh-mka-n+XwaKx)f%-z};IxQsRKm-BD5SYO*3@3-U%foR{$^u( z)XB2Cre+qt?ZGrvy0k^z@sjn2@KZW|>7HrryoJADKOeqt z$2)0wk0?9>M@ZztvBZrN#R|vQm8qE)`46L9KuJ0RQ;1xM))Dvx^9?2G2wdGEm%d_g zF=XZ6wT+7iEE_de-`L!I_Qpr-wZH6$MjIK1_cp87MDEYS+RHE(PxA)EY8~2ML7OaY zoG5o^rF#QnAr#T!5%^_)$JYauHs0af{zXZ;4s3V2RUNc6)u4VUyI!f@cJ1FwN8@S~ zSE({mv;4oW_psxpR&aWs!<|-n!~(m}R7>QXDi|SOigF=ZONWi?CEPo?a`;hed~Aq$ zqI!ws?yy*2M=2X1Jf4R~^#0$YR`pbtgKAi>4?j9BUMa-xe%=* z@XLVLqpb_^j7CX1qGm=*_1b~QsF|K9N0_+q_AjIOU#}_sIn^sXcgFUw zBk=4F&m+PjR84$Dy`$zbNV{CeYA~8}TAu;bTdBM#Rb$Kbm?uJ03{PagYE^3Zxs!)# zbPj_+E}=OMcsm+bze1Ca@Oee^E3ul4{Rj3dT3*=$qaU%#K2=b!^6Of&hH;I#H2D!_ z!V=(42-}w~R8cw%(|<5BgGS>MSem z@*!?3&~6L^?`&h;6`Ig;f17Cfxx0ghXV0|CS!{V!XyPEc$t$uF-uTTO_R3ef*cJDs z`6RwnGcZj!!yyUsQ>%&F=BgKYb)8WLYfRW$Vj>r!SNE9{m{h5Gh<7YoKAIgkI^l^R z9pMq{Kb#$?KVwu70wo#7!(EQY{=9dv{lMkM(P)45{NE9le;JSGTCFVhmIdXDj9r{Z zFTF9ve$e>BxoaTzLMeCg&aQ+rBGjD;`hs^Rgk}WI6@=P3Yr3`Q9d36*lRlz5ucS2P zsa?*)r5}wwcx00OaHFzYG3BxwLDDc64CCd;?zC4}UCf(X_Kd`xt@`R^SjRuP&G)U4 zYOViSEZyRL20UGo^9Uyw;BEuybn7mjnPdJ)IC0}od9YuwM9p^+oH8GOeMw+oG~1U= zeQ}Ah{FfA>UPX>sqAanEbG{%tJYqr05>-cEb`Z#g`t(>G&0383geM2APrU$``| zyb4>bO7FW&*~xvnK;H`WCU(9<>pI$wTczypE(qd6w2oNVaCu<(dG>#d=^Z{BzvwI2#d+J45W_b%WPgTaww}e|RF8FpaM2g! zl5mU>(aP@NfDFeE5+PE5$Z&iu5#6^-hO#v)J++G?3$YoFuce|0l;rZ0j^1e*jz6cO z2$ZBF7JriAc!Mg6KuJ2H;*B3V{)z~Z{?!MLhohos9f2vtl&3XzW2@e9RW9j&@vHx1 z>`LHluD;^Ms8B3aE$uhRFW*LJ?jPZUm zPm(Q3k}XM+k|YT+{`cH7=e_UG%W_wJKuBeIrskV@2=;LH~SY~5{^4!C7Kz@ z#QvONOdM3%esfbRv&IXVJFvaNHVS9%RjkRagU#WeZ`EZ!;^7ij+I22-IsZdCgeIE| zqyCU1yWU#M>w~Brb0st*o~V}J-oBc5d7rN~)VJ$FXwor%&=BAL8N*O3GB&Nu(325I zC#e(C+)ogi(i8VgvU+vkbozh!#hUZ$Zd7w*v$G

e)K=btu#P(80~#B6-(mr8h70 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 A@&Et; literal 0 HcmV?d00001 From fc25a0766654fb021821856fe74ffa8a09026b88 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 16 Jan 2020 16:11:04 +0100 Subject: [PATCH 53/68] Update platform offset for 3dtech --- resources/definitions/3dtech_semi_professional.def.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/definitions/3dtech_semi_professional.def.json b/resources/definitions/3dtech_semi_professional.def.json index fe466192df..df4479befb 100644 --- a/resources/definitions/3dtech_semi_professional.def.json +++ b/resources/definitions/3dtech_semi_professional.def.json @@ -7,7 +7,8 @@ "author": "3DTech", "manufacturer": "3DTech", "file_formats": "text/x-gcode", - "platform": "3dtech_semi_professional_platform.stl", + "platform": "3dtech_semi_professional_platform.stl", + "platform_offset": [0, -2.5, 0 ], "machine_extruder_trains": { "0": "3dtech_semi_professional_extruder_0" From 1814b67b67ca646f0a72c42ed00ed51a94be22bf Mon Sep 17 00:00:00 2001 From: MaukCC Date: Thu, 16 Jan 2020 16:13:27 +0100 Subject: [PATCH 54/68] HMS434 update (#6826) Material exclusion update --- resources/definitions/hms434.def.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/definitions/hms434.def.json b/resources/definitions/hms434.def.json index be78df9daa..e5f32283d8 100644 --- a/resources/definitions/hms434.def.json +++ b/resources/definitions/hms434.def.json @@ -12,7 +12,7 @@ "exclude_materials": [ "chromatik_pla", "dsm_arnitel2045_175", "dsm_novamid1070_175", - "emotiontech_abs", "emotiontech_petg", "emotiontech_pla", "emotiontech_pva-m", "emotiontech_pva-oks", "emotiontech_pva-s", "emotiontech_tpu98a", + "emotiontech_abs", "emotiontech_asax", "emotiontech_hips", "emotiontech_petg", "emotiontech_pla", "emotiontech_pva-m", "emotiontech_pva-oks", "emotiontech_pva-s", "emotiontech_tpu98a", "fabtotum_abs", "fabtotum_nylon", "fabtotum_pla", "fabtotum_tpu", "fiberlogy_hd_pla", "filo3d_pla", "filo3d_pla_green", "filo3d_pla_red", @@ -67,7 +67,7 @@ "material_print_temp_wait": {"default_value": false }, "material_bed_temp_wait": {"default_value": false }, "machine_max_feedrate_z": {"default_value": 10 }, - "machine_acceleration": {"default_value": 500 }, + "machine_acceleration": {"default_value": 180 }, "machine_start_gcode": {"default_value": "\n;Neither Hybrid AM Systems nor any of Hybrid AM Systems representatives has any liabilities or gives any warranties on this .gcode file, or on any or all objects made with this .gcode file.\n\nM140 S{material_bed_temperature_layer_0}\n\nM117 Homing Y ......\nG28 Y\nM117 Homing X ......\nG28 X\nM117 Homing Z ......\nG28 Z F100\n\nG1 Z10 F900\nG1 X-30 Y100 F12000\n\nM190 S{material_bed_temperature_layer_0}\nM117 HMS434 Printing ...\n\n" }, "machine_end_gcode": {"default_value": "" }, From 4b60da6802b7eab64ec26fcb4c76f5dd4ae12315 Mon Sep 17 00:00:00 2001 From: ninovanhooff Date: Thu, 16 Jan 2020 16:26:15 +0100 Subject: [PATCH 55/68] Update resources/qml/Actions.qml Remove redundant icon from marketplaceMaterialsAction --- resources/qml/Actions.qml | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/qml/Actions.qml b/resources/qml/Actions.qml index 8a1b2092fa..c62b0cb89a 100644 --- a/resources/qml/Actions.qml +++ b/resources/qml/Actions.qml @@ -192,7 +192,6 @@ Item Action { id: marketplaceMaterialsAction - iconName: "configure" text: catalog.i18nc("@action:inmenu", "Add more materials from Marketplace") } From 0e5654e44b2e9ea1d827dea87248b140a987d7ad Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 17 Jan 2020 13:09:03 +0100 Subject: [PATCH 56/68] Guard against selection pass not existing yet It could happen that the selection pass is not initialised because you're right clicking on the screen before the first render has happened. Hopefully this fixes #6976. --- cura/CuraApplication.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index f778cb0fab..221ccf9fb0 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import os @@ -1827,15 +1827,21 @@ class CuraApplication(QtApplication): def _onContextMenuRequested(self, x: float, y: float) -> None: # Ensure we select the object if we request a context menu over an object without having a selection. - if not Selection.hasSelection(): - node = self.getController().getScene().findObject(cast(SelectionPass, self.getRenderer().getRenderPass("selection")).getIdAtPosition(x, y)) - if node: - parent = node.getParent() - while(parent and parent.callDecoration("isGroup")): - node = parent - parent = node.getParent() + if Selection.hasSelection(): + return + selection_pass = cast(SelectionPass, self.getRenderer().getRenderPass("selection")) + if not selection_pass: # If you right-click before the rendering has been initialised there might not be a selection pass yet. + print("--------------ding! Got the crash.") + return + node = self.getController().getScene().findObject(selection_pass.getIdAtPosition(x, y)) + if not node: + return + parent = node.getParent() + while parent and parent.callDecoration("isGroup"): + node = parent + parent = node.getParent() - Selection.add(node) + Selection.add(node) @pyqtSlot() def showMoreInformationDialogForAnonymousDataCollection(self): From 4e8534b93b1be06d25f647a1f6da1032d71379dc Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 17 Jan 2020 11:10:29 +0100 Subject: [PATCH 57/68] Unsubscribe from package when a license is declined (cloud flow) CURA-6984 --- plugins/Toolbox/src/CloudApiModel.py | 8 ++++++++ plugins/Toolbox/src/CloudSync/CloudPackageManager.py | 5 +++++ plugins/Toolbox/src/CloudSync/SyncOrchestrator.py | 3 +-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/plugins/Toolbox/src/CloudApiModel.py b/plugins/Toolbox/src/CloudApiModel.py index 31c3139262..556d54cf88 100644 --- a/plugins/Toolbox/src/CloudApiModel.py +++ b/plugins/Toolbox/src/CloudApiModel.py @@ -18,3 +18,11 @@ class CloudApiModel: cloud_api_root=cloud_api_root, cloud_api_version=cloud_api_version, ) + + ## https://api.ultimaker.com/cura-packages/v1/user/packages/{package_id} + @classmethod + def userPackageUrl(cls, package_id: str) -> str: + + return (CloudApiModel.api_url_user_packages + "/{package_id}").format( + package_id=package_id + ) diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageManager.py b/plugins/Toolbox/src/CloudSync/CloudPackageManager.py index ee57a1b90d..0cbc9eaa7a 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageManager.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageManager.py @@ -16,3 +16,8 @@ class CloudPackageManager: data=data.encode(), scope=self._scope ) + + def unsubscribe(self, package_id: str) -> None: + url = CloudApiModel.userPackageUrl(package_id) + self._request_manager.delete(url=url, scope=self._scope) + diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index 674fb68729..abde4e4072 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -83,8 +83,7 @@ class SyncOrchestrator(Extension): self._cloud_package_manager.subscribe(item["package_id"]) has_changes = True else: - # todo unsubscribe declined packages - pass + self._cloud_package_manager.unsubscribe(item["package_id"]) # delete temp file os.remove(item["package_path"]) From 9dd50c88046128bd77dcd4cd7a02b7180a4fd580 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 17 Jan 2020 11:44:57 +0100 Subject: [PATCH 58/68] Replace buttons by PrimaryButtons for cloud sync dialogs CURA-6984 --- .../qml/dialogs/CompatibilityDialog.qml | 4 +++- .../qml/dialogs/ToolboxLicenseDialog.qml | 21 ++++++++++++------- resources/themes/cura-light/theme.json | 1 + 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml index 06c1102811..32b4da4823 100644 --- a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml @@ -152,7 +152,7 @@ UM.Dialog{ } // End of ScrollView - Cura.ActionButton + Cura.PrimaryButton { id: nextButton anchors.bottom: parent.bottom @@ -160,6 +160,8 @@ UM.Dialog{ anchors.margins: UM.Theme.getSize("default_margin").height text: catalog.i18nc("@button", "Next") onClicked: accept() + leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width + rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width } } } diff --git a/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml b/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml index 2c88ac6d5f..3e7cdc9df8 100644 --- a/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml @@ -10,6 +10,7 @@ import QtQuick.Controls.Styles 1.4 // TODO: Switch to QtQuick.Controls 2.x and remove QtQuick.Controls.Styles import UM 1.1 as UM +import Cura 1.6 as Cura UM.Dialog { @@ -51,18 +52,22 @@ UM.Dialog } rightButtons: [ - Button + Cura.PrimaryButton { - id: acceptButton - anchors.margins: UM.Theme.getSize("default_margin").width - text: catalog.i18nc("@action:button", "Accept") + leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width + rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width + + text: catalog.i18nc("@button", "Agree") onClicked: { handler.onLicenseAccepted() } - }, - Button + } + ] + + leftButtons: + [ + Cura.SecondaryButton { id: declineButton - anchors.margins: UM.Theme.getSize("default_margin").width - text: catalog.i18nc("@action:button", "Decline") + text: catalog.i18nc("@button", "Decline and remove from account") onClicked: { handler.onLicenseDeclined() } } ] diff --git a/resources/themes/cura-light/theme.json b/resources/themes/cura-light/theme.json index e5009d8633..de4c9ccb42 100644 --- a/resources/themes/cura-light/theme.json +++ b/resources/themes/cura-light/theme.json @@ -520,6 +520,7 @@ "action_button": [15.0, 2.5], "action_button_icon": [1.0, 1.0], "action_button_radius": [0.15, 0.15], + "dialog_primary_button_padding": [3.0, 0], "radio_button": [1.3, 1.3], From b03be75a1338cab6ef0ce79a88b01199c4fe977b Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 17 Jan 2020 12:21:18 +0100 Subject: [PATCH 59/68] Show error messages for cloud flow errors - failed downloads - failed installs CURA-6984 --- plugins/Toolbox/src/CloudSync/SyncOrchestrator.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index abde4e4072..e97bdbcbc4 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -1,8 +1,10 @@ import os from typing import List, Dict, Any, cast +from UM import i18n_catalog from UM.Extension import Extension from UM.Logger import Logger +from UM.Message import Message from UM.PluginRegistry import PluginRegistry from cura.CuraApplication import CuraApplication from .CloudPackageChecker import CloudPackageChecker @@ -64,7 +66,10 @@ class SyncOrchestrator(Extension): # \param success_items: Dict[package_id, file_path] # \param error_items: List[package_id] def _onDownloadFinished(self, success_items: Dict[str, str], error_items: List[str]) -> None: - # todo handle error items + if error_items: + message = i18n_catalog.i18nc("@info:generic", "{} plugins failed to download".format(len(error_items))) + self._showErrorMessage(message) + plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath(self.getPluginId())) self._license_presenter.present(plugin_path, success_items) @@ -78,7 +83,8 @@ class SyncOrchestrator(Extension): if item["accepted"]: # install and subscribe packages if not self._package_manager.installPackage(item["package_path"]): - Logger.error("could not install {}".format(item["package_id"])) + message = "Could not install {}".format(item["package_id"]) + self._showErrorMessage(message) continue self._cloud_package_manager.subscribe(item["package_id"]) has_changes = True @@ -89,3 +95,8 @@ class SyncOrchestrator(Extension): if has_changes: self._restart_presenter.present() + + ## Logs an error and shows it to the user + def _showErrorMessage(self, text: str): + Logger.error(text) + Message(text, lifetime=0).show() From 3b1e88e49c005657e463f6c76c505ad253ce8636 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 20 Jan 2020 09:41:48 +0100 Subject: [PATCH 60/68] Limit retraction_combing_max_distance to be no lower than retraction_min_travel Retraction_min_travel overrides retraction_combing_max_distance in CuraEngine, so it has no effect to make it any lower. Contributes to issue CURA-6860. --- resources/definitions/fdmprinter.def.json | 5 +++-- resources/definitions/strateo3d.def.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/resources/definitions/fdmprinter.def.json b/resources/definitions/fdmprinter.def.json index ca70f0d7de..efa59a17de 100644 --- a/resources/definitions/fdmprinter.def.json +++ b/resources/definitions/fdmprinter.def.json @@ -3776,8 +3776,9 @@ "description": "When non-zero, combing travel moves that are longer than this distance will use retraction.", "unit": "mm", "type": "float", - "default_value": 0, - "minimum_value": "0", + "default_value": 1.5, + "value": "retraction_min_travel", + "minimum_value": "retraction_min_travel", "enabled": "resolveOrValue('retraction_combing') != 'off'", "settable_per_mesh": false, "settable_per_extruder": true diff --git a/resources/definitions/strateo3d.def.json b/resources/definitions/strateo3d.def.json index 2ee3650404..177e208ebc 100644 --- a/resources/definitions/strateo3d.def.json +++ b/resources/definitions/strateo3d.def.json @@ -93,7 +93,7 @@ "prime_tower_position_y": { "value": "machine_depth - prime_tower_size - max(extruderValue(adhesion_extruder_nr, 'brim_width') * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 if adhesion_type == 'brim' else (extruderValue(adhesion_extruder_nr, 'raft_margin') if adhesion_type == 'raft' else (extruderValue(adhesion_extruder_nr, 'skirt_gap') if adhesion_type == 'skirt' else 0)), max(extruderValues('travel_avoid_distance'))) - max(extruderValues('support_offset')) - sum(extruderValues('skirt_brim_line_width')) * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 - (resolveOrValue('draft_shield_dist') if resolveOrValue('draft_shield_enabled') else 0) - 1" }, "retraction_amount": { "default_value": 1.5 }, "retraction_combing": { "default_value": "all" }, - "retraction_combing_max_distance": { "default_value": 5 }, + "retraction_combing_max_distance": { "value": "max(5, retraction_min_travel)" }, "retraction_count_max": { "default_value": 15 }, "retraction_hop": { "value": "2" }, "retraction_hop_enabled": { "value": "extruders_enabled_count > 1" }, From 66105e8a3a8cfc54410d57a48eb0bfc81715417f Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 20 Jan 2020 09:48:23 +0100 Subject: [PATCH 61/68] Add invalid imports checker Since plugins.* is not available on the PATH for some builds, they should not be used. Relative imports are preferred --- docker/build.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docker/build.sh b/docker/build.sh index 5b035ca08a..9bff78c2a3 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -13,6 +13,19 @@ export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}" cd "${PROJECT_DIR}" +# Check for plugins.* import statements. These imports may work when running from source, +# but will fail in some build types (linux and mac) +GREP_OUTPUT=$(grep -Ern "^\s*(from plugins|import plugins)" --include \*.py "${PROJECT_DIR}" || true) +echo "$GREP_OUTPUT" + +if [ -z "$GREP_OUTPUT" ] +then + echo "invalid imports checker: OK" +else + echo "error: sources contain invalid imports. Use relative imports when referencing plugin source files" + exit 1 +fi + # # Clone Uranium and set PYTHONPATH first # From 5ddee1e70fbc2bec6b8ccc1531d146f7d79cec1b Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 20 Jan 2020 09:51:30 +0100 Subject: [PATCH 62/68] Properly override retraction_combing_max_distance Since by default it now defines a value for this one rather than default_value, this override had broken. Thank you, tests. Contributes to issue CURA-6860. --- resources/definitions/voron2_base.def.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/definitions/voron2_base.def.json b/resources/definitions/voron2_base.def.json index 7a9a6ee8aa..adc4846905 100644 --- a/resources/definitions/voron2_base.def.json +++ b/resources/definitions/voron2_base.def.json @@ -105,7 +105,7 @@ "retraction_hop_enabled": { "default_value": true }, "retraction_hop": { "default_value": 0.2 }, "retraction_combing": { "default_value": "noskin" }, - "retraction_combing_max_distance": { "default_value": 10 }, + "retraction_combing_max_distance": { "value": "10" }, "travel_avoid_other_parts": { "default_value": false }, "speed_travel": { "maximum_value": 300, "value": 300, "maximum_value_warning": 501 }, "speed_travel_layer_0": { "value": "math.ceil(speed_travel * 0.4)" }, From 8f6fb5e007a4747b418d434b30a64fb5a4923bda Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 20 Jan 2020 10:46:55 +0100 Subject: [PATCH 63/68] Revert "Properly override retraction_combing_max_distance" This reverts commit 5ddee1e70fbc2bec6b8ccc1531d146f7d79cec1b. It was changing the default behaviour for this printer. Contributes to issue CURA-6860. --- resources/definitions/voron2_base.def.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/definitions/voron2_base.def.json b/resources/definitions/voron2_base.def.json index adc4846905..7a9a6ee8aa 100644 --- a/resources/definitions/voron2_base.def.json +++ b/resources/definitions/voron2_base.def.json @@ -105,7 +105,7 @@ "retraction_hop_enabled": { "default_value": true }, "retraction_hop": { "default_value": 0.2 }, "retraction_combing": { "default_value": "noskin" }, - "retraction_combing_max_distance": { "value": "10" }, + "retraction_combing_max_distance": { "default_value": 10 }, "travel_avoid_other_parts": { "default_value": false }, "speed_travel": { "maximum_value": 300, "value": 300, "maximum_value_warning": 501 }, "speed_travel_layer_0": { "value": "math.ceil(speed_travel * 0.4)" }, From 92926cfc21819b9b6df030f2d93041e07fcee7e3 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 20 Jan 2020 10:47:46 +0100 Subject: [PATCH 64/68] Revert "Limit retraction_combing_max_distance to be no lower than retraction_min_travel" This reverts commit 3b1e88e49c005657e463f6c76c505ad253ce8636. It was changing the default behaviour for these printers. Contributes to issue CURA-6860. --- resources/definitions/fdmprinter.def.json | 5 ++--- resources/definitions/strateo3d.def.json | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/resources/definitions/fdmprinter.def.json b/resources/definitions/fdmprinter.def.json index efa59a17de..ca70f0d7de 100644 --- a/resources/definitions/fdmprinter.def.json +++ b/resources/definitions/fdmprinter.def.json @@ -3776,9 +3776,8 @@ "description": "When non-zero, combing travel moves that are longer than this distance will use retraction.", "unit": "mm", "type": "float", - "default_value": 1.5, - "value": "retraction_min_travel", - "minimum_value": "retraction_min_travel", + "default_value": 0, + "minimum_value": "0", "enabled": "resolveOrValue('retraction_combing') != 'off'", "settable_per_mesh": false, "settable_per_extruder": true diff --git a/resources/definitions/strateo3d.def.json b/resources/definitions/strateo3d.def.json index 177e208ebc..2ee3650404 100644 --- a/resources/definitions/strateo3d.def.json +++ b/resources/definitions/strateo3d.def.json @@ -93,7 +93,7 @@ "prime_tower_position_y": { "value": "machine_depth - prime_tower_size - max(extruderValue(adhesion_extruder_nr, 'brim_width') * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 if adhesion_type == 'brim' else (extruderValue(adhesion_extruder_nr, 'raft_margin') if adhesion_type == 'raft' else (extruderValue(adhesion_extruder_nr, 'skirt_gap') if adhesion_type == 'skirt' else 0)), max(extruderValues('travel_avoid_distance'))) - max(extruderValues('support_offset')) - sum(extruderValues('skirt_brim_line_width')) * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 - (resolveOrValue('draft_shield_dist') if resolveOrValue('draft_shield_enabled') else 0) - 1" }, "retraction_amount": { "default_value": 1.5 }, "retraction_combing": { "default_value": "all" }, - "retraction_combing_max_distance": { "value": "max(5, retraction_min_travel)" }, + "retraction_combing_max_distance": { "default_value": 5 }, "retraction_count_max": { "default_value": 15 }, "retraction_hop": { "value": "2" }, "retraction_hop_enabled": { "value": "extruders_enabled_count > 1" }, From 39780636a910252bb059821c746a1e234799620d Mon Sep 17 00:00:00 2001 From: MakeIt3D <59931733+MakeIt3D@users.noreply.github.com> Date: Mon, 20 Jan 2020 05:53:48 -0800 Subject: [PATCH 65/68] Add MAKEiT Pro-MX Profile (#6983) CURA-7125 --- resources/definitions/makeit_pro_mx.def.json | 96 +++++++++++++++++++ .../extruders/makeit_mx_dual_1st.def.json | 27 ++++++ .../extruders/makeit_mx_dual_2nd.def.json | 27 ++++++ 3 files changed, 150 insertions(+) create mode 100644 resources/definitions/makeit_pro_mx.def.json create mode 100644 resources/extruders/makeit_mx_dual_1st.def.json create mode 100644 resources/extruders/makeit_mx_dual_2nd.def.json diff --git a/resources/definitions/makeit_pro_mx.def.json b/resources/definitions/makeit_pro_mx.def.json new file mode 100644 index 0000000000..13770e8571 --- /dev/null +++ b/resources/definitions/makeit_pro_mx.def.json @@ -0,0 +1,96 @@ +{ + "version": 2, + "name": "MAKEiT Pro-MX", + "inherits": "fdmprinter", + "metadata": { + "visible": true, + "author": "unknown", + "manufacturer": "MAKEiT 3D", + "file_formats": "text/x-gcode", + "has_materials": false, + "machine_extruder_trains": + { + "0": "makeit_mx_dual_1st", + "1": "makeit_mx_dual_2nd" + } + }, + + "overrides": { + "machine_name": { "default_value": "MAKEiT Pro-MX" }, + "machine_width": { + "default_value": 200 + }, + "machine_height": { + "default_value": 330 + }, + "machine_depth": { + "default_value": 240 + }, + "machine_center_is_zero": { + "default_value": false + }, + "machine_head_with_fans_polygon": + { + "default_value": [ + [ -200, 240 ], + [ -200, -32 ], + [ 200, 240 ], + [ 200, -32 ] + ] + }, + "gantry_height": { + "value": "200" + }, + "machine_use_extruder_offset_to_offset_coords": { + "default_value": true + }, + "machine_gcode_flavor": { + "default_value": "RepRap (Marlin/Sprinter)" + }, + "machine_start_gcode": { + "default_value": "G21 ;metric values\nG90 ;absolute positioning\nM82 ;set extruder to absolute mode\nG92 E0 ;zero the extruded length\nG28 ;home\nG1 F200 E30 ;extrude 30 mm of feed stock\nG92 E0 ;zero the extruded length\nG1 E-5 ;retract 5 mm\nG28 SC ;Do homeing, clean nozzles and let printer to know that printing started\nG92 X-6 ;Sets Curas checker board to match printers heated bed coordinates\nG1 F{speed_travel}\nM117 Printing..." + }, + "machine_end_gcode": { + "default_value": "M104 T0 S0 ;1st extruder heater off\nM104 T1 S0 ;2nd extruder heater off\nM140 S0 ;heated bed heater off (if you have it)\nG91 ;relative positioning\nG1 E-5 F9000 ;retract the filament a bit before lifting the nozzle, to release some of the pressure\nG1 Z+5 X+20 Y+20 F9000 ;move Z up a bit\nM117 MAKEiT Pro@Done\nG28 X0 Y0 ;move X/Y to min endstops, so the head is out of the way\nM84 ;steppers off\nG90 ;absolute positioning\nM81" + }, + "machine_extruder_count": { + "default_value": 2 + }, + "print_sequence": { + "enabled": false + }, + "prime_tower_position_x": { + "value": "185" + }, + "prime_tower_position_y": { + "value": "160" + }, + "layer_height": { + "default_value": 0.2 + }, + "retraction_speed": { + "default_value": 180 + }, + "infill_sparse_density": { + "default_value": 20 + }, + "retraction_amount": { + "default_value": 6 + }, + "speed_print": { + "default_value": 60 + }, + "wall_thickness": { + "default_value": 1.2 + }, + "cool_min_layer_time_fan_speed_max": { + "default_value": 5 + }, + "adhesion_type": { + "default_value": "skirt" + }, + "machine_heated_bed": { + "default_value": true + } + } +} \ No newline at end of file diff --git a/resources/extruders/makeit_mx_dual_1st.def.json b/resources/extruders/makeit_mx_dual_1st.def.json new file mode 100644 index 0000000000..48a15bb4e7 --- /dev/null +++ b/resources/extruders/makeit_mx_dual_1st.def.json @@ -0,0 +1,27 @@ +{ + "version": 2, + "name": "1st Extruder", + "inherits": "fdmextruder", + "metadata": { + "machine": "makeit_pro_mx", + "position": "0" + }, + + "overrides": { + "extruder_nr": { + "default_value": 0, + "maximum_value": "1" + }, + "machine_nozzle_offset_x": { "default_value": 0.0 }, + "machine_nozzle_offset_y": { "default_value": 0.0 }, + "machine_nozzle_size": { "default_value": 0.4 }, + "material_diameter": { "default_value": 1.75 }, + + "machine_extruder_start_pos_abs": { "default_value": true }, + "machine_extruder_start_pos_x": { "value": "prime_tower_position_x" }, + "machine_extruder_start_pos_y": { "value": "prime_tower_position_y" }, + "machine_extruder_end_pos_abs": { "default_value": true }, + "machine_extruder_end_pos_x": { "value": "prime_tower_position_x" }, + "machine_extruder_end_pos_y": { "value": "prime_tower_position_y" } + } +} \ No newline at end of file diff --git a/resources/extruders/makeit_mx_dual_2nd.def.json b/resources/extruders/makeit_mx_dual_2nd.def.json new file mode 100644 index 0000000000..b17b1b9051 --- /dev/null +++ b/resources/extruders/makeit_mx_dual_2nd.def.json @@ -0,0 +1,27 @@ +{ + "version": 2, + "name": "2nd Extruder", + "inherits": "fdmextruder", + "metadata": { + "machine": "makeit_pro_mx", + "position": "1" + }, + + "overrides": { + "extruder_nr": { + "default_value": 1, + "maximum_value": "1" + }, + "machine_nozzle_offset_x": { "default_value": 0.0 }, + "machine_nozzle_offset_y": { "default_value": 0.0 }, + "machine_nozzle_size": { "default_value": 0.4 }, + "material_diameter": { "default_value": 1.75 }, + + "machine_extruder_start_pos_abs": { "default_value": true }, + "machine_extruder_start_pos_x": { "value": "prime_tower_position_x" }, + "machine_extruder_start_pos_y": { "value": "prime_tower_position_y" }, + "machine_extruder_end_pos_abs": { "default_value": true }, + "machine_extruder_end_pos_x": { "value": "prime_tower_position_x" }, + "machine_extruder_end_pos_y": { "value": "prime_tower_position_y" } + } +} \ No newline at end of file From b830a6faa3f83814b795fdca41bb73b90e48ac68 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 20 Jan 2020 16:22:02 +0100 Subject: [PATCH 66/68] Rewrite invalid imports checker to Python Makes it consistent with other checkers we already have --- cmake/CuraTests.cmake | 7 ++++ docker/build.sh | 11 ------ scripts/check_invalid_imports.py | 60 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 scripts/check_invalid_imports.py diff --git a/cmake/CuraTests.cmake b/cmake/CuraTests.cmake index b1d3e0ddc4..c76019d310 100644 --- a/cmake/CuraTests.cmake +++ b/cmake/CuraTests.cmake @@ -56,6 +56,13 @@ function(cura_add_test) endif() endfunction() +#Add test for whether the shortcut alt-keys are unique in every translation. +add_test( + NAME "invalid-imports" + COMMAND ${Python3_EXECUTABLE} scripts/check_invalid_imports.py + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} +) + cura_add_test(NAME pytest-main DIRECTORY ${CMAKE_SOURCE_DIR}/tests PYTHONPATH "${CMAKE_SOURCE_DIR}|${URANIUM_DIR}") file(GLOB_RECURSE _plugins plugins/*/__init__.py) diff --git a/docker/build.sh b/docker/build.sh index 9bff78c2a3..a500663c64 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -13,18 +13,7 @@ export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}" cd "${PROJECT_DIR}" -# Check for plugins.* import statements. These imports may work when running from source, -# but will fail in some build types (linux and mac) -GREP_OUTPUT=$(grep -Ern "^\s*(from plugins|import plugins)" --include \*.py "${PROJECT_DIR}" || true) -echo "$GREP_OUTPUT" -if [ -z "$GREP_OUTPUT" ] -then - echo "invalid imports checker: OK" -else - echo "error: sources contain invalid imports. Use relative imports when referencing plugin source files" - exit 1 -fi # # Clone Uranium and set PYTHONPATH first diff --git a/scripts/check_invalid_imports.py b/scripts/check_invalid_imports.py new file mode 100644 index 0000000000..121184e739 --- /dev/null +++ b/scripts/check_invalid_imports.py @@ -0,0 +1,60 @@ +import os +import re +import sys +from pathlib import Path + +""" +Run this file with the Cura project root as the working directory +""" + +class InvalidImportsChecker: + # compile regex + REGEX = re.compile(r"^\s*(from plugins|import plugins)") + + def check(self): + """ Checks for invalid imports + + :return: True if checks passed, False when the test fails + """ + cwd = os.getcwd() + cura_result = checker.check_dir(os.path.join(cwd, "cura")) + plugins_result = checker.check_dir(os.path.join(cwd, "plugins")) + result = cura_result and plugins_result + if not result: + print("error: sources contain invalid imports. Use relative imports when referencing plugin source files") + + return result + + def check_dir(self, root_dir: str) -> bool: + """ Checks a directory for invalid imports + + :return: True if checks passed, False when the test fails + """ + passed = True + for path_like in Path(root_dir).rglob('*.py'): + if not self.check_file(str(path_like)): + passed = False + + return passed + + def check_file(self, file_path): + """ Checks a file for invalid imports + + :return: True if checks passed, False when the test fails + """ + passed = True + with open(file_path, 'r', encoding = "utf-8") as inputFile: + # loop through each line in file + for line_i, line in enumerate(inputFile, 1): + # check if we have a regex match + match = self.REGEX.search(line) + if match: + path = os.path.relpath(file_path) + print("{path}:{line_i}:{match}".format(path=path, line_i=line_i, match=match.group(1))) + passed = False + return passed + + +if __name__ == "__main__": + checker = InvalidImportsChecker() + sys.exit(0 if checker.check() else 1) From 2e20bf6a983dfd4fcfad56aecb3ce41f53621b31 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 20 Jan 2020 16:33:03 +0100 Subject: [PATCH 67/68] Update invalid imports checker documentation Makes it consistent with other checkers we already have --- cmake/CuraTests.cmake | 2 +- scripts/check_invalid_imports.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cmake/CuraTests.cmake b/cmake/CuraTests.cmake index c76019d310..251bec5781 100644 --- a/cmake/CuraTests.cmake +++ b/cmake/CuraTests.cmake @@ -56,7 +56,7 @@ function(cura_add_test) endif() endfunction() -#Add test for whether the shortcut alt-keys are unique in every translation. +#Add test for import statements which are not compatible with all builds add_test( NAME "invalid-imports" COMMAND ${Python3_EXECUTABLE} scripts/check_invalid_imports.py diff --git a/scripts/check_invalid_imports.py b/scripts/check_invalid_imports.py index 121184e739..ba21b9f822 100644 --- a/scripts/check_invalid_imports.py +++ b/scripts/check_invalid_imports.py @@ -5,8 +5,13 @@ from pathlib import Path """ Run this file with the Cura project root as the working directory +Checks for invalid imports. When importing from plugins, there will be no problems when running from source, +but for some build types the plugins dir is not on the path, so relative imports should be used instead. eg: +from ..UltimakerCloudScope import UltimakerCloudScope <-- OK +import plugins.Toolbox.src ... <-- NOT OK """ + class InvalidImportsChecker: # compile regex REGEX = re.compile(r"^\s*(from plugins|import plugins)") From 5d21872e50e99f99076b7799cc72ad8f66eb87a3 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 21 Jan 2020 10:31:47 +0100 Subject: [PATCH 68/68] Take nozzle offsets into account when placing prime tower extruderValues('machine_nozzle_offset_x') := [0, 20, -18] map(abs(extruderValues('machine_nozzle_offset_x') := [0, 20, 18] max(map(abs(extruderValues('machine_nozzle_offset_x') := 20 So we take the highest offset of all extruders to get the area that can be reached by all extruders. And we take the abs() of all extruder values because positive or negative only means that the other extruders get offset in the same direction. Fixes #6997. --- resources/definitions/fdmprinter.def.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/definitions/fdmprinter.def.json b/resources/definitions/fdmprinter.def.json index ca70f0d7de..b5d423c28b 100644 --- a/resources/definitions/fdmprinter.def.json +++ b/resources/definitions/fdmprinter.def.json @@ -5688,7 +5688,7 @@ "unit": "mm", "enabled": "resolveOrValue('prime_tower_enable')", "default_value": 200, - "value": "machine_width - max(extruderValue(adhesion_extruder_nr, 'brim_width') * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 if adhesion_type == 'brim' or (prime_tower_brim_enable and adhesion_type != 'raft') else (extruderValue(adhesion_extruder_nr, 'raft_margin') if adhesion_type == 'raft' else (extruderValue(adhesion_extruder_nr, 'skirt_gap') if adhesion_type == 'skirt' else 0)), max(extruderValues('travel_avoid_distance'))) - max(extruderValues('support_offset')) - sum(extruderValues('skirt_brim_line_width')) * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 - (resolveOrValue('draft_shield_dist') if resolveOrValue('draft_shield_enabled') else 0) - 1", + "value": "machine_width - max(extruderValue(adhesion_extruder_nr, 'brim_width') * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 if adhesion_type == 'brim' or (prime_tower_brim_enable and adhesion_type != 'raft') else (extruderValue(adhesion_extruder_nr, 'raft_margin') if adhesion_type == 'raft' else (extruderValue(adhesion_extruder_nr, 'skirt_gap') if adhesion_type == 'skirt' else 0)), max(extruderValues('travel_avoid_distance'))) - max(extruderValues('support_offset')) - sum(extruderValues('skirt_brim_line_width')) * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 - (resolveOrValue('draft_shield_dist') if resolveOrValue('draft_shield_enabled') else 0) - max(map(abs, extruderValues('machine_nozzle_offset_x'))) - 1", "maximum_value": "machine_width / 2 if machine_center_is_zero else machine_width", "minimum_value": "resolveOrValue('prime_tower_size') - machine_width / 2 if machine_center_is_zero else resolveOrValue('prime_tower_size')", "settable_per_mesh": false, @@ -5702,7 +5702,7 @@ "unit": "mm", "enabled": "resolveOrValue('prime_tower_enable')", "default_value": 200, - "value": "machine_depth - prime_tower_size - max(extruderValue(adhesion_extruder_nr, 'brim_width') * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 if adhesion_type == 'brim' or (prime_tower_brim_enable and adhesion_type != 'raft') else (extruderValue(adhesion_extruder_nr, 'raft_margin') if adhesion_type == 'raft' else (extruderValue(adhesion_extruder_nr, 'skirt_gap') if adhesion_type == 'skirt' else 0)), max(extruderValues('travel_avoid_distance'))) - max(extruderValues('support_offset')) - sum(extruderValues('skirt_brim_line_width')) * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 - (resolveOrValue('draft_shield_dist') if resolveOrValue('draft_shield_enabled') else 0) - 1", + "value": "machine_depth - prime_tower_size - max(extruderValue(adhesion_extruder_nr, 'brim_width') * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 if adhesion_type == 'brim' or (prime_tower_brim_enable and adhesion_type != 'raft') else (extruderValue(adhesion_extruder_nr, 'raft_margin') if adhesion_type == 'raft' else (extruderValue(adhesion_extruder_nr, 'skirt_gap') if adhesion_type == 'skirt' else 0)), max(extruderValues('travel_avoid_distance'))) - max(extruderValues('support_offset')) - sum(extruderValues('skirt_brim_line_width')) * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 - (resolveOrValue('draft_shield_dist') if resolveOrValue('draft_shield_enabled') else 0) - max(map(abs, extruderValues('machine_nozzle_offset_y'))) - 1", "maximum_value": "machine_depth / 2 - resolveOrValue('prime_tower_size') if machine_center_is_zero else machine_depth - resolveOrValue('prime_tower_size')", "minimum_value": "machine_depth / -2 if machine_center_is_zero else 0", "settable_per_mesh": false,