diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc index cfc0497..721d106 100644 --- a/core/includes/install.core.inc +++ b/core/includes/install.core.inc @@ -6,6 +6,10 @@ */ use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Config\ConfigImporter; +use Drupal\Core\Config\ConfigImporterException; +use Drupal\Core\Config\FileStorage; +use Drupal\Core\Config\StorageComparer; use Drupal\Core\DrupalKernel; use Drupal\Core\Database\Database; use Drupal\Core\Database\DatabaseExceptionWrapper; @@ -445,10 +449,16 @@ function install_begin_request($class_loader, &$install_state) { } } - // Use the language from the profile configuration, if available, to override - // the language previously set in the parameters. - if (isset($install_state['profile_info']['distribution']['langcode'])) { - $install_state['parameters']['langcode'] = $install_state['profile_info']['distribution']['langcode']; + // Use the language from profile configuration if available. + if (!empty($install_state['config_install'])) { + $install_state['parameters']['langcode'] = $install_state['config']['system.site']['default_langcode']; + } + else { + // Otherwise, Use the language from the profile configuration, if available, + // to override the language previously set in the parameters. + if (isset($install_state['profile_info']['distribution']['langcode'])) { + $install_state['parameters']['langcode'] = $install_state['profile_info']['distribution']['langcode']; + } } // Set the default language to the selected language, if any. @@ -789,6 +799,25 @@ function install_tasks($install_state) { // Now add any tasks defined by the installation profile. if (!empty($install_state['parameters']['profile'])) { + if ($install_state['config_install']) { + // @todo add a load of commentary about what is happening. + unset($tasks['install_download_translation']); + $key = array_search('install_profile_modules', array_keys($tasks), TRUE); + unset($tasks['install_profile_modules']); + unset($tasks['install_profile_themes']); + unset($tasks['install_install_profile']); + $config_tasks = [ + 'install_config_import_batch' => [ + 'display_name' => t('Install configuration'), + 'type' => 'batch', + ], + 'install_config_download_translations' => [], + 'install_config_fix_profile' => [], + ]; + $tasks = array_slice($tasks, 0, $key, TRUE) + + $config_tasks + + array_slice($tasks, $key, NULL, TRUE); + } // Load the profile install file, because it is not always loaded when // hook_install_tasks() is invoked (e.g. batch processing). $profile = $install_state['parameters']['profile']; @@ -1464,6 +1493,12 @@ function install_load_profile(&$install_state) { $profile = $install_state['parameters']['profile']; $install_state['profiles'][$profile]->load(); $install_state['profile_info'] = install_profile_info($profile, isset($install_state['parameters']['langcode']) ? $install_state['parameters']['langcode'] : 'en'); + // If the profile has a config/sync directory copy the information to the + // install_state global. + if (isset($install_state['profile_info']['config_install'])) { + $install_state['config_install'] = $install_state['profile_info']['config_install']; + $install_state['config'] = $install_state['profile_info']['config']; + } } /** @@ -2217,3 +2252,189 @@ function install_write_profile($install_state) { throw new InstallProfileMismatchException($install_state['parameters']['profile'], $settings_profile, $settings_path, \Drupal::translation()); } } + +/** + * Creates a batch for the config importer to process. + * + * @see install_tasks() + */ +function install_config_import_batch() { + global $config_directories; + // We need to manually trigger the installation of core-provided entity types, + // as those will not be handled by the module installer. + // @see install_profile_modules() + install_core_entity_type_definitions(); + + // Create a source storage that reads from sync. + $sync = new FileStorage($config_directories[CONFIG_SYNC_DIRECTORY]); + // Match up the site uuids, the install_base_system install task will have + // installed the system module and created a new UUID. + $system_site = $sync->read('system.site'); + \Drupal::configFactory()->getEditable('system.site')->set('uuid', $system_site['uuid'])->save(); + + // Create the storage comparer and the config importer. + $config_manager = \Drupal::service('config.manager'); + $storage_comparer = new StorageComparer($sync, \Drupal::service('config.storage'), $config_manager); + $storage_comparer->createChangelist(); + $config_importer = new ConfigImporter( + $storage_comparer, + \Drupal::service('event_dispatcher'), + $config_manager, + \Drupal::service('lock.persistent'), + \Drupal::service('config.typed'), + \Drupal::service('module_handler'), + \Drupal::service('module_installer'), + \Drupal::service('theme_handler'), + \Drupal::service('string_translation') + ); + + try { + $sync_steps = $config_importer->initialize(); + + $batch = [ + 'operations' => [], + 'finished' => 'install_config_import_batch_finish', + 'title' => t('Synchronizing configuration'), + 'init_message' => t('Starting configuration synchronization.'), + 'progress_message' => t('Completed @current step of @total.'), + 'error_message' => t('Configuration synchronization has encountered an error.'), + 'file' => drupal_get_path('module', 'config') . '/config.admin.inc', + ]; + foreach ($sync_steps as $sync_step) { + $batch['operations'][] = ['install_config_import_batch_process', [$config_importer, $sync_step]]; + } + + return $batch; + } + catch (ConfigImporterException $e) { + // There are validation errors. + drupal_set_message(t('The configuration synchronization failed validation.')); + foreach ($config_importer->getErrors() as $message) { + drupal_set_message($message, 'error'); + } + } +} + +/** + * Processes the config import batch and persists the importer. + * + * @param \Drupal\Core\Config\ConfigImporter $config_importer + * The batch config importer object to persist. + * @param string $sync_step + * The synchronisation step to do. + * @param $context + * The batch context. + * + * @see install_config_import_batch() + */ +function install_config_import_batch_process(ConfigImporter $config_importer, $sync_step, &$context) { + if (!isset($context['sandbox']['config_importer'])) { + $context['sandbox']['config_importer'] = $config_importer; + } + + $config_importer = $context['sandbox']['config_importer']; + $config_importer->doSyncStep($sync_step, $context); + if ($errors = $config_importer->getErrors()) { + if (!isset($context['results']['errors'])) { + $context['results']['errors'] = []; + } + $context['results']['errors'] += $errors; + } +} + +/** + * Finish config importer batch. + * + * @see install_config_import_batch() + */ +function install_config_import_batch_finish($success, $results, $operations) { + if ($success) { + if (!empty($results['errors'])) { + foreach ($results['errors'] as $error) { + drupal_set_message($error, 'error'); + \Drupal::logger('config_sync')->error($error); + } + drupal_set_message(t('The configuration was imported with errors.'), 'warning'); + } + else { + // Configuration sync needs a complete cache flush. + drupal_flush_all_caches(); + } + } + else { + // An error occurred. + // $operations contains the operations that remained unprocessed. + $error_operation = reset($operations); + $message = t('An error occurred while processing %error_operation with arguments: @arguments', [ + '%error_operation' => $error_operation[0], + '@arguments' => print_r($error_operation[1], TRUE) + ]); + drupal_set_message($message, 'error'); + } +} + + +/** + * Replaces install_download_translation() during configuration installs. + * + * @param array $install_state + * An array of information about the current installation state. + * + * @return string + * A themed status report, or an exception if there are requirement errors. + * Upon successful download the page is reloaded and no output is returned. + * + * @see install_download_translation() + */ +function install_config_download_translations(&$install_state) { + $needs_download = isset($install_state['parameters']['langcode']) && !isset($install_state['translations'][$install_state['parameters']['langcode']]) && $install_state['parameters']['langcode'] !== 'en'; + if ($needs_download) { + return install_download_translation($install_state); + } +} + + +/** + * Fixes configuration if the install profile has made changes in hook_install(). + */ +function install_config_fix_profile() { + global $install_state; + // It is possible that installing the profile makes unintended configuration + // changes. + $config_manager = \Drupal::service('config.manager'); + $storage_comparer = new StorageComparer(\Drupal::service('config.storage.sync'), \Drupal::service('config.storage'), $config_manager); + $storage_comparer->createChangelist(); + if ($storage_comparer->hasChanges()) { + $config_importer = new ConfigImporter( + $storage_comparer, + \Drupal::service('event_dispatcher'), + $config_manager, + \Drupal::service('lock.persistent'), + \Drupal::service('config.typed'), + \Drupal::service('module_handler'), + \Drupal::service('module_installer'), + \Drupal::service('theme_handler'), + \Drupal::service('string_translation') + ); + try { + $config_importer->import(); + } + catch (ConfigImporterException $e) { + // There are validation errors. + drupal_set_message(t('The configuration synchronization failed validation.')); + foreach ($config_importer->getErrors() as $message) { + drupal_set_message($message, 'error'); + } + } + + // At this point the configuration should match completely. + if (\Drupal::moduleHandler()->moduleExists('language')) { + // If the English language exists at this point we need to ensure + // install_download_additional_translations_operations() does not delete + // it. + if (ConfigurableLanguage::load('en')) { + $install_state['profile_info']['keep_english'] = TRUE; + } + } + } +} diff --git a/core/includes/install.inc b/core/includes/install.inc index c5b93b5..672815c 100644 --- a/core/includes/install.inc +++ b/core/includes/install.inc @@ -10,6 +10,7 @@ use Drupal\Component\Utility\Crypt; use Drupal\Component\Utility\OpCodeCache; use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Config\FileStorage; use Drupal\Core\Extension\ExtensionDiscovery; use Drupal\Core\Site\Settings; @@ -482,12 +483,18 @@ function _drupal_rewrite_settings_dump_one(\stdClass $variable, $prefix = '', $s * @see update_prepare_d8_bootstrap() */ function drupal_install_config_directories() { - global $config_directories; + global $config_directories, $install_state; // Add a randomized config directory name to settings.php, unless it was // manually defined in the existing already. if (empty($config_directories[CONFIG_SYNC_DIRECTORY])) { - $config_directories[CONFIG_SYNC_DIRECTORY] = \Drupal::service('site.path') . '/files/config_' . Crypt::randomBytesBase64(55) . '/sync'; + if ($install_state['config_install']) { + $profile = $install_state['parameters']['profile']; + $config_directories[CONFIG_SYNC_DIRECTORY] = $install_state['config_install']; + } + else { + $config_directories[CONFIG_SYNC_DIRECTORY] = \Drupal::service('site.path') . '/files/config_' . Crypt::randomBytesBase64(55) . '/sync'; + } $settings['config_directories'][CONFIG_SYNC_DIRECTORY] = (object) [ 'value' => $config_directories[CONFIG_SYNC_DIRECTORY], 'required' => TRUE, @@ -1077,9 +1084,10 @@ function install_profile_info($profile, $langcode = 'en') { 'version' => NULL, 'hidden' => FALSE, 'php' => DRUPAL_MINIMUM_PHP, + 'config_install' => FALSE, ]; - $profile_file = drupal_get_path('profile', $profile) . "/$profile.info.yml"; - $info = \Drupal::service('info_parser')->parse($profile_file); + $profile_path = drupal_get_path('profile', $profile); + $info = \Drupal::service('info_parser')->parse($profile_path . "/$profile.info.yml"); $info += $defaults; // drupal_required_modules() includes the current profile as a dependency. @@ -1090,6 +1098,12 @@ function install_profile_info($profile, $langcode = 'en') { $info['dependencies'] = array_unique(array_merge($required, $info['dependencies'], $locale)); + // If the profile has a config/sync directory use that to install drupal. + if (is_dir($profile_path . '/config/sync')) { + $info['config_install'] = $profile_path . '/config/sync'; + $sync = new FileStorage($profile_path . '/config/sync'); + $info['config']['system.site'] = $sync->read('system.site'); + } $cache[$profile][$langcode] = $info; } return $cache[$profile][$langcode]; diff --git a/core/lib/Drupal/Core/Config/ConfigImporter.php b/core/lib/Drupal/Core/Config/ConfigImporter.php index 06fed4b..25937e6 100644 --- a/core/lib/Drupal/Core/Config/ConfigImporter.php +++ b/core/lib/Drupal/Core/Config/ConfigImporter.php @@ -405,6 +405,14 @@ protected function createExtensionChangelist() { $module_list = array_reverse($module_list); $this->extensionChangelist['module']['install'] = array_intersect(array_keys($module_list), $install); + // If we're installing the install profile ensure it comes last. This will + // when installing a site from configuration. + $install_profile_key = array_search($new_extensions['profile'], $this->extensionChangelist['module']['install'], TRUE); + if ($install_profile_key !== FALSE) { + unset($this->extensionChangelist['module']['install'][$install_profile_key]); + $this->extensionChangelist['module']['install'][] = $new_extensions['profile']; + } + // Work out what themes to install and to uninstall. $this->extensionChangelist['theme']['install'] = array_keys(array_diff_key($new_extensions['theme'], $current_extensions['theme'])); $this->extensionChangelist['theme']['uninstall'] = array_keys(array_diff_key($current_extensions['theme'], $new_extensions['theme'])); diff --git a/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php b/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php index c28b7f8..6f9ddca 100644 --- a/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php +++ b/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php @@ -118,6 +118,7 @@ protected function getEditableConfigNames() { * {@inheritdoc} */ public function buildForm(array $form, FormStateInterface $form_state) { + global $install_state; $form['#title'] = $this->t('Configure site'); // Warn about settings.php permissions risk @@ -145,12 +146,14 @@ public function buildForm(array $form, FormStateInterface $form_state) { $form['site_information'] = [ '#type' => 'fieldgroup', '#title' => $this->t('Site information'), + '#access' => !empty($install_state['config_install']), ]; $form['site_information']['site_name'] = [ '#type' => 'textfield', '#title' => $this->t('Site name'), '#required' => TRUE, '#weight' => -20, + '#access' => !empty($install_state['config_install']), ]; $form['site_information']['site_mail'] = [ '#type' => 'email', @@ -159,6 +162,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#description' => $this->t("Automated emails, such as registration information, will be sent from this address. Use an address ending in your site's domain to help prevent these emails from being flagged as spam."), '#required' => TRUE, '#weight' => -15, + '#access' => !empty($install_state['config_install']), ]; $form['admin_account'] = [ @@ -188,6 +192,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { $form['regional_settings'] = [ '#type' => 'fieldgroup', '#title' => $this->t('Regional settings'), + '#access' => !empty($install_state['config_install']), ]; $countries = $this->countryManager->getList(); $form['regional_settings']['site_default_country'] = [ @@ -198,6 +203,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#options' => $countries, '#description' => $this->t('Select the default country for the site.'), '#weight' => 0, + '#access' => !empty($install_state['config_install']), ]; $form['regional_settings']['date_default_timezone'] = [ '#type' => 'select', @@ -208,17 +214,20 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#description' => $this->t('By default, dates in this site will be displayed in the chosen time zone.'), '#weight' => 5, '#attributes' => ['class' => ['timezone-detect']], + '#access' => !empty($install_state['config_install']), ]; $form['update_notifications'] = [ '#type' => 'fieldgroup', '#title' => $this->t('Update notifications'), '#description' => $this->t('The system will notify you when updates and important security releases are available for installed components. Anonymous information about your site is sent to Drupal.org.', [':drupal' => 'https://www.drupal.org']), + '#access' => !empty($install_state['config_install']), ]; $form['update_notifications']['enable_update_status_module'] = [ '#type' => 'checkbox', '#title' => $this->t('Check for updates automatically'), '#default_value' => 1, + '#access' => !empty($install_state['config_install']), ]; $form['update_notifications']['enable_update_status_emails'] = [ '#type' => 'checkbox', @@ -229,6 +238,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { 'input[name="enable_update_status_module"]' => ['checked' => TRUE], ], ], + '#access' => !empty($install_state['config_install']), ]; $form['actions'] = ['#type' => 'actions']; @@ -255,21 +265,25 @@ public function validateForm(array &$form, FormStateInterface $form_state) { * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { - $this->config('system.site') - ->set('name', (string) $form_state->getValue('site_name')) - ->set('mail', (string) $form_state->getValue('site_mail')) - ->save(TRUE); + global $install_state; - $this->config('system.date') - ->set('timezone.default', (string) $form_state->getValue('date_default_timezone')) - ->set('country.default', (string) $form_state->getValue('site_default_country')) - ->save(TRUE); + if (!empty($install_state['config_install'])) { + $this->config('system.site') + ->set('name', (string) $form_state->getValue('site_name')) + ->set('mail', (string) $form_state->getValue('site_mail')) + ->save(TRUE); + + $this->config('system.date') + ->set('timezone.default', (string) $form_state->getValue('date_default_timezone')) + ->set('country.default', (string) $form_state->getValue('site_default_country')) + ->save(TRUE); + } $account_values = $form_state->getValue('account'); // Enable update.module if this option was selected. $update_status_module = $form_state->getValue('enable_update_status_module'); - if ($update_status_module) { + if (!empty($install_state['config_install']) && $update_status_module) { $this->moduleInstaller->install(['file', 'update'], FALSE); // Add the site maintenance account's email address to the list of diff --git a/core/modules/system/src/Tests/Installer/InstallerExistingConfigMultilingualTest.php b/core/modules/system/src/Tests/Installer/InstallerExistingConfigMultilingualTest.php new file mode 100644 index 0000000..60cac03 --- /dev/null +++ b/core/modules/system/src/Tests/Installer/InstallerExistingConfigMultilingualTest.php @@ -0,0 +1,24 @@ +translations['Save and continue'] = 'Enregistrer et continuer'; + parent::setUpSite(); + } + + /** + * @inheritDoc + */ + protected function getConfigTarball() { + return __DIR__ . '/../../../tests/fixtures/config_install/testing_config_install.tar.gz'; + } + +} diff --git a/core/modules/system/src/Tests/Installer/InstallerExistingConfigTestBase.php b/core/modules/system/src/Tests/Installer/InstallerExistingConfigTestBase.php new file mode 100644 index 0000000..e062452 --- /dev/null +++ b/core/modules/system/src/Tests/Installer/InstallerExistingConfigTestBase.php @@ -0,0 +1,98 @@ +getConfigTarball(), 'gz'); + + $core_extension = Yaml::decode($archiver->extractInString('core.extension.yml')); + $this->profile = $core_extension['profile']; + + // Create a profile for testing. + $info = [ + 'type' => 'profile', + 'core' => \Drupal::CORE_COMPATIBILITY, + 'name' => 'Configuration installation test profile (' . $core_extension['profile'] . ')', + ]; + // File API functions are not available yet. + $path = $this->siteDirectory . '/profiles/' . $core_extension['profile']; + mkdir($path, 0777, TRUE); + file_put_contents("$path/{$core_extension['profile']}.info.yml", Yaml::encode($info)); + + // Create config/sync directory and extract tarball contents to it. + $config_sync_directory = $path . '/config/sync'; + mkdir($config_sync_directory, 0777, TRUE); + $files = []; + $list = $archiver->listContent(); + if (is_array($list)) { + /** @var array $list */ + foreach ($list as $file) { + $files[] = $file['filename']; + } + } + $archiver->extractList($files, $config_sync_directory); + + parent::setUp(); + } + + /** + * Gets the filepath to the configuration tarball. + * + * The tarball will be extracted to the install profile's config/sync + * directory for testing. + * + * @return string + * The filepath to the configuration tarball. + */ + abstract protected function getConfigTarball(); + + /** + * {@inheritdoc} + */ + protected function installParameters() { + $parameters = parent::installParameters(); + + // The options that change configuration are disabled when installing from + // existing configuration. + unset($parameters['forms']['install_configure_form']['site_name']); + unset($parameters['forms']['install_configure_form']['site_mail']); + unset($parameters['forms']['install_configure_form']['update_status_module']); + + return $parameters; + } + + /** + * Confirms that the installation installed the configuration correctly. + */ + public function testConfigSync() { + // After installation there is no snapshot and nothing to import. + $change_list = $this->configImporter()->getStorageComparer()->getChangelist(); + $expected = [ + 'create' => [], + // The system.mail is changed configuration because the test system + // changes it to ensure that mails are not sent. + 'update' => ['system.mail'], + 'delete' => [], + 'rename' => [], + ]; + $this->assertEqual($expected, $change_list); + } + +} diff --git a/core/modules/system/tests/fixtures/config_install/multilingual.tar.gz b/core/modules/system/tests/fixtures/config_install/multilingual.tar.gz new file mode 100644 index 0000000..d43aafa --- /dev/null +++ b/core/modules/system/tests/fixtures/config_install/multilingual.tar.gz @@ -0,0 +1,52 @@ +",X}r8fSp:QѷX&,˷n(@(q3m0`* +E5 # >M-RN>u^;!?t$K/!ԑ +aAx2Cp~wr":hhɭȏ,7~z0)~ʯפ( BR 2\|t3,SBNɓBli!(}>tt|;K  5^1XA4 Ce5j ?C0yyzQ,K-Q+OaU~#1̦_cvJUc eXr5OX1VgOESԋ4 Zrn7JH~ni+C'-y#Das(BE?+J9@g@82Ԁ!hDSuIo&aMP(BX%Q9eœN]ϹvcK_l}vb` MwJ^Ku~6FT=+t}(kߛRC)tK,dBH4pup'BO8ëy-Ôo.s)\ z%(lHU~ǬIUoOژ:i >=| -t, py:+,Aֱ1(2@HҿLtyeCs@wKp|*k٫kfAN{M +f?>EŧK_1 {? ?hϳP aM@CJs(GY;5yao"ÇyStx OL!$g,bip< p_?$74 *f$Uu14Ry/.6mzlyc3m.wJyᇝ`MU 8<3|??*D<'uej(kxnUJצ5D{{qumz*6Iܗ?w%h^:h +w ?@p$^Yl +iQ5h:QJYm;Za d@!t |/50Yeb_ +4uovS{{?USv:mP1bgS&FSnK^r_ g?7$ 8ZUӈ @!/2:˫~L7hb67ف R#kP8Y /8~QGaq-mctW>cZ*{6@fl+Z:DEܩ; n5VAs>if YV&' F4.l "ϐ濨Iʲ`H?o7rʙWǏTлɎռbNáC YXw`D.vH{%._ Gs@VI?jh"PETfSﱆRyi|P[O'ж +w;zos&~?$wFL֐,&@9I߸AuYl]׺T3R/Uњt.f] s= G~]D?kc?]Z>#Ck$H:0bhRu {MQ%>M]<  +9I`:^[x!f3<iO%` B+m<|fU_xhw3=ޢg'sNrs2U͵=a-0]tngUONf =5P7!kD%7KL];meq|_-ЙJS-<|OJ|[?sa?$W+@t"TNꀪiuQ!Ue`ܷ}ieԁ` +>(eX՞qj< "'0^H /L$&E%6SrDh u0_JHi$]9ĝ2D* _e l,NL$#?Y}G#[bnqՇcyNQkEcol[Z XL84#E s )}S?2cƙjGK.vA3fcTP\0+C}?~Dԟ~?? K{ Ӂm#Y`5CfVn=6ꥶZp,/UAoXtLEP0F\bX8$ )4K +,Wzuqw[5bqz_8RidDt.H>Œu?bw\> R$hIU 0Sg5E#1y=A6+|?vdox.{w43n\NkˀGrb$sJ?EuӁIp}$X0W=<f^qM;kR.'s"<?| th 8sϚ qGL8CVaSKj馏4iI/9LRp(`P[N04i#&U"W!>ɔY׳ ?(8w" 5]PE ^/"ȊbE!j$£_Cq2Dwز2$\j*|6~P'ɀ~zGf,NOzL= }tXI>AXA/UJ{Sw +q֯% + +ʑo]}Ǖvm^f|ywqN]Hj?fW XXeV S5``]OUn\zzq0JA*ۙcڣjPUV7y!#2^^^?6Y5~r?6>96~.Xw +;4?eM"u,n5_Gor/ ߙ^9fXǶ~@D@9ziދ/FV,7`.~T8C}.i?3 Dm9! rG=ng9:3j/Edȏ?6Q ?A]٥ӡ'&WS&+ /e?F񟱽'x_׵r-GI'$>3:j*VQ!4LdUˑ r⾯p{t5_wC +zɯZ(ςM'ΠFv{AZݢ;1+W#O3(z>s$}:y!3{%QL` c30Cm_M]ᮃ.>Ya, bw)݀BeG JQU<@DPx?^?yύo1R?f9J3Q57[cO iG? +_&H~|'&cO(yLY.Upon.~"|&ۋ.yl HWx@e"F4)1?ZŇot#_[~m]PӧA ;IZB9Dv~z|&ew|ig^h1G +CP@H6Ǭ Vm{yLGn?!&"-J+BZ%IW~u[)þ9ڈk nTbMe-ߔJ4'2O_)Z^Gn w0V>sgtWT`E yD&9nwN(!:Kh.BmA A3fW>xhC^y+{濍E?=l_;a~$e&ACsu9#g4Mv +lw@#l.qvZ ;(x.9/CR?*zרGBZǻ+Ya޶ }yoEq +4}h8WK7I#Ü/PaV&nju3YxFY σ]ΕދtTfܕLaq{h ufn~橨_Ś$'Ŧ~1jl5ϋg7ܪP<2<+y! ߙ׍mJm۸m +FĉZ޵eύb>*xԇg?A$_8:_ϫd ˜ĨE"0yh*+^Qzi`T{Dԟ,=f3Z*KykI]Kt> :##rX*s@*U)ZLC|ۗ_5878N?}Y*m R{L8s PE W `y;fhV⢬#AƝz37m\v9i #[f9@[dJ`da4K +%N#j@Y 'm Qԩ͹5Yc1OBlY ~ O ^q-w?e"3@gϨ@dE]- +GXm>U-Z4lpY]Y3ʬX_56~oa$ N??Gvϲ b(ʐg ȼFYCU^w//޲ctF ]4gS;B]aӿ{'_kP$gp =$+k$uDp CH@ <iK X8*CԿUԊi-SU o:W,HzGv(۠}~?] R|j_PP ߔ/㊂9G.u!Eҟ|dw}tdYpC4[M2p=q,ar?##׊79)2}d#' 3;3B1+iO"҇BBv/vo,HGKعz0ȅEHO~9fo7 6s,!٧L)*]D7QO4-E鍖 {lC MU1jTVUjKSsߌM43\/+d8e:(XϮ}`ZiTezбhLk@  r 6naH[u^ρ <_|?@rg hy `5NChIpx\;`{D9md}<ܴ#{fBRW)/XBv9K0BQ[J( D&nǏNpp𢔏`)bKҘ\_w;KT}Xݶ~u>li~3A +A.9:3;1>3%AEJ3ӬP3(#Os}Sg6Ho!>'+"4>6d|dԍ88D/ڸ`h\bg̲4}.B. M_,x=%R4 a}ؽD+F]Yqe?)OvJOJӻ?6 +x$ +1e>rBkAҮ!TQȍ,RE"CW>:#LQ듿> ol"mڃiI^s^Gq%Ų#u?3rљ]??Hx +@F +x(C H4yMc #qgtubiRp;Ò^+3}Ӟ޻O{!L{ޚ$|D$q&Sg3I{?+&HBCAHU"C:-(d'"H +z&/W+eѼ zë{v՛b]QXt^M㞹bA$z b?=_& 'ϋo&H@'Z0u2'@&+·zRYC#VnڵqXV^E*TI\ JrMvl8c]?1>c' etWE$,CVƚhUG<5Ѡwl rcýϯDF]bAraqgn5ʁSs}WA΅!lJgiof +i@3Y 1&Jn0c!q$0ݱoyKhA +~ဧyRs;?aߐz>o%_=guh_W|૩_հ_UUh<7I&F4׬]7|f؋-ET6Q~Fӿ [??~ BOvvTQ]}3ب~x[$ +Eod#nwِ)'X %T-4r42;'k ?Ab"ij +hMEրI'I&v4 KWg,tI`CH^Ez|L>8(ꜬȞ㯘IA-LMmd&ќ3"m < +ȍYFu10Q֓^W'-~)gCU@_wA 8ƗGm3mDF󩣟٦滁kg:a}ȷV;*  #?LB$QK°x} H~L]ON?`:bD hr03ݝ}8"b{u:9ZOnߨ-Ӗ7rמ i֑\mkf/%FF?_ڟ +@ 'iYx~0b %=n +jQQ!_"u4}s;yd>6f͢[_xx3ͪ~,wH1/~,=?*(2- _ VH,xC#>c j6B>H+aqF] Z'-L?tGC3IdGdԻzE]>oWn=iׯGaΨ(~q}qgf|㤘ΈȌ'[\da&Y] yϗ}qv/ܿ}w +A8=j{o Y{OCtb2vQD|I ϱ{ Sg 6N՝Q ͍1.96_4DA"+DDSQBӉ{ކp^_;įqoJKkG>}P[x" +֏odyuO.~HygC=Jso:/ xIX5;%7>ObQ[q#{7vųӍ<֟^W$~ƪ5jSm\'[8} 63[7))! L]>}LI̶a¸RIjZ\+נZ6V~i_/O"ː4%D)& Dc(3-pS:C!k|)l6{fo%'04 Y^uhg02Hy Ӵ|eA9:~Ad/DW:r#[3shm})h|=Fvx1n;zhK0@M+a+EsIG @Wd +lNY#{xіr&sS ekOniWFX*>YrML߸W3dMr=gy7;+ +&%tܜ&˕ g~g&cAY7,n|-}s*\do"ДAGO 𳡮edWzO?@3iʪ +8(D(7\6՜Lwme7gF^(N1b"W :bDMb^kDiʬ~9 _(/+wǼjCrg> Gh)P#2?8evP4-%?˩LQ)p"0T9İ&߀oz0 ͵|PvO~S"? 9:}o熖Pv|ٛo~g7Z 9"뙤.Sx΃ŧ"i1P9GPU#JP[q] 8p{_\TKE{8D3G.{߆"IHJgo7-a_NJ1bPǫt-h߆@߽S{װZv7rY|5T*HsԷ< X qW +o ސ6KchϚ e(vcyC8>sM?}H3tKPV $d i h^5d9RGV|kkKMӅGC%^[`oA>O6F|k=f C ,t. p/rOC(#JMH0ɺJG =M;KήSs1*i=SO\o}3?谁w <+WXg:LKPYEIx 18MrD #j-{/b39|s4 RZqui:=~Ѥaop7G|^Ջ?i/鹖$5+'~ߊӽֱz7-KXO?+T簊o@^3NYʢ !RCӨSsGV/w K1 3m.ub~ٚmNc{)Vsn1bV2%=Z+,' @i ُCc,gp)oU<ŋ4dYT + sVi}M4OMB!T hF[=fʵkW3{` mL2<_9 hAcGC"P*k:{)΍5{)7˺6xtG˼P<9-3U owOP7H_Kg=zcg[|RJ `@l ƞ/C*JWoWc3H:T|C:+RĔௐwV)ڏ?O("ҢJ +hIC. !q.Y_S:0h.xWֽ,,r`)еE8,ↆ̾lsy?Bg4~Oө"H!MP ?CSy@xU #Qd=<)bLeneVy--.FK}Ywd6'L.ny{M^A/B$@q# HM(Ұv{fgqR_k,-a]pz:@oBRPW5 1XxD 3 eAFO; g~[Ϗsj5F;45̵]PCn[!xo9 O ߅(?528h4[ Ŀӗ ީw˰?i-\q2{Ԍ\ű7vBS/f 7{txy}_`wJDA((KY<"eXY8Z"Je;ןK)|6_h̒”Z?ف," &%Ǐ}_PQ1y`@t0*S~#~A[sy~-cmP/=^qŧ#3C}ws%Qp1u{$V`4?5,ƿMe]$+I*/PFNxP4?o9[^P. tlo2SiзkD//"ROx|/"j*$' 3x@ +F ^PRPOr₆q~qhM Sc4Ylt=Z)濇 Q?͋"pX7(h2-ckF(iV3x*S5omީF`M:BMm͠u@+jD:(ǿ|~8f_g(JL -YdSuFd+ſP`Xge^!m)oԉ=Zyݲ/ /McrC?@?slJ 2YC3XySf hRt=z=Pa3[*ᐭwE1p4NM:#]a/;/0ZBfN5R$뒬 GE:zcC \jh)nJ?ϕ'_S;xٷ3iPD -08 CaN_ ƍrQ1pl?ۑ2z U<iM[o2v]WꡩHQaAdQ<mjƝ% ģ C:8#n3'}e%Efsz{pQᅣO[y( נ4{moZWH\GPհr^R/2 j/hQ L|@Q?b"#O~]16V\q_N z0lFw_:chڻ>$teA @[T/r$t'˱VŊQϛ}nͳUz^NHkOuTZ2E\*~[rjD +g C8؞ + C,<4vƫu[!c]qߧuF\֏|.& ܛ_ֽЅֵ#4u_y)I(# e\nv+N#qݨB$]T_WAB^`(+z$7(7[4ݒ,jI6bq0)**KtRHSO 1qO`j|K{[rA`X&`6t0,K3(85,Q-Di\cXv?ˤ뿋Pa AuQd^PX rx('/sjXB +k0|( n۰s߹UGu.n"[6?M_x#l}c8"GfE(@eRS)ր1ip6Rta=>}]:cRΥ?ϥKgxE0 M%Ve&PRB=18r.t!*u~>v\5ÕM=>]#78z(`o^Ϥ_4+B^K48,*ͩT?t# h(N!_/=aHņ +cȞt[ (ߥW9>.B1eiˤ!IUP +d@A?{ߑ|_>εྌP+BaQPZT8z]kU=U"·e?x}f N]19 +YAe`rP} *?,#yoW-C-eӵh3(x?>I8Qs?}J,Ҫ85I4Ugi8^HKg廳EYZK)w;-e͜YП_:u1~.Iy%*D#4 gIPǀ7h p cM@"@3T]G ~ w6ڃg*3nܪ oox'uuC>] 9 (;~+I]J1! +^m YgGЬ"ZsLk `yDN4 8q{=jѴQ5kłˊz.V T[O7C 5 ]Iʸ]Q +C+~OZHPi *%BVt +٪g65Il͖0Ѕ4F"Tg۶86OT;ׅ~D+%c !ll8 8VZ4ϛR"ͣ|i#_ԣiY7aJw=yWV$|RNOYUb~7MǠQO?/Bвb>B)k9ԧu-NmkWQR- $dݫwe 8+QY;b=6w²g UVʓr@P__8r sRsLϟQAgNL$L +Zwڃ;Ty^C45y@;Qsq~,au ]䚓Y0kQ@f߇CRi(XxUHȌ)6G=uCCiL·^bNEAf M̌^隬t3P14Oi -NI +a#1ԡQg>*/z1\Zv$AZ>V[I? Reh|?2}8 HlmTs蘇-x~F5י +4:Hc|ˮ 8y~hUƝlWPE1[JgnR|xRQcWcچ#]?>#xM.B1]fJҺ +xce4o0M 4 5? h6O.{F쁑H gn5 f?KB?F'{S`gSPY@k2?P@bX41h>+s +>e>UiIdJ>,ՆRx?m܃H%mg@- LmEt!s1dcAKu$`ȋVLagSE(W'c8v+͏]!_|1(ő G_zũ\[4~'mfjoͰi}7oe7_MR5+${ȋ-+>d(^6j/_?SuL mq/_./pw_[=_( +D>3vefظAgR$ؿ|u_r5Eo2_^WӾ1\fHoljYWn%rA emٗjMcV1ǹE^hլ^e+G?!NeGY13Ezש_Ms|Iuág>h;*  B/!MRY\S#A%֒+\}6 ,F/} {W"s6&piiV|arڶ9O-?.CѬad@FG!X >W䅿P+ rz`6h=;zSF?5`n{Uf)|SPt/kzg72:ArM7rM3Ӳq]`L#x@|Iz4G  ?TH o{r);׹z~@?s<]o}я`8щ,O9/~,]y7 +? UFbȹEr!c0CQ<*VO:S֬yq×of&I1gqz>jAl} :+pQ g=bdD|kH:WE?Qw4lw7N ꌐX<ՒtN㍙': yțӞ(gK|lG9Sh)IS=͐VEo~WG垄Cte \Ϲ)׵m[|߮z&WY]uFޱ5Ň+H$BDQ)Jhm j:! |?G<׈G>}dy" +֏HxyuEO.zH{! "[R?'R /ՀgI ϓXa$5.F6Zk<{l Fr]nc54D#cm֜)n'aWzTl p2-eyT`E;Td^@GBsJh  C3t:KIuC^OJMMZ`w:rWd`K'u`vO!EE}؊Q@ W7?|&m?u1:fQ8>qJ<-p UX4%