diff --git a/.editorconfig b/.editorconfig index 242859d..686c443 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,5 +13,5 @@ charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -[composer.json] +[composer.{json,lock}] indent_size = 4 diff --git a/composer.json b/composer.json index c093cce..02690c6 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "wikimedia/composer-merge-plugin": "~1.3" }, "replace": { - "drupal/core": "~8.3" + "drupal/core": "~8.4" }, "minimum-stability": "dev", "prefer-stable": true, @@ -50,8 +50,7 @@ "scripts": { "pre-autoload-dump": "Drupal\\Core\\Composer\\Composer::preAutoloadDump", "post-autoload-dump": [ - "Drupal\\Core\\Composer\\Composer::ensureHtaccess", - "Drupal\\Core\\Composer\\Composer::configurePhpcs" + "Drupal\\Core\\Composer\\Composer::ensureHtaccess" ], "post-package-install": "Drupal\\Core\\Composer\\Composer::vendorTestCodeCleanup", "post-package-update": "Drupal\\Core\\Composer\\Composer::vendorTestCodeCleanup" diff --git a/composer.lock b/composer.lock index 6941eda..0b63316 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "fb766841005ecf4b3ec4ecd8b4c98df4", + "content-hash": "47cf4b2b460c00b55b2533e8caa6df19", "packages": [ { "name": "asm89/stack-cors", @@ -2420,29 +2420,30 @@ }, { "name": "twig/twig", - "version": "v1.25.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "f16a634ab08d87e520da5671ec52153d627f10f6" + "reference": "9935b662e24d6e634da88901ab534cc12e8c728f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/f16a634ab08d87e520da5671ec52153d627f10f6", - "reference": "f16a634ab08d87e520da5671ec52153d627f10f6", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/9935b662e24d6e634da88901ab534cc12e8c728f", + "reference": "9935b662e24d6e634da88901ab534cc12e8c728f", "shasum": "" }, "require": { "php": ">=5.2.7" }, "require-dev": { + "psr/container": "^1.0", "symfony/debug": "~2.7", - "symfony/phpunit-bridge": "~2.7" + "symfony/phpunit-bridge": "~3.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.25-dev" + "dev-master": "1.32-dev" } }, "autoload": { @@ -2477,7 +2478,7 @@ "keywords": [ "templating" ], - "time": "2016-09-21T23:05:12+00:00" + "time": "2017-02-27T00:07:03+00:00" }, { "name": "wikimedia/composer-merge-plugin", diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 0000000..0ee3d56 --- /dev/null +++ b/core/.gitignore @@ -0,0 +1,7 @@ +# Ignore node_modules folder created when installing core's JavaScript +# dependencies. +node_modules + +# Ignore overrides of core's phpcs.xml.dist and phpunit.xml.dist. +phpcs.xml +phpunit.xml diff --git a/core/MAINTAINERS.txt b/core/MAINTAINERS.txt index 6b9e833..0bb060d 100644 --- a/core/MAINTAINERS.txt +++ b/core/MAINTAINERS.txt @@ -87,7 +87,6 @@ Base system - ? Basic Auth -- Klaus Purer 'klausi' https://www.drupal.org/u/klausi - Juampy Novillo Requena 'juampy' https://www.drupal.org/u/juampy Batch API @@ -340,7 +339,6 @@ Request Processing - Larry Garfield 'Crell' https://www.drupal.org/u/crell REST -- Klaus Purer 'klausi' https://www.drupal.org/u/klausi - Larry Garfield 'Crell' https://www.drupal.org/u/crell - Wim Leers 'Wim Leers' https://www.drupal.org/u/wim-leers @@ -391,7 +389,6 @@ Telephone Testing framework - Alex Pott 'alexpott' https://www.drupal.org/u/alexpott - Sascha Grossenbacher 'Berdir' https://www.drupal.org/u/berdir -- Klaus Purer 'klausi' https://www.drupal.org/u/klausi - Daniel Wehner 'dawehner' https://www.drupal.org/u/dawehner Text Field @@ -481,11 +478,11 @@ Workflow Initiative - Dick Olsson 'dixon_' https://www.drupal.org/u/dixon_ PHPUnit Initiative -- Klaus Purer 'klausi' https://www.drupal.org/u/klausi - Daniel Wehner 'dawehner' https://www.drupal.org/u/dawehner Layout Initiative - Tim Plunkett 'tim.plunkett' https://www.drupal.org/u/tim.plunkett +- Emilie Nouveau 'DyanneNova' https://www.drupal.org/u/dyannenova Media Initiative - Janez Urevc 'slashrsm' https://www.drupal.org/u/slashrsm diff --git a/core/composer.json b/core/composer.json index 4cdea59..82ea0e6 100644 --- a/core/composer.json +++ b/core/composer.json @@ -34,6 +34,9 @@ "paragonie/random_compat": "^1.0|^2.0", "asm89/stack-cors": "~1.0" }, + "conflict": { + "drush/drush": "<8.1.10" + }, "require-dev": { "behat/mink": "1.7.x-dev", "behat/mink-goutte-driver": "~1.2", @@ -41,7 +44,7 @@ "jcalderonzumba/gastonjs": "~1.0.2", "jcalderonzumba/mink-phantomjs-driver": "~0.3.1", "mikey179/vfsStream": "~1.2", - "phpunit/phpunit": ">=4.8.28 <5", + "phpunit/phpunit": ">=4.8.35 <5", "symfony/browser-kit": ">=2.8.13 <3.0", "symfony/css-selector": "~2.8" }, @@ -172,8 +175,7 @@ "scripts": { "pre-autoload-dump": "Drupal\\Core\\Composer\\Composer::preAutoloadDump", "post-autoload-dump": [ - "Drupal\\Core\\Composer\\Composer::ensureHtaccess", - "Drupal\\Core\\Composer\\Composer::configurePhpcs" + "Drupal\\Core\\Composer\\Composer::ensureHtaccess" ] } } diff --git a/core/config/schema/core.data_types.schema.yml b/core/config/schema/core.data_types.schema.yml index eeac1ae..bb4993a 100644 --- a/core/config/schema/core.data_types.schema.yml +++ b/core/config/schema/core.data_types.schema.yml @@ -46,7 +46,7 @@ mapping: sequence: label: Sequence class: '\Drupal\Core\Config\Schema\Sequence' - definition_class: '\Drupal\Core\TypedData\ListDataDefinition' + definition_class: '\Drupal\Core\Config\Schema\SequenceDataDefinition' # Simple extended data types: diff --git a/core/core.link_relation_types.yml b/core/core.link_relation_types.yml index 88fea0e..fb34027 100644 --- a/core/core.link_relation_types.yml +++ b/core/core.link_relation_types.yml @@ -79,6 +79,9 @@ create-form: current: description: "Refers to a resource containing the most recent item(s) in a collection of resources." reference: '[RFC5005]' +customize-form: + description: "The target URI points to a resource where a submission form for customizing associated resource can be obtained." + reference: '[RFC6861]' derivedfrom: description: 'The target IRI points to a resource from which this material was derived.' reference: '[draft-hoffman-xml2rfc]' diff --git a/core/core.services.yml b/core/core.services.yml index 40c6a8a..e4d7155 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -303,7 +303,7 @@ services: - { name: event_subscriber } config.installer: class: Drupal\Core\Config\ConfigInstaller - arguments: ['@config.factory', '@config.storage', '@config.typed', '@config.manager', '@event_dispatcher', '%install_profile%'] + arguments: ['@config.factory', '@config.storage', '@config.typed', '@config.manager', '@event_dispatcher', '%install_profile%', '@profile_handler'] lazy: true config.storage: class: Drupal\Core\Config\CachedStorage @@ -331,9 +331,11 @@ services: arguments: ['@config.storage', 'config/schema', '', true, '%install_profile%'] config.typed: class: Drupal\Core\Config\TypedConfigManager - arguments: ['@config.storage', '@config.storage.schema', '@cache.discovery', '@module_handler'] + arguments: ['@config.storage', '@config.storage.schema', '@cache.discovery', '@module_handler', '@class_resolver'] tags: - { name: plugin_manager_cache_clear } + calls: + - [setValidationConstraintManager, ['@validation.constraint']] context.handler: class: Drupal\Core\Plugin\Context\ContextHandler arguments: ['@typed_data_manager'] @@ -519,6 +521,9 @@ services: - { name: module_install.uninstall_validator } arguments: ['@string_translation'] lazy: true + profile_handler: + class: Drupal\Core\Extension\ProfileHandler + arguments: ['@app.root', '@info_parser'] theme_handler: class: Drupal\Core\Extension\ThemeHandler arguments: ['@app.root', '@config.factory', '@module_handler', '@state', '@info_parser'] @@ -1230,8 +1235,8 @@ services: tags: - { name: event_subscriber } arguments: ['@http_kernel', '@logger.channel.php', '@redirect.destination', '@router.no_access_checks'] - exception.default: - class: Drupal\Core\EventSubscriber\DefaultExceptionSubscriber + exception.final: + class: Drupal\Core\EventSubscriber\FinalExceptionSubscriber tags: - { name: event_subscriber } arguments: ['@config.factory'] diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc index cfc0497..bae5acf 100644 --- a/core/includes/install.core.inc +++ b/core/includes/install.core.inc @@ -443,6 +443,12 @@ function install_begin_request($class_loader, &$install_state) { if (isset($install_state['profile_info']['distribution']['install']['theme'])) { $install_state['theme'] = $install_state['profile_info']['distribution']['install']['theme']; } + // Ensure all profile directories are registered. + $profiles = \Drupal::service('profile_handler')->getProfiles($profile); + $profile_directories = array_map(function($extension) { + return $extension->getPath(); + }, $profiles); + $listing->setProfileDirectories($profile_directories); } // Use the language from the profile configuration, if available, to override @@ -1210,6 +1216,8 @@ function install_select_profile(&$install_state) { * - For non-interactive installations via install_drupal() settings. * - A discovered profile that is a distribution. If multiple profiles are * distributions, then the first discovered profile will be selected. + * If an inherited profile is detected that is a distribution, it will be + * chosen over its base profile. * - Only one visible profile is available. * * @param array $install_state @@ -1232,11 +1240,8 @@ function _install_select_profile(&$install_state) { } } // Check for a distribution profile. - foreach ($install_state['profiles'] as $profile) { - $profile_info = install_profile_info($profile->getName()); - if (!empty($profile_info['distribution'])) { - return $profile->getName(); - } + if ($distribution = \Drupal::service('profile_handler')->selectDistribution(array_keys($install_state['profiles']))) { + return $distribution; } // Get all visible (not hidden) profiles. @@ -1569,7 +1574,10 @@ function install_profile_themes(&$install_state) { * An array of information about the current installation state. */ function install_install_profile(&$install_state) { - \Drupal::service('module_installer')->install([drupal_get_profile()], FALSE); + // Install all the profiles. + $profiles = \Drupal::service('profile_handler')->getProfiles(); + \Drupal::service('module_installer')->install(array_keys($profiles), FALSE); + // Install all available optional config. During installation the module order // is determined by dependencies. If there are no dependencies between modules // then the order in which they are installed is dependent on random factors diff --git a/core/includes/install.inc b/core/includes/install.inc index c5b93b5..ac4b265 100644 --- a/core/includes/install.inc +++ b/core/includes/install.inc @@ -1043,6 +1043,14 @@ function drupal_check_module($module) { * - install: Optional parameters to override the installer: * - theme: The machine name of a theme to use in the installer instead of * Drupal's default installer theme. + * - base profile: Existence of this key denotes that the installation profile + * depends on a parent installation profile. + * - name: The shortname of the base installation profile. + * - excluded_dependencies: An array of shortnames of other modules that have + * to be excluded from the base profile requirements. This allows e.g. to + * disable a demo module that would be installed by the base profile. + * If there are no excluded_dependencies, a shortcut of "base profile: name" + * can be used. * * Note that this function does an expensive file system scan to get info file * information for dependencies. If you only need information from the info @@ -1069,18 +1077,7 @@ function install_profile_info($profile, $langcode = 'en') { $cache = &drupal_static(__FUNCTION__, []); if (!isset($cache[$profile][$langcode])) { - // Set defaults for module info. - $defaults = [ - 'dependencies' => [], - 'themes' => ['stark'], - 'description' => '', - 'version' => NULL, - 'hidden' => FALSE, - 'php' => DRUPAL_MINIMUM_PHP, - ]; - $profile_file = drupal_get_path('profile', $profile) . "/$profile.info.yml"; - $info = \Drupal::service('info_parser')->parse($profile_file); - $info += $defaults; + $info = \Drupal::service('profile_handler')->getProfileInfo($profile); // drupal_required_modules() includes the current profile as a dependency. // Remove that dependency, since a module cannot depend on itself. diff --git a/core/includes/theme.inc b/core/includes/theme.inc index 498c490..4cc6424 100644 --- a/core/includes/theme.inc +++ b/core/includes/theme.inc @@ -240,8 +240,7 @@ function drupal_find_theme_templates($cache, $extension, $path) { // Match templates based on the 'template' filename. foreach ($cache as $hook => $info) { if (isset($info['template'])) { - $template_candidates = [$info['template'], str_replace($info['theme path'] . '/templates/', '', $info['template'])]; - if (in_array($template, $template_candidates)) { + if ($template === $info['template']) { $implementations[$hook] = [ 'template' => $template, 'path' => dirname($file->uri), diff --git a/core/lib/Drupal.php b/core/lib/Drupal.php index f155480..1acfc12 100644 --- a/core/lib/Drupal.php +++ b/core/lib/Drupal.php @@ -81,7 +81,7 @@ class Drupal { /** * The current system version. */ - const VERSION = '8.3.0'; + const VERSION = '8.4.0-dev'; /** * Core API compatibility. diff --git a/core/lib/Drupal/Component/Plugin/Context/ContextDefinitionInterface.php b/core/lib/Drupal/Component/Plugin/Context/ContextDefinitionInterface.php index a829cec..c18a704 100644 --- a/core/lib/Drupal/Component/Plugin/Context/ContextDefinitionInterface.php +++ b/core/lib/Drupal/Component/Plugin/Context/ContextDefinitionInterface.php @@ -3,7 +3,9 @@ namespace Drupal\Component\Plugin\Context; /** - * Interface for context definitions. + * Interface used to define definition objects found in ContextInterface. + * + * @see \Drupal\Component\Plugin\Context\ContextInterface * * @todo WARNING: This interface is going to receive some additions as part of * https://www.drupal.org/node/2346999. diff --git a/core/lib/Drupal/Component/Plugin/Context/ContextInterface.php b/core/lib/Drupal/Component/Plugin/Context/ContextInterface.php index 9b7134f..605c658 100644 --- a/core/lib/Drupal/Component/Plugin/Context/ContextInterface.php +++ b/core/lib/Drupal/Component/Plugin/Context/ContextInterface.php @@ -3,7 +3,14 @@ namespace Drupal\Component\Plugin\Context; /** - * A generic context interface for wrapping data a plugin needs to operate. + * Provides data and definitions for plugins during runtime and administration. + * + * Plugin contexts are satisfied by ContextInterface implementing objects. + * These objects always contain a definition of what data they will provide + * during runtime. During run time, ContextInterface implementing objects must + * also provide the corresponding data value. + * + * @see \Drupal\Component\Plugin\Context\ContextDefinitionInterface */ interface ContextInterface { diff --git a/core/lib/Drupal/Core/Action/ConfigurableActionBase.php b/core/lib/Drupal/Core/Action/ConfigurableActionBase.php index cde7d56..3d372fe 100644 --- a/core/lib/Drupal/Core/Action/ConfigurableActionBase.php +++ b/core/lib/Drupal/Core/Action/ConfigurableActionBase.php @@ -17,7 +17,7 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition) { parent::__construct($configuration, $plugin_id, $plugin_definition); - $this->configuration += $this->defaultConfiguration(); + $this->setConfiguration($configuration); } /** @@ -38,7 +38,7 @@ public function getConfiguration() { * {@inheritdoc} */ public function setConfiguration(array $configuration) { - $this->configuration = $configuration; + $this->configuration = $configuration + $this->defaultConfiguration(); } /** diff --git a/core/lib/Drupal/Core/Composer/Composer.php b/core/lib/Drupal/Core/Composer/Composer.php index 1ca6262..bdb0127 100644 --- a/core/lib/Drupal/Core/Composer/Composer.php +++ b/core/lib/Drupal/Core/Composer/Composer.php @@ -6,7 +6,6 @@ use Composer\Script\Event; use Composer\Installer\PackageEvent; use Composer\Semver\Constraint\Constraint; -use PHP_CodeSniffer; /** * Provides static functions for composer script events. @@ -138,28 +137,6 @@ public static function ensureHtaccess(Event $event) { } /** - * Configures phpcs if present. - * - * @param \Composer\Script\Event $event - */ - public static function configurePhpcs(Event $event) { - // Grab the local repo which tells us what's been installed. - $local_repository = $event->getComposer() - ->getRepositoryManager() - ->getLocalRepository(); - // Make sure both phpcs and coder are installed. - $phpcs_package = $local_repository->findPackage('squizlabs/php_codesniffer', '*'); - $coder_package = $local_repository->findPackage('drupal/coder', '*'); - if (!empty($phpcs_package) && !empty($coder_package)) { - $config = $event->getComposer()->getConfig(); - $vendor_dir = $config->get('vendor-dir'); - // Set phpcs' installed_paths config to point to our coder_sniffer - // directory. - PHP_CodeSniffer::setConfigData('installed_paths', $vendor_dir . '/drupal/coder/coder_sniffer'); - } - } - - /** * Remove possibly problematic test files from vendored projects. * * @param \Composer\Installer\PackageEvent $event diff --git a/core/lib/Drupal/Core/Config/ConfigInstaller.php b/core/lib/Drupal/Core/Config/ConfigInstaller.php index c632aa8..bcb2144 100644 --- a/core/lib/Drupal/Core/Config/ConfigInstaller.php +++ b/core/lib/Drupal/Core/Config/ConfigInstaller.php @@ -6,6 +6,7 @@ use Drupal\Component\Utility\Unicode; use Drupal\Core\Config\Entity\ConfigDependencyManager; use Drupal\Core\Config\Entity\ConfigEntityDependency; +use Drupal\Core\Extension\ProfileHandlerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; class ConfigInstaller implements ConfigInstallerInterface { @@ -53,6 +54,13 @@ class ConfigInstaller implements ConfigInstallerInterface { protected $sourceStorage; /** + * The profile handler. + * + * @var \Drupal\Core\Extension\ProfileHandlerInterface + */ + protected $profileHandler; + + /** * Is configuration being created as part of a configuration sync. * * @var bool @@ -81,14 +89,17 @@ class ConfigInstaller implements ConfigInstallerInterface { * The event dispatcher. * @param string $install_profile * The name of the currently active installation profile. + * @param \Drupal\Core\Extension\ProfileHandlerInterface $profile_handler + * (optional) The profile handler. */ - public function __construct(ConfigFactoryInterface $config_factory, StorageInterface $active_storage, TypedConfigManagerInterface $typed_config, ConfigManagerInterface $config_manager, EventDispatcherInterface $event_dispatcher, $install_profile) { + public function __construct(ConfigFactoryInterface $config_factory, StorageInterface $active_storage, TypedConfigManagerInterface $typed_config, ConfigManagerInterface $config_manager, EventDispatcherInterface $event_dispatcher, $install_profile, ProfileHandlerInterface $profile_handler = NULL) { $this->configFactory = $config_factory; $this->activeStorages[$active_storage->getCollectionName()] = $active_storage; $this->typedConfig = $typed_config; $this->configManager = $config_manager; $this->eventDispatcher = $event_dispatcher; $this->installProfile = $install_profile; + $this->profileHandler = $profile_handler ?: \Drupal::service('profile_handler'); } /** @@ -466,12 +477,13 @@ public function checkConfigurationToInstall($type, $name) { // Check the dependencies of configuration provided by the module. list($invalid_default_config, $missing_dependencies) = $this->findDefaultConfigWithUnmetDependencies($storage, $enabled_extensions, $profile_storages); if (!empty($invalid_default_config)) { - throw UnmetDependenciesException::create($name, array_unique($missing_dependencies)); + throw UnmetDependenciesException::create($name, array_unique($missing_dependencies, SORT_REGULAR)); } // Install profiles can not have config clashes. Configuration that // has the same name as a module's configuration will be used instead. - if ($name != $this->drupalGetProfile()) { + $profiles = $this->profileHandler->getProfiles(); + if (!isset($profiles[$name])) { // Throw an exception if the module being installed contains configuration // that already exists. Additionally, can not continue installing more // modules because those may depend on the current module being installed. diff --git a/core/lib/Drupal/Core/Config/ExtensionInstallStorage.php b/core/lib/Drupal/Core/Config/ExtensionInstallStorage.php index 9103ab8..2ad1ae3 100644 --- a/core/lib/Drupal/Core/Config/ExtensionInstallStorage.php +++ b/core/lib/Drupal/Core/Config/ExtensionInstallStorage.php @@ -3,6 +3,7 @@ namespace Drupal\Core\Config; use Drupal\Core\Extension\ExtensionDiscovery; +use Drupal\Core\Extension\ProfileHandlerInterface; /** * Storage to access configuration and schema in enabled extensions. @@ -52,9 +53,11 @@ class ExtensionInstallStorage extends InstallStorage { * (optional) The current installation profile. This parameter will be * mandatory in Drupal 9.0.0. In Drupal 8.3.0 not providing this parameter * will trigger a silenced deprecation warning. + * @param \Drupal\Core\Extension\ProfileHandlerInterface $profile_handler + * (optional) The profile handler. */ - public function __construct(StorageInterface $config_storage, $directory = self::CONFIG_INSTALL_DIRECTORY, $collection = StorageInterface::DEFAULT_COLLECTION, $include_profile = TRUE, $profile = NULL) { - parent::__construct($directory, $collection); + public function __construct(StorageInterface $config_storage, $directory = self::CONFIG_INSTALL_DIRECTORY, $collection = StorageInterface::DEFAULT_COLLECTION, $include_profile = TRUE, $profile = NULL, ProfileHandlerInterface $profile_handler = NULL) { + parent::__construct($directory, $collection, $profile_handler); $this->configStorage = $config_storage; $this->includeProfile = $include_profile; if (is_null($profile)) { @@ -93,19 +96,11 @@ protected function getAllFolders() { $extensions = $this->configStorage->read('core.extension'); // @todo Remove this scan as part of https://www.drupal.org/node/2186491 - $listing = new ExtensionDiscovery(\Drupal::root()); + $listing = new ExtensionDiscovery(\Drupal::root(), TRUE, NULL, NULL, $this->profileHandler); if (!empty($extensions['module'])) { $modules = $extensions['module']; // Remove the install profile as this is handled later. unset($modules[$this->installProfile]); - $profile_list = $listing->scan('profile'); - if ($this->installProfile && isset($profile_list[$this->installProfile])) { - // Prime the drupal_get_filename() static cache with the profile info - // file location so we can use drupal_get_path() on the active profile - // during the module scan. - // @todo Remove as part of https://www.drupal.org/node/2186491 - drupal_get_filename('profile', $this->installProfile, $profile_list[$this->installProfile]->getPathname()); - } $module_list_scan = $listing->scan('module'); $module_list = []; foreach (array_keys($modules) as $module) { @@ -126,18 +121,11 @@ protected function getAllFolders() { } if ($this->includeProfile) { - // The install profile can override module default configuration. We do - // this by replacing the config file path from the module/theme with the - // install profile version if there are any duplicates. - if ($this->installProfile) { - if (!isset($profile_list)) { - $profile_list = $listing->scan('profile'); - } - if (isset($profile_list[$this->installProfile])) { - $profile_folders = $this->getComponentNames([$profile_list[$this->installProfile]]); - $this->folders = $profile_folders + $this->folders; - } - } + // The install profile (and any parent profiles) can override module + // default configuration. We do this by replacing the config file path + // from the module/theme with the install profile version if there are + // any duplicates. + $this->folders += $this->getComponentNames($this->profileHandler->getProfiles($this->installProfile)); } } return $this->folders; diff --git a/core/lib/Drupal/Core/Config/InstallStorage.php b/core/lib/Drupal/Core/Config/InstallStorage.php index 9fea3df..fad317a 100644 --- a/core/lib/Drupal/Core/Config/InstallStorage.php +++ b/core/lib/Drupal/Core/Config/InstallStorage.php @@ -4,6 +4,7 @@ use Drupal\Core\Extension\ExtensionDiscovery; use Drupal\Core\Extension\Extension; +use Drupal\Core\Extension\ProfileHandlerInterface; /** * Storage used by the Drupal installer. @@ -48,6 +49,13 @@ class InstallStorage extends FileStorage { protected $directory; /** + * The profile handler used to find additional folders to scan for config. + * + * @var \Drupal\Core\Extension\ProfileHandlerInterface + */ + protected $profileHandler; + + /** * Constructs an InstallStorage object. * * @param string $directory @@ -56,9 +64,14 @@ class InstallStorage extends FileStorage { * @param string $collection * (optional) The collection to store configuration in. Defaults to the * default collection. + * @param \Drupal\Core\Extension\ProfileHandlerInterface $profile_handler + * (optional) The profile handler. */ - public function __construct($directory = self::CONFIG_INSTALL_DIRECTORY, $collection = StorageInterface::DEFAULT_COLLECTION) { + public function __construct($directory = self::CONFIG_INSTALL_DIRECTORY, $collection = StorageInterface::DEFAULT_COLLECTION, ProfileHandlerInterface $profile_handler = NULL) { parent::__construct($directory, $collection); + if (\Drupal::hasService('profile_handler')) { + $this->profileHandler = $profile_handler ?: \Drupal::service('profile_handler'); + } } /** @@ -151,21 +164,12 @@ protected function getAllFolders() { if (!isset($this->folders)) { $this->folders = []; $this->folders += $this->getCoreNames(); + // Get dependent profiles and add the extension components. + $this->folders += $this->getComponentNames($this->profileHandler->getProfiles()); // Perform an ExtensionDiscovery scan as we cannot use drupal_get_path() // yet because the system module may not yet be enabled during install. // @todo Remove as part of https://www.drupal.org/node/2186491 $listing = new ExtensionDiscovery(\Drupal::root()); - if ($profile = drupal_get_profile()) { - $profile_list = $listing->scan('profile'); - if (isset($profile_list[$profile])) { - // Prime the drupal_get_filename() static cache with the profile info - // file location so we can use drupal_get_path() on the active profile - // during the module scan. - // @todo Remove as part of https://www.drupal.org/node/2186491 - drupal_get_filename('profile', $profile, $profile_list[$profile]->getPathname()); - $this->folders += $this->getComponentNames([$profile_list[$profile]]); - } - } // @todo Remove as part of https://www.drupal.org/node/2186491 $this->folders += $this->getComponentNames($listing->scan('module')); $this->folders += $this->getComponentNames($listing->scan('theme')); diff --git a/core/lib/Drupal/Core/Config/Schema/ArrayElement.php b/core/lib/Drupal/Core/Config/Schema/ArrayElement.php index c507655..b38a94b 100644 --- a/core/lib/Drupal/Core/Config/Schema/ArrayElement.php +++ b/core/lib/Drupal/Core/Config/Schema/ArrayElement.php @@ -2,10 +2,12 @@ namespace Drupal\Core\Config\Schema; +use Drupal\Core\TypedData\ComplexDataInterface; + /** * Defines a generic configuration element that contains multiple properties. */ -abstract class ArrayElement extends Element implements \IteratorAggregate, TypedConfigInterface { +abstract class ArrayElement extends Element implements \IteratorAggregate, TypedConfigInterface, ComplexDataInterface { /** * Parsed elements. @@ -161,4 +163,25 @@ public function isNullable() { return isset($this->definition['nullable']) && $this->definition['nullable'] == TRUE; } + /** + * {@inheritdoc} + */ + public function set($property_name, $value, $notify = TRUE) { + $this->value[$property_name] = $value; + // Config schema elements do not make use of notifications. Thus, we skip + // notifying parents. + return $this; + } + + /** + * {@inheritdoc} + */ + public function getProperties($include_computed = FALSE) { + $properties = []; + foreach (array_keys($this->value) as $name) { + $properties[$name] = $this->get($name); + } + return $properties; + } + } diff --git a/core/lib/Drupal/Core/Config/Schema/Sequence.php b/core/lib/Drupal/Core/Config/Schema/Sequence.php index ce8dc1b..547969b 100644 --- a/core/lib/Drupal/Core/Config/Schema/Sequence.php +++ b/core/lib/Drupal/Core/Config/Schema/Sequence.php @@ -10,6 +10,12 @@ * * Read https://www.drupal.org/node/1905070 for more details about configuration * schema, types and type resolution. + * + * Note that sequences implement the typed data ComplexDataInterface (via the + * parent ArrayElement) rather than the ListInterface. This is because sequences + * may have named keys, which is not supported by ListInterface. From the typed + * data API perspective sequences are handled as ordered mappings without + * metadata about existing properties. */ class Sequence extends ArrayElement { diff --git a/core/lib/Drupal/Core/Config/Schema/SequenceDataDefinition.php b/core/lib/Drupal/Core/Config/Schema/SequenceDataDefinition.php new file mode 100644 index 0000000..c317e29 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Schema/SequenceDataDefinition.php @@ -0,0 +1,28 @@ +definition['orderby']) ? $this->definition['orderby'] : NULL; + } + +} diff --git a/core/lib/Drupal/Core/Config/StorableConfigBase.php b/core/lib/Drupal/Core/Config/StorableConfigBase.php index 4b227c9..0751e9f 100644 --- a/core/lib/Drupal/Core/Config/StorableConfigBase.php +++ b/core/lib/Drupal/Core/Config/StorableConfigBase.php @@ -3,6 +3,8 @@ namespace Drupal\Core\Config; use Drupal\Core\Config\Schema\Ignore; +use Drupal\Core\Config\Schema\Sequence; +use Drupal\Core\Config\Schema\SequenceDataDefinition; use Drupal\Core\TypedData\PrimitiveInterface; use Drupal\Core\TypedData\Type\FloatInterface; use Drupal\Core\TypedData\Type\IntegerInterface; @@ -210,6 +212,29 @@ protected function castValue($key, $value) { foreach ($value as $nested_value_key => $nested_value) { $value[$nested_value_key] = $this->castValue($key . '.' . $nested_value_key, $nested_value); } + + if ($element instanceof Sequence) { + $data_definition = $element->getDataDefinition(); + if ($data_definition instanceof SequenceDataDefinition) { + // Apply any sorting defined on the schema. + switch ($data_definition->getOrderBy()) { + case 'key': + ksort($value); + break; + + case 'value': + // The PHP documentation notes that "Be careful when sorting + // arrays with mixed types values because sort() can produce + // unpredictable results". There is no risk here because + // \Drupal\Core\Config\StorableConfigBase::castValue() has + // already cast all values to the same type using the + // configuration schema. + sort($value); + break; + + } + } + } } return $value; } diff --git a/core/lib/Drupal/Core/Config/TypedConfigManager.php b/core/lib/Drupal/Core/Config/TypedConfigManager.php index 22795ad..bd5fa99 100644 --- a/core/lib/Drupal/Core/Config/TypedConfigManager.php +++ b/core/lib/Drupal/Core/Config/TypedConfigManager.php @@ -6,6 +6,7 @@ use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Config\Schema\ConfigSchemaAlterException; use Drupal\Core\Config\Schema\ConfigSchemaDiscovery; +use Drupal\Core\DependencyInjection\ClassResolverInterface; use Drupal\Core\Config\Schema\Undefined; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\TypedData\TypedDataManager; @@ -45,13 +46,18 @@ class TypedConfigManager extends TypedDataManager implements TypedConfigManagerI * The storage object to use for reading schema data * @param \Drupal\Core\Cache\CacheBackendInterface $cache * The cache backend to use for caching the definitions. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler. + * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver + * (optional) The class resolver. */ - public function __construct(StorageInterface $configStorage, StorageInterface $schemaStorage, CacheBackendInterface $cache, ModuleHandlerInterface $module_handler) { + public function __construct(StorageInterface $configStorage, StorageInterface $schemaStorage, CacheBackendInterface $cache, ModuleHandlerInterface $module_handler, ClassResolverInterface $class_resolver = NULL) { $this->configStorage = $configStorage; $this->schemaStorage = $schemaStorage; $this->setCacheBackend($cache, 'typed_config_definitions'); $this->alterInfo('config_schema_info'); $this->moduleHandler = $module_handler; + $this->classResolver = $class_resolver ?: \Drupal::service('class_resolver'); } /** @@ -184,6 +190,7 @@ protected function getDefinitionWithReplacements($base_plugin_id, array $replace $definition += [ 'definition_class' => '\Drupal\Core\TypedData\DataDefinition', 'type' => $type, + 'unwrap_for_canonical_representation' => TRUE, ]; return $definition; } diff --git a/core/lib/Drupal/Core/Database/database.api.php b/core/lib/Drupal/Core/Database/database.api.php index 885c6cb..45a5618 100644 --- a/core/lib/Drupal/Core/Database/database.api.php +++ b/core/lib/Drupal/Core/Database/database.api.php @@ -5,6 +5,8 @@ * Hooks related to the Database system and the Schema API. */ +use Drupal\Core\Database\Query\Condition; + /** * @defgroup database Database abstraction layer * @{ @@ -432,11 +434,11 @@ function hook_query_TAG_alter(Drupal\Core\Database\Query\AlterableInterface $que if (!\Drupal::currentUser()->hasPermission('bypass node access')) { // The node_access table has the access grants for any given node. $access_alias = $query->join('node_access', 'na', '%alias.nid = n.nid'); - $or = db_or(); + $or = new Condition('OR'); // If any grant exists for the specified user, then user has access to the node for the specified operation. foreach (node_access_grants($op, $query->getMetaData('account')) as $realm => $gids) { foreach ($gids as $gid) { - $or->condition(db_and() + $or->condition((new Condition('AND')) ->condition($access_alias . '.gid', $gid) ->condition($access_alias . '.realm', $realm) ); diff --git a/core/lib/Drupal/Core/DrupalKernel.php b/core/lib/Drupal/Core/DrupalKernel.php index db773ed..3f53d04 100644 --- a/core/lib/Drupal/Core/DrupalKernel.php +++ b/core/lib/Drupal/Core/DrupalKernel.php @@ -410,7 +410,7 @@ public static function findSitePath(Request $request, $require_settings = TRUE, * {@inheritdoc} */ public function setSitePath($path) { - if ($this->booted) { + if ($this->booted && $path !== $this->sitePath) { throw new \LogicException('Site path cannot be changed after calling boot()'); } $this->sitePath = $path; diff --git a/core/lib/Drupal/Core/Entity/ContentEntityBase.php b/core/lib/Drupal/Core/Entity/ContentEntityBase.php index 30f5c7a..afddb22 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityBase.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php @@ -830,6 +830,7 @@ protected function initializeTranslation($langcode) { $translation->translationInitialize = FALSE; $translation->typedData = NULL; $translation->loadedRevisionId = &$this->loadedRevisionId; + $translation->isDefaultRevision = &$this->isDefaultRevision; return $translation; } @@ -1095,7 +1096,7 @@ public function __clone() { // Ensure that the following properties are actually cloned by // overwriting the original references with ones pointing to copies of // them: enforceIsNew, newRevision, loadedRevisionId, fields, entityKeys, - // translatableEntityKeys and values. + // translatableEntityKeys, values and isDefaultRevision. $enforce_is_new = $this->enforceIsNew; $this->enforceIsNew = &$enforce_is_new; @@ -1117,6 +1118,9 @@ public function __clone() { $values = $this->values; $this->values = &$values; + $default_revision = $this->isDefaultRevision; + $this->isDefaultRevision = &$default_revision; + foreach ($this->fields as $name => $fields_by_langcode) { $this->fields[$name] = []; // Untranslatable fields may have multiple references for the same field @@ -1281,20 +1285,15 @@ public static function bundleFieldDefinitions(EntityTypeInterface $entity_type, * An array of field names. */ protected function getFieldsToSkipFromTranslationChangesCheck() { + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type */ + $entity_type = $this->getEntityType(); // A list of known revision metadata fields which should be skipped from // the comparision. - // @todo Replace the hard coded list of revision metadata fields with the - // solution from https://www.drupal.org/node/2615016. $fields = [ - $this->getEntityType()->getKey('revision'), + $entity_type->getKey('revision'), 'revision_translation_affected', - 'revision_uid', - 'revision_user', - 'revision_timestamp', - 'revision_created', - 'revision_log', - 'revision_log_message', ]; + $fields = array_merge($fields, array_values($entity_type->getRevisionMetadataKeys())); return $fields; } diff --git a/core/lib/Drupal/Core/Entity/ContentEntityType.php b/core/lib/Drupal/Core/Entity/ContentEntityType.php index 400374b..0e26c3b 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityType.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityType.php @@ -8,6 +8,13 @@ class ContentEntityType extends EntityType implements ContentEntityTypeInterface { /** + * An array of entity revision metadata keys. + * + * @var array + */ + protected $revision_metadata_keys = []; + + /** * {@inheritdoc} */ public function __construct($definition) { @@ -41,4 +48,44 @@ protected function checkStorageClass($class) { } } + /** + * {@inheritdoc} + */ + public function getRevisionMetadataKeys($include_backwards_compatibility_field_names = TRUE) { + // Provide backwards compatibility in case the revision metadata keys are + // not defined in the entity annotation. + if (!$this->revision_metadata_keys && $include_backwards_compatibility_field_names) { + $base_fields = \Drupal::service('entity_field.manager')->getBaseFieldDefinitions($this->id()); + if ((isset($base_fields['revision_uid']) && $revision_user = 'revision_uid') || (isset($base_fields['revision_user']) && $revision_user = 'revision_user')) { + @trigger_error('The revision_user revision metadata key is not set.', E_USER_DEPRECATED); + $this->revision_metadata_keys['revision_user'] = $revision_user; + } + if ((isset($base_fields['revision_timestamp']) && $revision_timestamp = 'revision_timestamp') || (isset($base_fields['revision_created'])) && $revision_timestamp = 'revision_created') { + @trigger_error('The revision_created revision metadata key is not set.', E_USER_DEPRECATED); + $this->revision_metadata_keys['revision_created'] = $revision_timestamp; + } + if ((isset($base_fields['revision_log']) && $revision_log = 'revision_log') || (isset($base_fields['revision_log_message']) && $revision_log = 'revision_log_message')) { + @trigger_error('The revision_log_message revision metadata key is not set.', E_USER_DEPRECATED); + $this->revision_metadata_keys['revision_log_message'] = $revision_log; + } + } + return $this->revision_metadata_keys; + } + + /** + * {@inheritdoc} + */ + public function getRevisionMetadataKey($key) { + $keys = $this->getRevisionMetadataKeys(); + return isset($keys[$key]) ? $keys[$key] : FALSE; + } + + /** + * {@inheritdoc} + */ + public function hasRevisionMetadataKey($key) { + $keys = $this->getRevisionMetadataKeys(); + return isset($keys[$key]); + } + } diff --git a/core/lib/Drupal/Core/Entity/ContentEntityTypeInterface.php b/core/lib/Drupal/Core/Entity/ContentEntityTypeInterface.php index f9ef160..4c9e890 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityTypeInterface.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityTypeInterface.php @@ -6,4 +6,50 @@ * Provides an interface for a content entity type and its metadata. */ interface ContentEntityTypeInterface extends EntityTypeInterface { + + /** + * Gets an array of entity revision metadata keys. + * + * @param bool $include_backwards_compatibility_field_names + * (optional and deprecated) Whether to provide the revision keys on a + * best-effort basis by looking at the base fields defined by the entity + * type. Note that this parameter will be removed in Drupal 9.0.0. Defaults + * to TRUE. + * + * @return array + * An array describing how the Field API can extract revision metadata + * information of this entity type: + * - revision_log_message: The name of the property that contains description + * of the changes that were made in the current revision. + * - revision_user: The name of the property that contains the user ID of + * the author of the current revision. + * - revision_created: The name of the property that contains the timestamp + * of the current revision. + */ + public function getRevisionMetadataKeys($include_backwards_compatibility_field_names = TRUE); + + /** + * Gets a specific entity revision metadata key. + * + * @param string $key + * The name of the entity revision metadata key to return. + * + * @return string|bool + * The entity revision metadata key, or FALSE if it does not exist. + * + * @see self::getRevisionMetadataKeys() + */ + public function getRevisionMetadataKey($key); + + /** + * Indicates if a given entity revision metadata key exists. + * + * @param string $key + * The name of the entity revision metadata key to check. + * + * @return bool + * TRUE if a given entity revision metadata key exists, FALSE otherwise. + */ + public function hasRevisionMetadataKey($key); + } diff --git a/core/lib/Drupal/Core/Entity/RevisionLogEntityTrait.php b/core/lib/Drupal/Core/Entity/RevisionLogEntityTrait.php index 7392858..f935a8b 100644 --- a/core/lib/Drupal/Core/Entity/RevisionLogEntityTrait.php +++ b/core/lib/Drupal/Core/Entity/RevisionLogEntityTrait.php @@ -25,18 +25,18 @@ * @see \Drupal\Core\Entity\FieldableEntityInterface::baseFieldDefinitions() */ public static function revisionLogBaseFieldDefinitions(EntityTypeInterface $entity_type) { - $fields['revision_created'] = BaseFieldDefinition::create('created') + $fields[static::getRevisionMetadataKey($entity_type, 'revision_created')] = BaseFieldDefinition::create('created') ->setLabel(t('Revision create time')) ->setDescription(t('The time that the current revision was created.')) ->setRevisionable(TRUE); - $fields['revision_user'] = BaseFieldDefinition::create('entity_reference') + $fields[static::getRevisionMetadataKey($entity_type, 'revision_user')] = BaseFieldDefinition::create('entity_reference') ->setLabel(t('Revision user')) ->setDescription(t('The user ID of the author of the current revision.')) ->setSetting('target_type', 'user') ->setRevisionable(TRUE); - $fields['revision_log_message'] = BaseFieldDefinition::create('string_long') + $fields[static::getRevisionMetadataKey($entity_type, 'revision_log_message')] = BaseFieldDefinition::create('string_long') ->setLabel(t('Revision log message')) ->setDescription(t('Briefly describe the changes you have made.')) ->setRevisionable(TRUE) @@ -56,14 +56,14 @@ public static function revisionLogBaseFieldDefinitions(EntityTypeInterface $enti * Implements \Drupal\Core\Entity\RevisionLogInterface::getRevisionCreationTime(). */ public function getRevisionCreationTime() { - return $this->revision_created->value; + return $this->{static::getRevisionMetadataKey($this->getEntityType(), 'revision_created')}->value; } /** * Implements \Drupal\Core\Entity\RevisionLogInterface::setRevisionCreationTime(). */ public function setRevisionCreationTime($timestamp) { - $this->revision_created->value = $timestamp; + $this->{static::getRevisionMetadataKey($this->getEntityType(), 'revision_created')}->value = $timestamp; return $this; } @@ -71,14 +71,14 @@ public function setRevisionCreationTime($timestamp) { * Implements \Drupal\Core\Entity\RevisionLogInterface::getRevisionUser(). */ public function getRevisionUser() { - return $this->revision_user->entity; + return $this->{static::getRevisionMetadataKey($this->getEntityType(), 'revision_user')}->entity; } /** * Implements \Drupal\Core\Entity\RevisionLogInterface::setRevisionUser(). */ public function setRevisionUser(UserInterface $account) { - $this->revision_user->entity = $account; + $this->{static::getRevisionMetadataKey($this->getEntityType(), 'revision_user')}->entity = $account; return $this; } @@ -86,14 +86,14 @@ public function setRevisionUser(UserInterface $account) { * Implements \Drupal\Core\Entity\RevisionLogInterface::getRevisionUserId(). */ public function getRevisionUserId() { - return $this->revision_user->target_id; + return $this->{static::getRevisionMetadataKey($this->getEntityType(), 'revision_user')}->target_id; } /** * Implements \Drupal\Core\Entity\RevisionLogInterface::setRevisionUserId(). */ public function setRevisionUserId($user_id) { - $this->revision_user->target_id = $user_id; + $this->{static::getRevisionMetadataKey($this->getEntityType(), 'revision_user')}->target_id = $user_id; return $this; } @@ -101,15 +101,41 @@ public function setRevisionUserId($user_id) { * Implements \Drupal\Core\Entity\RevisionLogInterface::getRevisionLogMessage(). */ public function getRevisionLogMessage() { - return $this->revision_log_message->value; + return $this->{static::getRevisionMetadataKey($this->getEntityType(), 'revision_log_message')}->value; } /** * Implements \Drupal\Core\Entity\RevisionLogInterface::setRevisionLogMessage(). */ public function setRevisionLogMessage($revision_log_message) { - $this->revision_log_message->value = $revision_log_message; + $this->{static::getRevisionMetadataKey($this->getEntityType(), 'revision_log_message')}->value = $revision_log_message; return $this; } + /** + * Gets the name of a revision metadata field. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * A content entity type definition. + * @param string $key + * The revision metadata key to get, must be one of 'revision_created', + * 'revision_user' or 'revision_log_message'. + * + * @return string + * The name of the field for the specified $key. + */ + protected static function getRevisionMetadataKey(EntityTypeInterface $entity_type, $key) { + // We need to prevent ContentEntityType::getRevisionMetadataKey() from + // providing fallback as that requires fetching the entity type's field + // definition leading to an infinite recursion. + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type */ + $revision_metadata_keys = $entity_type->getRevisionMetadataKeys(FALSE) + [ + 'revision_created' => 'revision_created', + 'revision_user' => 'revision_user', + 'revision_log_message' => 'revision_log_message', + ]; + + return $revision_metadata_keys[$key]; + } + } diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php index 6df1af0..d696a94 100644 --- a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php +++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php @@ -294,17 +294,11 @@ public function getTableMapping(array $storage_definitions = NULL) { // Make sure the key fields come first in the list of fields. $all_fields = array_merge($key_fields, array_diff($all_fields, $key_fields)); - // Nodes have all three of these fields, while custom blocks only have - // log. - // @todo Provide automatic definitions for revision metadata fields in - // https://www.drupal.org/node/2248983. - $revision_metadata_fields = array_intersect([ - 'revision_timestamp', - 'revision_uid', - 'revision_log', - ], $all_fields); - + // If the entity is revisionable, gather the fields that need to be put + // in the revision table. $revisionable = $this->entityType->isRevisionable(); + $revision_metadata_fields = $revisionable ? array_values($this->entityType->getRevisionMetadataKeys()) : []; + $translatable = $this->entityType->isTranslatable(); if (!$revisionable && !$translatable) { // The base layout stores all the base field values in the base table. diff --git a/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php index e948aa5..ca8e778 100644 --- a/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php @@ -111,16 +111,24 @@ protected function validateModules(ConfigImporter $config_importer) { } } - // Get the install profile from the site's configuration. + // Get the active install profile from the site's configuration. $current_core_extension = $config_importer->getStorageComparer()->getTargetStorage()->read('core.extension'); - $install_profile = isset($current_core_extension['profile']) ? $current_core_extension['profile'] : NULL; + if (isset($current_core_extension['profile'])) { + // Ensure the active profile is not changing. + if ($current_core_extension['profile'] !== $core_extension['profile']) { + $config_importer->logError($this->t('Cannot change the install profile from %new_profile to %profile once Drupal is installed.', ['%profile' => $install_profile, '%new_profile' => $core_extension['profile']])); + } + } + + // Get a list of installed profiles. + $installed_profiles = \Drupal::service('profile_handler')->getProfiles(); // Ensure that all modules being uninstalled are not required by modules // that will be installed after the import. $uninstalls = $config_importer->getExtensionChangelist('module', 'uninstall'); foreach ($uninstalls as $module) { foreach (array_keys($module_data[$module]->required_by) as $dependent_module) { - if ($module_data[$dependent_module]->status && !in_array($dependent_module, $uninstalls, TRUE) && $dependent_module !== $install_profile) { + if ($module_data[$dependent_module]->status && !in_array($dependent_module, $uninstalls, TRUE) && (!array_key_exists($dependent_module, $installed_profiles))) { $module_name = $module_data[$module]->info['name']; $dependent_module_name = $module_data[$dependent_module]->info['name']; $config_importer->logError($this->t('Unable to uninstall the %module module since the %dependent_module module is installed.', ['%module' => $module_name, '%dependent_module' => $dependent_module_name])); @@ -128,15 +136,17 @@ protected function validateModules(ConfigImporter $config_importer) { } } - // Ensure that the install profile is not being uninstalled. - if (in_array($install_profile, $uninstalls, TRUE)) { - $profile_name = $module_data[$install_profile]->info['name']; - $config_importer->logError($this->t('Unable to uninstall the %profile profile since it is the install profile.', ['%profile' => $profile_name])); - } - - // Ensure the profile is not changing. - if ($install_profile !== $core_extension['profile']) { - $config_importer->logError($this->t('Cannot change the install profile from %new_profile to %profile once Drupal is installed.', ['%profile' => $install_profile, '%new_profile' => $core_extension['profile']])); + // Ensure that none of the installed profiles are being uninstalled. + if ($profile_uninstalls = array_intersect_key($installed_profiles, array_flip($uninstalls))) { + foreach ($profile_uninstalls as $profile) { + $profile_names[] = $profile->info['name']; + } + $message = $this->formatPlural(count($profile_names), + 'Unable to uninstall the %profile profile since it is an installed profile.', + 'Unable to uninstall the %profile profiles since they are installed profiles.', + ['%profile' => implode(', ', $profile_names)] + ); + $config_importer->logError($message); } } diff --git a/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionSubscriber.php deleted file mode 100644 index 4737e80..0000000 --- a/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionSubscriber.php +++ /dev/null @@ -1,245 +0,0 @@ -configFactory = $config_factory; - } - - /** - * Gets the configured error level. - * - * @return string - */ - protected function getErrorLevel() { - if (!isset($this->errorLevel)) { - $this->errorLevel = $this->configFactory->get('system.logging')->get('error_level'); - } - return $this->errorLevel; - } - - /** - * Handles any exception as a generic error page for HTML. - * - * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event - * The event to process. - */ - protected function onHtml(GetResponseForExceptionEvent $event) { - $exception = $event->getException(); - $error = Error::decodeException($exception); - - // Display the message if the current error reporting level allows this type - // of message to be displayed, and unconditionally in update.php. - $message = ''; - if (error_displayable($error)) { - // If error type is 'User notice' then treat it as debug information - // instead of an error message. - // @see debug() - if ($error['%type'] == 'User notice') { - $error['%type'] = 'Debug'; - } - - // Attempt to reduce verbosity by removing DRUPAL_ROOT from the file path - // in the message. This does not happen for (false) security. - $root_length = strlen(DRUPAL_ROOT); - if (substr($error['%file'], 0, $root_length) == DRUPAL_ROOT) { - $error['%file'] = substr($error['%file'], $root_length + 1); - } - - unset($error['backtrace']); - - if ($this->getErrorLevel() != ERROR_REPORTING_DISPLAY_VERBOSE) { - // Without verbose logging, use a simple message. - - // We call SafeMarkup::format directly here, rather than use t() since - // we are in the middle of error handling, and we don't want t() to - // cause further errors. - $message = SafeMarkup::format('%type: @message in %function (line %line of %file).', $error); - } - else { - // With verbose logging, we will also include a backtrace. - - $backtrace_exception = $exception; - while ($backtrace_exception->getPrevious()) { - $backtrace_exception = $backtrace_exception->getPrevious(); - } - $backtrace = $backtrace_exception->getTrace(); - // First trace is the error itself, already contained in the message. - // While the second trace is the error source and also contained in the - // message, the message doesn't contain argument values, so we output it - // once more in the backtrace. - array_shift($backtrace); - - // Generate a backtrace containing only scalar argument values. - $error['@backtrace'] = Error::formatBacktrace($backtrace); - $message = SafeMarkup::format('%type: @message in %function (line %line of %file).
@backtrace
', $error); - } - } - - $content = $this->t('The website encountered an unexpected error. Please try again later.'); - $content .= $message ? '

' . $message : ''; - $response = new Response($content, 500); - - if ($exception instanceof HttpExceptionInterface) { - $response->setStatusCode($exception->getStatusCode()); - $response->headers->add($exception->getHeaders()); - } - else { - $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR, '500 Service unavailable (with message)'); - } - - $event->setResponse($response); - } - - /** - * Handles any exception as a generic error page for JSON. - * - * @todo This should probably check the error reporting level. - * - * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event - * The event to process. - */ - protected function onJson(GetResponseForExceptionEvent $event) { - $exception = $event->getException(); - $error = Error::decodeException($exception); - - // Display the message if the current error reporting level allows this type - // of message to be displayed, - $data = NULL; - if (error_displayable($error) && $message = $exception->getMessage()) { - $data = ['message' => sprintf('A fatal error occurred: %s', $message)]; - } - - $response = new JsonResponse($data, Response::HTTP_INTERNAL_SERVER_ERROR); - if ($exception instanceof HttpExceptionInterface) { - $response->setStatusCode($exception->getStatusCode()); - $response->headers->add($exception->getHeaders()); - } - - $event->setResponse($response); - } - - /** - * Handles an HttpExceptionInterface exception for unknown formats. - * - * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event - * The event to process. - */ - protected function onFormatUnknown(GetResponseForExceptionEvent $event) { - /** @var \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface|\Exception $exception */ - $exception = $event->getException(); - - $response = new Response($exception->getMessage(), $exception->getStatusCode(), $exception->getHeaders()); - $event->setResponse($response); - } - - /** - * Handles errors for this subscriber. - * - * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event - * The event to process. - */ - public function onException(GetResponseForExceptionEvent $event) { - $format = $this->getFormat($event->getRequest()); - $exception = $event->getException(); - - $method = 'on' . $format; - if (!method_exists($this, $method)) { - if ($exception instanceof HttpExceptionInterface) { - $this->onFormatUnknown($event); - $response = $event->getResponse(); - $response->headers->set('Content-Type', 'text/plain'); - } - else { - $this->onHtml($event); - } - } - else { - $this->$method($event); - } - } - - /** - * Gets the error-relevant format from the request. - * - * @param \Symfony\Component\HttpFoundation\Request $request - * The request object. - * - * @return string - * The format as which to treat the exception. - */ - protected function getFormat(Request $request) { - $format = $request->query->get(MainContentViewSubscriber::WRAPPER_FORMAT, $request->getRequestFormat()); - - // These are all JSON errors for our purposes. Any special handling for - // them can/should happen in earlier listeners if desired. - if (in_array($format, ['drupal_modal', 'drupal_dialog', 'drupal_ajax'])) { - $format = 'json'; - } - - // Make an educated guess that any Accept header type that includes "json" - // can probably handle a generic JSON response for errors. As above, for - // any format this doesn't catch or that wants custom handling should - // register its own exception listener. - foreach ($request->getAcceptableContentTypes() as $mime) { - if (strpos($mime, 'html') === FALSE && strpos($mime, 'json') !== FALSE) { - $format = 'json'; - } - } - - return $format; - } - - /** - * Registers the methods in this class that should be listeners. - * - * @return array - * An array of event listener definitions. - */ - public static function getSubscribedEvents() { - $events[KernelEvents::EXCEPTION][] = ['onException', -256]; - return $events; - } - -} diff --git a/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php index 76cc55e..6ccb743 100644 --- a/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php @@ -14,7 +14,7 @@ class ExceptionJsonSubscriber extends HttpExceptionSubscriberBase { * {@inheritdoc} */ protected function getHandledFormats() { - return ['json']; + return ['json', 'drupal_modal', 'drupal_dialog', 'drupal_ajax']; } /** diff --git a/core/lib/Drupal/Core/EventSubscriber/FinalExceptionSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/FinalExceptionSubscriber.php new file mode 100644 index 0000000..4317ff0 --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/FinalExceptionSubscriber.php @@ -0,0 +1,195 @@ +configFactory = $config_factory; + } + + /** + * Gets the configured error level. + * + * @return string + */ + protected function getErrorLevel() { + if (!isset($this->errorLevel)) { + $this->errorLevel = $this->configFactory->get('system.logging')->get('error_level'); + } + return $this->errorLevel; + } + + /** + * Handles exceptions for this subscriber. + * + * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event + * The event to process. + */ + public function onException(GetResponseForExceptionEvent $event) { + $exception = $event->getException(); + $error = Error::decodeException($exception); + + // Display the message if the current error reporting level allows this type + // of message to be displayed, and unconditionally in update.php. + $message = ''; + if ($this->isErrorDisplayable($error)) { + // If error type is 'User notice' then treat it as debug information + // instead of an error message. + // @see debug() + if ($error['%type'] == 'User notice') { + $error['%type'] = 'Debug'; + } + + $error = $this->simplifyFileInError($error); + + unset($error['backtrace']); + + if (!$this->isErrorLevelVerbose()) { + // Without verbose logging, use a simple message. + + // We call SafeMarkup::format directly here, rather than use t() since + // we are in the middle of error handling, and we don't want t() to + // cause further errors. + $message = SafeMarkup::format('%type: @message in %function (line %line of %file).', $error); + } + else { + // With verbose logging, we will also include a backtrace. + + $backtrace_exception = $exception; + while ($backtrace_exception->getPrevious()) { + $backtrace_exception = $backtrace_exception->getPrevious(); + } + $backtrace = $backtrace_exception->getTrace(); + // First trace is the error itself, already contained in the message. + // While the second trace is the error source and also contained in the + // message, the message doesn't contain argument values, so we output it + // once more in the backtrace. + array_shift($backtrace); + + // Generate a backtrace containing only scalar argument values. + $error['@backtrace'] = Error::formatBacktrace($backtrace); + $message = SafeMarkup::format('%type: @message in %function (line %line of %file).
@backtrace
', $error); + } + } + + $content = $this->t('The website encountered an unexpected error. Please try again later.'); + $content .= $message ? '

' . $message : ''; + $response = new Response($content, 500, ['Content-Type' => 'text/plain']); + + if ($exception instanceof HttpExceptionInterface) { + $response->setStatusCode($exception->getStatusCode()); + $response->headers->add($exception->getHeaders()); + } + else { + $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR, '500 Service unavailable (with message)'); + } + + $event->setResponse($response); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + // Run as the final (very late) KernelEvents::EXCEPTION subscriber. + $events[KernelEvents::EXCEPTION][] = ['onException', -256]; + return $events; + } + + /** + * Checks whether the error level is verbose or not. + * + * @return bool + */ + protected function isErrorLevelVerbose() { + return $this->getErrorLevel() === ERROR_REPORTING_DISPLAY_VERBOSE; + } + + /** + * Wrapper for error_displayable(). + * + * @param $error + * Optional error to examine for ERROR_REPORTING_DISPLAY_SOME. + * + * @return bool + * + * @see \error_displayable + */ + protected function isErrorDisplayable($error) { + return error_displayable($error); + } + + /** + * Attempts to reduce error verbosity in the error message's file path. + * + * Attempts to reduce verbosity by removing DRUPAL_ROOT from the file path in + * the message. This does not happen for (false) security. + * + * @param $error + * Optional error to examine for ERROR_REPORTING_DISPLAY_SOME. + * + * @return + * The updated $error. + */ + protected function simplifyFileInError($error) { + // Attempt to reduce verbosity by removing DRUPAL_ROOT from the file path + // in the message. This does not happen for (false) security. + $root_length = strlen(DRUPAL_ROOT); + if (substr($error['%file'], 0, $root_length) == DRUPAL_ROOT) { + $error['%file'] = substr($error['%file'], $root_length + 1); + } + return $error; + } + +} diff --git a/core/lib/Drupal/Core/Extension/ExtensionDiscovery.php b/core/lib/Drupal/Core/Extension/ExtensionDiscovery.php index b74fc04..1996db4 100644 --- a/core/lib/Drupal/Core/Extension/ExtensionDiscovery.php +++ b/core/lib/Drupal/Core/Extension/ExtensionDiscovery.php @@ -92,6 +92,15 @@ class ExtensionDiscovery { protected $sitePath; /** + * The profile handler. + * + * Used to determine the directories in which we want to scan for modules. + * + * @var \Drupal\Core\Extension\ProfileHandlerInterface|null + */ + protected $profileHandler; + + /** * Constructs a new ExtensionDiscovery object. * * @param string $root @@ -102,12 +111,27 @@ class ExtensionDiscovery { * The available profile directories * @param string $site_path * The path to the site. + * @param \Drupal\Core\Extension\ProfileHandlerInterface $profile_handler + * (optional) The profile handler. */ - public function __construct($root, $use_file_cache = TRUE, $profile_directories = NULL, $site_path = NULL) { + public function __construct($root, $use_file_cache = TRUE, $profile_directories = NULL, $site_path = NULL, ProfileHandlerInterface $profile_handler = NULL) { $this->root = $root; $this->fileCache = $use_file_cache ? FileCacheFactory::get('extension_discovery') : NULL; $this->profileDirectories = $profile_directories; $this->sitePath = $site_path; + + // ExtensionDiscovery can be used without a service container + // (@drupalKernel::moduleData), so create a fallback profile handler if the + // profile_handler service is unavailable. + if ($profile_handler) { + $this->profileHandler = $profile_handler; + } + elseif (\Drupal::hasService('profile_handler')) { + $this->profileHandler = \Drupal::service('profile_handler'); + } + else { + $this->profileHandler = new FallbackProfileHandler($root); + } } /** @@ -241,7 +265,11 @@ public function setProfileDirectoriesFromSettings() { // In case both profile directories contain the same extension, the actual // profile always has precedence. if ($profile) { - $this->profileDirectories[] = drupal_get_path('profile', $profile); + $profiles = $this->profileHandler->getProfiles($profile); + $profile_directories = array_map(function($extension) { + return $extension->getPath(); + }, $profiles); + $this->profileDirectories = array_unique(array_merge($profile_directories, $this->profileDirectories)); } return $this; } diff --git a/core/lib/Drupal/Core/Extension/FallbackProfileHandler.php b/core/lib/Drupal/Core/Extension/FallbackProfileHandler.php new file mode 100644 index 0000000..f858ca2 --- /dev/null +++ b/core/lib/Drupal/Core/Extension/FallbackProfileHandler.php @@ -0,0 +1,75 @@ +root = $root; + } + + /** + * The stored profile info. + * + * @var array[] + */ + protected $profileInfo = []; + + /** + * {@inheritdoc} + */ + public function getProfileInfo($profile) { + if (isset($this->profileInfo[$profile])) { + return $this->profileInfo[$profile]; + } + return []; + } + + /** + * {@inheritdoc} + */ + public function setProfileInfo($profile, array $info) { + $this->profileInfo[$profile] = $info; + } + + /** + * {@inheritdoc} + */ + public function clearProfileCache() { + unset($this->profileInfo); + } + + /** + * {@inheritdoc} + */ + public function getProfiles($profile = NULL) { + $profile_path = drupal_get_path('profile', $profile); + return [ + $profile => new Extension($this->root, 'profile', $profile_path), + ]; + } + + /** + * {@inheritdoc} + */ + public function selectDistribution(array $profile_list) { + return NULL; + } + +} diff --git a/core/lib/Drupal/Core/Extension/ModuleInstaller.php b/core/lib/Drupal/Core/Extension/ModuleInstaller.php index f9500a5..2dc55d9 100644 --- a/core/lib/Drupal/Core/Extension/ModuleInstaller.php +++ b/core/lib/Drupal/Core/Extension/ModuleInstaller.php @@ -330,7 +330,7 @@ public function uninstall(array $module_list, $uninstall_dependents = TRUE) { if ($uninstall_dependents) { // Add dependent modules to the list. The new modules will be processed as // the while loop continues. - $profile = drupal_get_profile(); + $profiles = \Drupal::service('profile_handler')->getProfiles(); while (list($module) = each($module_list)) { foreach (array_keys($module_data[$module]->required_by) as $dependent) { if (!isset($module_data[$dependent])) { @@ -338,8 +338,8 @@ public function uninstall(array $module_list, $uninstall_dependents = TRUE) { return FALSE; } - // Skip already uninstalled modules. - if (isset($installed_modules[$dependent]) && !isset($module_list[$dependent]) && $dependent != $profile) { + // Skip already uninstalled modules and dependencies of profiles. + if (isset($installed_modules[$dependent]) && !isset($module_list[$dependent]) && (!array_key_exists($dependent, $profiles))) { $module_list[$dependent] = $dependent; } } diff --git a/core/lib/Drupal/Core/Extension/ProfileHandler.php b/core/lib/Drupal/Core/Extension/ProfileHandler.php new file mode 100644 index 0000000..b429972 --- /dev/null +++ b/core/lib/Drupal/Core/Extension/ProfileHandler.php @@ -0,0 +1,301 @@ +root = $root; + $this->infoParser = $info_parser; + $this->extensionDiscovery = $extension_discovery ?: new ExtensionDiscovery($root, TRUE, NULL, NULL, $this); + } + + /** + * Return the full path to a profile. + * + * Wrapper around drupal_get_path. If profile path is not available yet we + * call scan('profile') and prime the cache. + * + * @param string $profile + * The name of the profile. + * + * @return string + * The full path to the profile. + */ + protected function getProfilePath($profile) { + // Check to see if system_rebuild_module_data cache is primed. + // @todo Remove as part of https://www.drupal.org/node/2186491. + $modules_cache = &drupal_static('system_rebuild_module_data'); + if (!$this->scanCache && !isset($modules_cache)) { + // Find installation profiles. This needs to happen before performing a + // module scan as the module scan requires knowing what the active profile + // is. + // @todo Remove as part of https://www.drupal.org/node/2186491. + $profiles = $this->extensionDiscovery->scan('profile'); + foreach ($profiles as $profile_name => $extension) { + // Prime the drupal_get_filename() static cache with the profile info + // file location so we can use drupal_get_path() on the active profile + // during the module scan. + // @todo Remove as part of https://www.drupal.org/node/2186491. + drupal_get_filename('profile', $profile_name, $extension->getPathname()); + } + $this->scanCache = TRUE; + } + return drupal_get_path('profile', $profile); + } + + /** + * {@inheritdoc} + */ + public function getProfileInfo($profile) { + // Even though info_parser caches the info array, we need to also cache + // this since it is recursive. + if (!isset($this->infoCache[$profile])) { + // Set defaults for profile info. + $defaults = [ + 'dependencies' => [], + 'themes' => ['stark'], + 'description' => '', + 'version' => NULL, + 'hidden' => FALSE, + 'php' => DRUPAL_MINIMUM_PHP, + 'base profile' => [ + 'name' => '', + 'excluded_dependencies' => [], + 'excluded_themes' => [], + ], + ]; + + $profile_path = $this->getProfilePath($profile); + $profile_file = $profile_path . "/$profile.info.yml"; + $info = $this->infoParser->parse($profile_file) + $defaults; + + // Normalize any base profile info. + if (is_string($info['base profile'])) { + $info['base profile'] = [ + 'name' => $info['base profile'], + 'excluded_dependencies' => [], + 'excluded_themes' => [], + ]; + } + + $profile_list = []; + // Get the base profile dependencies. + if ($base_profile_name = $info['base profile']['name']) { + $base_info = $this->getProfileInfo($base_profile_name); + $profile_list += $base_info['profile_list']; + + // Ensure all dependencies are cleanly merged. + $info['dependencies'] = array_merge($info['dependencies'], $base_info['dependencies']); + if (isset($info['base profile']['excluded_dependencies'])) { + // Apply excluded dependencies. + $info['dependencies'] = array_diff($info['dependencies'], $info['base profile']['excluded_dependencies']); + } + // Ensure there's no circular dependency. + $info['dependencies'] = array_diff($info['dependencies'], [$profile]); + + // Ensure all themes are cleanly merged. + $info['themes'] = array_unique(array_merge($info['themes'], $base_info['themes'])); + if (isset($info['base profile']['excluded_themes'])) { + // Apply excluded themes. + $info['themes'] = array_diff($info['themes'], $info['base profile']['excluded_themes']); + } + // Ensure each theme is listed only once. + $info['themes'] = array_unique($info['themes']); + + } + $profile_list[$profile] = $profile; + $info['profile_list'] = $profile_list; + + // Ensure the same dependency notation as in modules can be used. + array_walk($info['dependencies'], function(&$dependency) { + $dependency = ModuleHandler::parseDependency($dependency)['name']; + }); + + // Installation profiles are hidden by default, unless explicitly + // specified otherwise in the .info.yml file. + $info['hidden'] = isset($info['hidden']) ? $info['hidden'] : TRUE; + + $this->infoCache[$profile] = $info; + } + return $this->infoCache[$profile]; + } + + /** + * {@inheritdoc} + */ + public function setProfileInfo($profile, array $info) { + $this->infoCache[$profile] = $info; + // Also unset the cached profile extension so the updated info will + // be picked up. + unset($this->profilesWithParentsCache[$profile]); + } + + /** + * {@inheritdoc} + */ + public function clearProfileCache() { + $this->profilesWithParentsCache = []; + $this->infoCache = []; + } + + /** + * Create an Extension object for a profile. + * + * @param string $profile + * The name of the profile. + * + * @return \Drupal\Core\Extension\Extension + * The extension object for the profile + * Properties added to extension: + * info: The parsed info.yml data. + * origin: The directory origin as used in ExtensionDiscovery. + */ + protected function getProfileExtension($profile) { + $profile_info = $this->getProfileInfo($profile); + + $type = $profile_info['type']; + $profile_path = $this->getProfilePath($profile); + $profile_file = $profile_path . "/$profile.info.yml"; + $filename = file_exists($profile_path . "/$profile.$type") ? "$profile.$type" : NULL; + $extension = new Extension($this->root, $type, $profile_file, $filename); + + $extension->info = $profile_info; + $extension->origin = ''; + + return $extension; + } + + /** + * Get a list of dependent profile names. + * + * @param string $profile + * Name of profile. + * + * @return string[] + * An associative array of profile names, keyed by profile name + * in descending order of their dependencies. + * (parent profiles first, main profile last) + */ + protected function getProfileList($profile) { + $profile_info = $this->getProfileInfo($profile); + return $profile_info['profile_list']; + } + + /** + * {@inheritdoc} + */ + public function getProfiles($profile = NULL) { + if (empty($profile)) { + $profile = drupal_get_profile(); + } + if (!isset($this->profilesWithParentsCache[$profile])) { + $profiles = []; + // Check if a valid profile name was given. + if (!empty($profile)) { + $list = $this->getProfileList($profile); + + // Starting weight for profiles ensures their hooks run last. + $weight = 1000; + + // Loop through profile list and create Extension objects. + $profiles = []; + foreach ($list as $profile_name) { + $extension = $this->getProfileExtension($profile_name); + $extension->weight = $weight; + $weight++; + $profiles[$profile_name] = $extension; + } + } + $this->profilesWithParentsCache[$profile] = $profiles; + } + return $this->profilesWithParentsCache[$profile]; + } + + /** + * {@inheritdoc} + */ + public function selectDistribution(array $profile_list) { + // First, find all profiles marked as distributions + $distributions = []; + foreach ($profile_list as $profile_name) { + $profile_info = $this->getProfileInfo($profile_name); + if (!empty($profile_info['distribution'])) { + $distributions[$profile_name] = $profile_name; + } + } + // Remove any base profiles. + foreach ($profile_list as $profile_name) { + $profile_info = $this->getProfileInfo($profile_name); + if ($base_profile = $profile_info['base profile']['name']) { + unset($distributions[$base_profile]); + } + } + return !empty($distributions) ? current($distributions) : NULL; + } + +} diff --git a/core/lib/Drupal/Core/Extension/ProfileHandlerInterface.php b/core/lib/Drupal/Core/Extension/ProfileHandlerInterface.php new file mode 100644 index 0000000..37b4bdf --- /dev/null +++ b/core/lib/Drupal/Core/Extension/ProfileHandlerInterface.php @@ -0,0 +1,79 @@ +setLabel($definition->getLabel()) ->setName($definition->getName()) ->setProvider($definition->getProvider()) - ->setQueryable($definition->isQueryable()) ->setRevisionable($definition->isRevisionable()) ->setSettings($definition->getSettings()) ->setTargetEntityTypeId($definition->getTargetEntityTypeId()) @@ -287,7 +286,8 @@ public function isMultiple() { * {@inheritdoc} */ public function isQueryable() { - return isset($this->definition['queryable']) ? $this->definition['queryable'] : !$this->isComputed(); + @trigger_error('BaseFieldDefinition::isQueryable() is deprecated in Drupal 8.4.0 and will be removed before Drupal 9.0.0. Instead, you should use \Drupal\Core\Field\BaseFieldDefinition::hasCustomStorage(). See https://www.drupal.org/node/2856563.', E_USER_DEPRECATED); + return !$this->hasCustomStorage(); } /** @@ -298,8 +298,14 @@ public function isQueryable() { * * @return static * The object itself for chaining. + * + * @deprecated in Drupal 8.4.0 and will be removed before Drupal 9.0.0. Use + * \Drupal\Core\Field\BaseFieldDefinition::setCustomStorage() instead. + * + * @see https://www.drupal.org/node/2856563 */ public function setQueryable($queryable) { + @trigger_error('BaseFieldDefinition::setQueryable() is deprecated in Drupal 8.4.0 and will be removed before Drupal 9.0.0. Instead, you should use \Drupal\Core\Field\BaseFieldDefinition::setCustomStorage(). See https://www.drupal.org/node/2856563.', E_USER_DEPRECATED); $this->definition['queryable'] = $queryable; return $this; } @@ -579,7 +585,7 @@ protected function getFieldItemClass() { public function __sleep() { // Do not serialize the statically cached property definitions. $vars = get_object_vars($this); - unset($vars['propertyDefinitions']); + unset($vars['propertyDefinitions'], $vars['typedDataManager']); return array_keys($vars); } diff --git a/core/lib/Drupal/Core/Field/FieldStorageDefinitionInterface.php b/core/lib/Drupal/Core/Field/FieldStorageDefinitionInterface.php index 1b842b9..d703659 100644 --- a/core/lib/Drupal/Core/Field/FieldStorageDefinitionInterface.php +++ b/core/lib/Drupal/Core/Field/FieldStorageDefinitionInterface.php @@ -109,6 +109,12 @@ public function isRevisionable(); * * @return bool * TRUE if the field is queryable. + * + * @deprecated in Drupal 8.4.0 and will be removed before Drupal 9.0.0. Use + * \Drupal\Core\Field\FieldStorageDefinitionInterface::hasCustomStorage() + * instead. + * + * @see https://www.drupal.org/node/2856563 */ public function isQueryable(); diff --git a/core/lib/Drupal/Core/Field/Plugin/Field/FieldWidget/LanguageSelectWidget.php b/core/lib/Drupal/Core/Field/Plugin/Field/FieldWidget/LanguageSelectWidget.php index 776e060..0872ba3 100644 --- a/core/lib/Drupal/Core/Field/Plugin/Field/FieldWidget/LanguageSelectWidget.php +++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldWidget/LanguageSelectWidget.php @@ -27,7 +27,32 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen $element['value'] = $element + [ '#type' => 'language_select', '#default_value' => $items[$delta]->value, - '#languages' => LanguageInterface::STATE_ALL, + '#languages' => $this->getSetting('include_locked') ? LanguageInterface::STATE_ALL : LanguageInterface::STATE_CONFIGURABLE, + ]; + + return $element; + } + + /** + * {@inheritdoc} + */ + public static function defaultSettings() { + $settings = parent::defaultSettings(); + $settings['include_locked'] = TRUE; + + return $settings; + } + + /** + * {@inheritdoc} + */ + public function settingsForm(array $form, FormStateInterface $form_state) { + $element = parent::settingsForm($form, $form_state); + + $element['include_locked'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Include locked languages such as Not specified and Not applicable'), + '#default_value' => $this->getSetting('include_locked'), ]; return $element; diff --git a/core/lib/Drupal/Core/FileTransfer/FTPExtension.php b/core/lib/Drupal/Core/FileTransfer/FTPExtension.php index c5f1b22..9f18963 100644 --- a/core/lib/Drupal/Core/FileTransfer/FTPExtension.php +++ b/core/lib/Drupal/Core/FileTransfer/FTPExtension.php @@ -45,7 +45,7 @@ protected function createDirectoryJailed($directory) { protected function removeDirectoryJailed($directory) { $pwd = ftp_pwd($this->connection); if (!ftp_chdir($this->connection, $directory)) { - throw new FileTransferException("Unable to change to directory @directory", NULL, ['@directory' => $directory]); + throw new FileTransferException("Unable to change the current directory to @directory", NULL, ['@directory' => $directory]); } $list = @ftp_nlist($this->connection, '.'); if (!$list) { @@ -65,7 +65,7 @@ protected function removeDirectoryJailed($directory) { } ftp_chdir($this->connection, $pwd); if (!ftp_rmdir($this->connection, $directory)) { - throw new FileTransferException("Unable to remove to directory @directory", NULL, ['@directory' => $directory]); + throw new FileTransferException("Unable to remove the directory @directory", NULL, ['@directory' => $directory]); } } @@ -74,7 +74,7 @@ protected function removeDirectoryJailed($directory) { */ protected function removeFileJailed($destination) { if (!ftp_delete($this->connection, $destination)) { - throw new FileTransferException("Unable to remove to file @file", NULL, ['@file' => $destination]); + throw new FileTransferException("Unable to remove the file @file", NULL, ['@file' => $destination]); } } diff --git a/core/lib/Drupal/Core/Form/FormValidator.php b/core/lib/Drupal/Core/Form/FormValidator.php index 7398053..8e976f5 100644 --- a/core/lib/Drupal/Core/Form/FormValidator.php +++ b/core/lib/Drupal/Core/Form/FormValidator.php @@ -229,8 +229,12 @@ protected function finalizeValidation(&$form, FormStateInterface &$form_state, $ * theming, and hook_form_alter functions. */ protected function doValidateForm(&$elements, FormStateInterface &$form_state, $form_id = NULL) { - // Recurse through all children. - foreach (Element::children($elements) as $key) { + // Recurse through all children, sorting the elements so that the order of + // error messages displayed to the user matches the order of elements in + // the form. Use a copy of $elements so that it is not modified by the + // sorting itself. + $elements_sorted = $elements; + foreach (Element::children($elements_sorted, TRUE) as $key) { if (isset($elements[$key]) && $elements[$key]) { $this->doValidateForm($elements[$key], $form_state); } diff --git a/core/lib/Drupal/Core/Menu/MenuLinkTree.php b/core/lib/Drupal/Core/Menu/MenuLinkTree.php index 8dd4c51..8a6dce8 100644 --- a/core/lib/Drupal/Core/Menu/MenuLinkTree.php +++ b/core/lib/Drupal/Core/Menu/MenuLinkTree.php @@ -22,6 +22,13 @@ class MenuLinkTree implements MenuLinkTreeInterface { protected $treeStorage; /** + * The menu link plugin manager. + * + * @var \Drupal\Core\Menu\MenuLinkManagerInterface + */ + protected $menuLinkManager; + + /** * The route provider to load routes by name. * * @var \Drupal\Core\Routing\RouteProviderInterface diff --git a/core/lib/Drupal/Core/Plugin/Context/ContextDefinitionInterface.php b/core/lib/Drupal/Core/Plugin/Context/ContextDefinitionInterface.php index 8cd0720..c60c6c7 100644 --- a/core/lib/Drupal/Core/Plugin/Context/ContextDefinitionInterface.php +++ b/core/lib/Drupal/Core/Plugin/Context/ContextDefinitionInterface.php @@ -5,7 +5,10 @@ use Drupal\Component\Plugin\Context\ContextDefinitionInterface as ComponentContextDefinitionInterface; /** - * Interface for context definitions. + * Interface to define definition objects in ContextInterface via TypedData. + * + * @see \Drupal\Component\Plugin\Context\ContextDefinitionInterface + * @see \Drupal\Core\Plugin\Context\ContextInterface */ interface ContextDefinitionInterface extends ComponentContextDefinitionInterface { diff --git a/core/lib/Drupal/Core/Plugin/Context/ContextInterface.php b/core/lib/Drupal/Core/Plugin/Context/ContextInterface.php index d126886..260cb2c 100644 --- a/core/lib/Drupal/Core/Plugin/Context/ContextInterface.php +++ b/core/lib/Drupal/Core/Plugin/Context/ContextInterface.php @@ -6,7 +6,10 @@ use Drupal\Core\Cache\CacheableDependencyInterface; /** - * Interface for context. + * Context data and definitions for plugins supporting caching and return docs. + * + * @see \Drupal\Component\Plugin\Context\ContextInterface + * @see \Drupal\Core\Plugin\Context\ContextDefinitionInterface */ interface ContextInterface extends ComponentContextInterface, CacheableDependencyInterface { diff --git a/core/lib/Drupal/Core/Plugin/DefaultSingleLazyPluginCollection.php b/core/lib/Drupal/Core/Plugin/DefaultSingleLazyPluginCollection.php index 519e93d..e2bff294 100644 --- a/core/lib/Drupal/Core/Plugin/DefaultSingleLazyPluginCollection.php +++ b/core/lib/Drupal/Core/Plugin/DefaultSingleLazyPluginCollection.php @@ -52,10 +52,7 @@ class DefaultSingleLazyPluginCollection extends LazyPluginCollection { */ public function __construct(PluginManagerInterface $manager, $instance_id, array $configuration) { $this->manager = $manager; - $this->instanceId = $instance_id; - // This is still needed by the parent LazyPluginCollection class. - $this->instanceIDs = [$instance_id => $instance_id]; - $this->configuration = $configuration; + $this->addInstanceId($instance_id, $configuration); } /** @@ -95,6 +92,8 @@ public function setConfiguration($configuration) { */ public function addInstanceId($id, $configuration = NULL) { $this->instanceId = $id; + // Reset the list of instance IDs since there can be only one. + $this->instanceIDs = []; parent::addInstanceId($id, $configuration); if ($configuration !== NULL) { $this->setConfiguration($configuration); diff --git a/core/lib/Drupal/Core/TypedData/ComplexDataDefinitionBase.php b/core/lib/Drupal/Core/TypedData/ComplexDataDefinitionBase.php index 0acdba4..0002497 100644 --- a/core/lib/Drupal/Core/TypedData/ComplexDataDefinitionBase.php +++ b/core/lib/Drupal/Core/TypedData/ComplexDataDefinitionBase.php @@ -42,7 +42,7 @@ public function getMainPropertyName() { public function __sleep() { // Do not serialize the cached property definitions. $vars = get_object_vars($this); - unset($vars['propertyDefinitions']); + unset($vars['propertyDefinitions'], $vars['typedDataManager']); return array_keys($vars); } diff --git a/core/lib/Drupal/Core/TypedData/DataDefinition.php b/core/lib/Drupal/Core/TypedData/DataDefinition.php index 7eec1a9..52a4394 100644 --- a/core/lib/Drupal/Core/TypedData/DataDefinition.php +++ b/core/lib/Drupal/Core/TypedData/DataDefinition.php @@ -7,6 +7,8 @@ */ class DataDefinition implements DataDefinitionInterface, \ArrayAccess { + use TypedDataTrait; + /** * The array holding values for all definition keys. * @@ -258,7 +260,7 @@ public function setSetting($setting_name, $value) { */ public function getConstraints() { $constraints = isset($this->definition['constraints']) ? $this->definition['constraints'] : []; - $constraints += \Drupal::typedDataManager()->getDefaultConstraints($this); + $constraints += $this->getTypedDataManager()->getDefaultConstraints($this); return $constraints; } @@ -340,4 +342,14 @@ public function toArray() { return $this->definition; } + /** + * {@inheritdoc} + */ + public function __sleep() { + // Never serialize the typed data manager. + $vars = get_object_vars($this); + unset($vars['typedDataManager']); + return array_keys($vars); + } + } diff --git a/core/lib/Drupal/Core/TypedData/TypedDataManager.php b/core/lib/Drupal/Core/TypedData/TypedDataManager.php index 8c4f265..dbf0d5f 100644 --- a/core/lib/Drupal/Core/TypedData/TypedDataManager.php +++ b/core/lib/Drupal/Core/TypedData/TypedDataManager.php @@ -117,7 +117,13 @@ public function createDataDefinition($data_type) { throw new \InvalidArgumentException("Invalid data type '$data_type' has been given"); } $class = $type_definition['definition_class']; - return $class::createFromDataType($data_type); + $data_definition = $class::createFromDataType($data_type); + + if (method_exists($data_definition, 'setTypedDataManager')) { + $data_definition->setTypedDataManager($this); + } + + return $data_definition; } /** diff --git a/core/modules/aggregator/src/Tests/FeedAdminDisplayTest.php b/core/modules/aggregator/src/Tests/FeedAdminDisplayTest.php index 0dbb872..46366b4 100644 --- a/core/modules/aggregator/src/Tests/FeedAdminDisplayTest.php +++ b/core/modules/aggregator/src/Tests/FeedAdminDisplayTest.php @@ -60,4 +60,14 @@ public function testFeedUpdateFields() { $this->assertNoText('left', 'The feed is not scheduled. It does not show a timeframe "x x left" for next update.'); } + /** + * {@inheritdoc} + */ + public function randomMachineName($length = 8) { + $value = parent::randomMachineName($length); + // See expected values in testFeedUpdateFields(). + $value = str_replace(['never', 'imminently', 'ago', 'left'], 'x', $value); + return $value; + } + } diff --git a/core/modules/block/src/Tests/Views/DisplayBlockTest.php b/core/modules/block/src/Tests/Views/DisplayBlockTest.php index 64f4851..3d35845 100644 --- a/core/modules/block/src/Tests/Views/DisplayBlockTest.php +++ b/core/modules/block/src/Tests/Views/DisplayBlockTest.php @@ -286,7 +286,7 @@ public function testBlockEmptyRendering() { $this->drupalGet($url); $this->assertEqual(0, count($this->xpath('//div[contains(@class, "block-views-blocktest-view-block-block-1")]'))); - // Ensure that the view cachability metadata is propagated even, for an + // Ensure that the view cacheability metadata is propagated even, for an // empty block. $this->assertCacheTags(array_merge($block->getCacheTags(), ['block_view', 'config:block_list', 'config:views.view.test_view_block' , 'http_response', 'rendered'])); $this->assertCacheContexts(['url.query_args:_wrapper_format']); diff --git a/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php b/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php index c5dfba2..bb59f50 100644 --- a/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php +++ b/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php @@ -21,6 +21,12 @@ class MigrateBlockTest extends MigrateDrupal6TestBase { 'comment', 'menu_ui', 'block_content', + 'taxonomy', + 'node', + 'aggregator', + 'book', + 'forum', + 'statistics', ]; /** @@ -30,15 +36,14 @@ protected function setUp() { parent::setUp(); // Install the themes used for this test. - $this->container->get('theme_installer')->install(['bartik', 'seven', 'test_theme']); + $this->container->get('theme_installer')->install(['bartik', 'test_theme']); $this->installConfig(['block_content']); $this->installEntitySchema('block_content'); - // Set Bartik and Seven as the default public and admin theme. + // Set Bartik as the default public theme. $config = $this->config('system.theme'); $config->set('default', 'bartik'); - $config->set('admin', 'seven'); $config->save(); $this->executeMigrations([ @@ -46,7 +51,6 @@ protected function setUp() { 'block_content_type', 'block_content_body_field', 'd6_custom_block', - 'menu', 'd6_user_role', 'd6_block', ]); @@ -66,14 +70,12 @@ protected function setUp() { * The theme. * @param string $weight * The block weight. - * @param string $label - * The block label. - * @param string $label_display - * The block label display setting. + * @param array $settings + * (optional) The block settings. * @param bool $status * Whether the block is expected to be enabled or disabled. */ - public function assertEntity($id, $visibility, $region, $theme, $weight, $label, $label_display, $status = TRUE) { + public function assertEntity($id, $visibility, $region, $theme, $weight, array $settings = NULL, $status = TRUE) { $block = Block::load($id); $this->assertTrue($block instanceof Block); $this->assertSame($visibility, $block->getVisibility()); @@ -81,10 +83,11 @@ public function assertEntity($id, $visibility, $region, $theme, $weight, $label, $this->assertSame($theme, $block->getTheme()); $this->assertSame($weight, $block->getWeight()); $this->assertSame($status, $block->status()); - - $config = $this->config('block.block.' . $id); - $this->assertSame($label, $config->get('settings.label')); - $this->assertSame($label_display, $config->get('settings.label_display')); + if ($settings) { + $block_settings = $block->get('settings'); + $block_settings['id'] = current(explode(':', $block_settings['id'])); + $this->assertEquals($settings, $block_settings); + } } /** @@ -92,62 +95,209 @@ public function assertEntity($id, $visibility, $region, $theme, $weight, $label, */ public function testBlockMigration() { $blocks = Block::loadMultiple(); - $this->assertIdentical(9, count($blocks)); + $this->assertCount(14, $blocks); - // User blocks - $visibility = []; - $visibility['request_path']['id'] = 'request_path'; - $visibility['request_path']['negate'] = TRUE; - $visibility['request_path']['pages'] = "\n/node/1\n/blog/*"; - $this->assertEntity('user', $visibility, 'sidebar_first', 'bartik', 0, '', '0'); + // Check user blocks. + $visibility = [ + 'request_path' => [ + 'id' => 'request_path', + 'negate' => TRUE, + 'pages' => "\n/node/1\n/blog/*", + ], + ]; + $settings = [ + 'id' => 'user_login_block', + 'label' => '', + 'provider' => 'user', + 'label_display' => '0', + ]; + $this->assertEntity('user', $visibility, 'sidebar_first', 'bartik', -10, $settings); $visibility = []; - $this->assertEntity('user_1', $visibility, 'sidebar_first', 'bartik', 0, '', '0'); + $settings = [ + 'id' => 'system_menu_block', + 'label' => '', + 'provider' => 'system', + 'label_display' => '0', + 'level' => 1, + 'depth' => 0, + ]; + $this->assertEntity('user_1', $visibility, 'sidebar_first', 'bartik', -11, $settings); - $visibility['user_role']['id'] = 'user_role'; - $roles['authenticated'] = 'authenticated'; - $visibility['user_role']['roles'] = $roles; - $context_mapping['user'] = '@user.current_user_context:current_user'; - $visibility['user_role']['context_mapping'] = $context_mapping; - $visibility['user_role']['negate'] = FALSE; - $this->assertEntity('user_2', $visibility, 'sidebar_second', 'bartik', -9, '', '0'); + $visibility = [ + 'user_role' => [ + 'id' => 'user_role', + 'roles' => [ + 'authenticated' => 'authenticated', + ], + 'context_mapping' => [ + 'user' => '@user.current_user_context:current_user', + ], + 'negate' => FALSE, + ], + ]; + $settings = [ + 'id' => 'broken', + 'label' => '', + 'provider' => 'core', + 'label_display' => '0', + 'items_per_page' => '5', + ]; + $this->assertEntity('user_2', $visibility, 'sidebar_second', 'bartik', -11, $settings); - $visibility = []; - $visibility['user_role']['id'] = 'user_role'; - $visibility['user_role']['roles'] = [ - 'migrate_test_role_1' => 'migrate_test_role_1' + $visibility = [ + 'user_role' => [ + 'id' => 'user_role', + 'roles' => [ + 'migrate_test_role_1' => 'migrate_test_role_1', + ], + 'context_mapping' => [ + 'user' => '@user.current_user_context:current_user', + ], + 'negate' => FALSE, + ], ]; - $context_mapping['user'] = '@user.current_user_context:current_user'; - $visibility['user_role']['context_mapping'] = $context_mapping; - $visibility['user_role']['negate'] = FALSE; - $this->assertEntity('user_3', $visibility, 'sidebar_second', 'bartik', -6, '', '0'); + $settings = [ + 'id' => 'broken', + 'label' => '', + 'provider' => 'core', + 'label_display' => '0', + 'items_per_page' => '10', + ]; + $this->assertEntity('user_3', $visibility, 'sidebar_second', 'bartik', -10, $settings); - // Check system block - $visibility = []; - $visibility['request_path']['id'] = 'request_path'; - $visibility['request_path']['negate'] = TRUE; - $visibility['request_path']['pages'] = '/node/1'; - $this->assertEntity('system', $visibility, 'footer_fifth', 'bartik', -5, '', '0'); + // Check system block. + $visibility = [ + 'request_path' => [ + 'id' => 'request_path', + 'negate' => TRUE, + 'pages' => '/node/1', + ], + ]; + $settings = [ + 'id' => 'system_powered_by_block', + 'label' => '', + 'provider' => 'system', + 'label_display' => '0', + ]; + $this->assertEntity('system', $visibility, 'footer_fifth', 'bartik', -5, $settings); - // Check menu blocks - $visibility = []; - $this->assertEntity('menu', $visibility, 'header', 'bartik', -5, '', '0'); + // Check menu blocks. + $settings = [ + 'id' => 'broken', + 'label' => '', + 'provider' => 'core', + 'label_display' => '0', + ]; + $this->assertEntity('menu', [], 'header', 'bartik', -5, $settings); + + // Check aggregator block. + $settings = [ + 'id' => 'aggregator_feed_block', + 'label' => '', + 'provider' => 'aggregator', + 'label_display' => '0', + 'block_count' => 7, + 'feed' => '5', + ]; + $this->assertEntity('aggregator', [], 'sidebar_second', 'bartik', -2, $settings); + + // Check book block. + $settings = [ + 'id' => 'book_navigation', + 'label' => '', + 'provider' => 'book', + 'label_display' => '0', + 'block_mode' => 'book pages', + ]; + $this->assertEntity('book', [], 'sidebar_second', 'bartik', -4, $settings); + + // Check forum block settings. + $settings = [ + 'id' => 'forum_active_block', + 'label' => '', + 'provider' => 'forum', + 'label_display' => '0', + 'block_count' => 3, + 'properties' => [ + 'administrative' => '1', + ], + ]; + $this->assertEntity('forum', [], 'sidebar_first', 'bartik', -8, $settings); + + $settings = [ + 'id' => 'forum_new_block', + 'label' => '', + 'provider' => 'forum', + 'label_display' => '0', + 'block_count' => 4, + 'properties' => [ + 'administrative' => '1', + ], + ]; + $this->assertEntity('forum_1', [], 'sidebar_first', 'bartik', -9, $settings); - // Check custom blocks - $visibility['request_path']['id'] = 'request_path'; - $visibility['request_path']['negate'] = FALSE; - $visibility['request_path']['pages'] = ''; - $this->assertEntity('block', $visibility, 'content', 'bartik', 0, 'Static Block', 'visible'); + // Check statistic block settings. + $settings = [ + 'id' => 'broken', + 'label' => '', + 'provider' => 'core', + 'label_display' => '0', + 'top_day_num' => 7, + 'top_all_num' => 8, + 'top_last_num' => 9, + ]; + $this->assertEntity('statistics', [], 'sidebar_second', 'bartik', 0, $settings); - $visibility['request_path']['id'] = 'request_path'; - $visibility['request_path']['negate'] = FALSE; - $visibility['request_path']['pages'] = '/node'; + // Check custom blocks. + $visibility = [ + 'request_path' => [ + 'id' => 'request_path', + 'negate' => FALSE, + 'pages' => '', + ], + ]; + $settings = [ + 'id' => 'block_content', + 'label' => 'Static Block', + 'provider' => 'block_content', + 'label_display' => 'visible', + 'status' => TRUE, + 'info' => '', + 'view_mode' => 'full', + ]; + $this->assertEntity('block', $visibility, 'content', 'bartik', 0, $settings); + + $visibility = [ + 'request_path' => [ + 'id' => 'request_path', + 'negate' => FALSE, + 'pages' => '/node', + ], + ]; + $settings = [ + 'id' => 'block_content', + 'label' => 'Another Static Block', + 'provider' => 'block_content', + 'label_display' => 'visible', + 'status' => TRUE, + 'info' => '', + 'view_mode' => 'full', + ]; // We expect this block to be disabled because '' is not a valid region, // and block_rebuild() will disable any block in an invalid region. - $this->assertEntity('block_1', $visibility, '', 'bluemarine', -4, 'Another Static Block', 'visible', FALSE); + $this->assertEntity('block_1', $visibility, '', 'bluemarine', -4, $settings, FALSE); - $visibility = []; - $this->assertEntity('block_2', $visibility, 'right', 'test_theme', -7, '', '0'); + $settings = [ + 'id' => 'block_content', + 'label' => '', + 'provider' => 'block_content', + 'label_display' => '0', + 'status' => TRUE, + 'info' => '', + 'view_mode' => 'full', + ]; + $this->assertEntity('block_2', [], 'right', 'test_theme', -7, $settings); // Custom block with php code is not migrated. $block = Block::load('block_3'); diff --git a/core/modules/block_content/src/Entity/BlockContent.php b/core/modules/block_content/src/Entity/BlockContent.php index 9d0bf74..b7c3d4d 100644 --- a/core/modules/block_content/src/Entity/BlockContent.php +++ b/core/modules/block_content/src/Entity/BlockContent.php @@ -52,6 +52,11 @@ * "langcode" = "langcode", * "uuid" = "uuid" * }, + * revision_metadata_keys = { + * "revision_user" = "revision_user", + * "revision_created" = "revision_created", + * "revision_log_message" = "revision_log" + * }, * bundle_entity_type = "block_content_type", * field_ui_base_route = "entity.block_content_type.edit_form", * render_cache = FALSE, diff --git a/core/modules/book/tests/src/Kernel/Migrate/d6/MigrateBookConfigsTest.php b/core/modules/book/tests/src/Kernel/Migrate/d6/MigrateBookConfigsTest.php index 17a3bff..becf87d 100644 --- a/core/modules/book/tests/src/Kernel/Migrate/d6/MigrateBookConfigsTest.php +++ b/core/modules/book/tests/src/Kernel/Migrate/d6/MigrateBookConfigsTest.php @@ -33,7 +33,7 @@ protected function setUp() { public function testBookSettings() { $config = $this->config('book.settings'); $this->assertIdentical('book', $config->get('child_type')); - $this->assertIdentical('all pages', $config->get('block.navigation.mode')); + $this->assertSame('book pages', $config->get('block.navigation.mode')); $this->assertIdentical(['book'], $config->get('allowed_types')); $this->assertConfigSchema(\Drupal::service('config.typed'), 'book.settings', $config->get()); } diff --git a/core/modules/ckeditor/src/CKEditorPluginButtonsInterface.php b/core/modules/ckeditor/src/CKEditorPluginButtonsInterface.php index 71b6632..4b2d128 100644 --- a/core/modules/ckeditor/src/CKEditorPluginButtonsInterface.php +++ b/core/modules/ckeditor/src/CKEditorPluginButtonsInterface.php @@ -35,8 +35,9 @@ * @return array * An array of buttons that are provided by this plugin. This will * only be used in the administrative section for assembling the toolbar. - * Each button should by keyed by its CKEditor button name, and should - * contain an array of button properties, including: + * Each button should be keyed by its CKEditor button name (you can look up + * the button name up in the plugin.js file), and should contain an array of + * button properties, including: * - label: A human-readable, translated button name. * - image: An image for the button to be used in the toolbar. * - image_rtl: If the image needs to have a right-to-left version, specify diff --git a/core/modules/comment/comment.module b/core/modules/comment/comment.module index ee26510..489a391 100644 --- a/core/modules/comment/comment.module +++ b/core/modules/comment/comment.module @@ -353,8 +353,7 @@ function comment_entity_storage_load($entities, $entity_type) { if (!\Drupal::entityManager()->getDefinition($entity_type)->entityClassImplements(FieldableEntityInterface::class)) { return; } - // @todo Investigate in https://www.drupal.org/node/2527866 why we need that. - if (!\Drupal::hasService('comment.manager') || !\Drupal::service('comment.manager')->getFields($entity_type)) { + if (!\Drupal::service('comment.manager')->getFields($entity_type)) { // Do not query database when entity has no comment fields. return; } diff --git a/core/modules/comment/src/Plugin/views/argument/UserUid.php b/core/modules/comment/src/Plugin/views/argument/UserUid.php index 69621559..7023102 100644 --- a/core/modules/comment/src/Plugin/views/argument/UserUid.php +++ b/core/modules/comment/src/Plugin/views/argument/UserUid.php @@ -3,6 +3,7 @@ namespace Drupal\comment\Plugin\views\argument; use Drupal\Core\Database\Connection; +use Drupal\Core\Database\Query\Condition; use Drupal\views\Plugin\views\argument\ArgumentPluginBase; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -90,7 +91,7 @@ public function query($group_by = FALSE) { $subselect->where("c.entity_id = $this->tableAlias.$entity_id"); $subselect->condition('c.entity_type', $entity_type); - $condition = db_or() + $condition = (new Condition('OR')) ->condition("$this->tableAlias.uid", $this->argument, '=') ->exists($subselect); diff --git a/core/modules/comment/src/Plugin/views/filter/UserUid.php b/core/modules/comment/src/Plugin/views/filter/UserUid.php index adee320..248b460 100644 --- a/core/modules/comment/src/Plugin/views/filter/UserUid.php +++ b/core/modules/comment/src/Plugin/views/filter/UserUid.php @@ -2,6 +2,7 @@ namespace Drupal\comment\Plugin\views\filter; +use Drupal\Core\Database\Query\Condition; use Drupal\views\Plugin\views\filter\FilterPluginBase; /** @@ -26,7 +27,7 @@ public function query() { $subselect->where("c.entity_id = $this->tableAlias.$entity_id"); $subselect->condition('c.entity_type', $entity_type); - $condition = db_or() + $condition = (new Condition('OR')) ->condition("$this->tableAlias.uid", $this->value, $this->operator) ->exists($subselect); diff --git a/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d6/CommentSourceWithHighWaterTest.php b/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d6/CommentSourceWithHighWaterTest.php new file mode 100644 index 0000000..a4d91c4 --- /dev/null +++ b/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d6/CommentSourceWithHighWaterTest.php @@ -0,0 +1,106 @@ + 1, + 'pid' => 0, + 'nid' => 2, + 'uid' => 3, + 'subject' => 'subject value 1', + 'comment' => 'comment value 1', + 'hostname' => 'hostname value 1', + 'timestamp' => 1382255613, + 'status' => 0, + 'thread' => '', + 'name' => '', + 'mail' => '', + 'homepage' => '', + 'format' => 'testformat1', + 'type' => 'story', + ], + [ + 'cid' => 2, + 'pid' => 1, + 'nid' => 3, + 'uid' => 4, + 'subject' => 'subject value 2', + 'comment' => 'comment value 2', + 'hostname' => 'hostname value 2', + 'timestamp' => 1382255662, + 'status' => 0, + 'thread' => '', + 'name' => '', + 'mail' => '', + 'homepage' => '', + 'format' => 'testformat2', + 'type' => 'page', + ], + ]; + + $tests[0]['source_data']['node'] = [ + [ + 'nid' => 2, + 'type' => 'story', + ], + [ + 'nid' => 3, + 'type' => 'page', + ], + ]; + + // The expected results. + $tests[0]['expected_data'] = [ + [ + 'cid' => 2, + 'pid' => 1, + 'nid' => 3, + 'uid' => 4, + 'subject' => 'subject value 2', + 'comment' => 'comment value 2', + 'hostname' => 'hostname value 2', + 'timestamp' => 1382255662, + 'status' => 1, + 'thread' => '', + 'name' => '', + 'mail' => '', + 'homepage' => '', + 'format' => 'testformat2', + 'type' => 'page', + ], + ]; + + // The expected count is the count returned by the query before the query + // is modified by SqlBase::initializeIterator(). + $tests[0]['expected_count'] = 2; + + $tests[0]['configuration']['high_water_property']['name'] = 'timestamp'; + $tests[0]['high_water'] = $tests[0]['source_data']['comments'][0]['timestamp']; + return $tests; + } + +} diff --git a/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d6/CommentTest.php b/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d6/CommentTest.php new file mode 100644 index 0000000..0372f0f --- /dev/null +++ b/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d6/CommentTest.php @@ -0,0 +1,116 @@ + 1, + 'pid' => 0, + 'nid' => 2, + 'uid' => 3, + 'subject' => 'subject value 1', + 'comment' => 'comment value 1', + 'hostname' => 'hostname value 1', + 'timestamp' => 1382255613, + 'status' => 0, + 'thread' => '', + 'name' => '', + 'mail' => '', + 'homepage' => '', + 'format' => 'testformat1', + 'type' => 'story', + ], + [ + 'cid' => 2, + 'pid' => 1, + 'nid' => 3, + 'uid' => 4, + 'subject' => 'subject value 2', + 'comment' => 'comment value 2', + 'hostname' => 'hostname value 2', + 'timestamp' => 1382255662, + 'status' => 0, + 'thread' => '', + 'name' => '', + 'mail' => '', + 'homepage' => '', + 'format' => 'testformat2', + 'type' => 'page', + ], + ]; + + $tests[0]['source_data']['node'] = [ + [ + 'nid' => 2, + 'type' => 'story', + ], + [ + 'nid' => 3, + 'type' => 'page', + ], + ]; + + // The expected results. + $tests[0]['expected_data'] = [ + [ + 'cid' => 1, + 'pid' => 0, + 'nid' => 2, + 'uid' => 3, + 'subject' => 'subject value 1', + 'comment' => 'comment value 1', + 'hostname' => 'hostname value 1', + 'timestamp' => 1382255613, + 'status' => 1, + 'thread' => '', + 'name' => '', + 'mail' => '', + 'homepage' => '', + 'format' => 'testformat1', + 'type' => 'story', + ], + [ + 'cid' => 2, + 'pid' => 1, + 'nid' => 3, + 'uid' => 4, + 'subject' => 'subject value 2', + 'comment' => 'comment value 2', + 'hostname' => 'hostname value 2', + 'timestamp' => 1382255662, + 'status' => 1, + 'thread' => '', + 'name' => '', + 'mail' => '', + 'homepage' => '', + 'format' => 'testformat2', + 'type' => 'page', + ], + ]; + + return $tests; + } + +} diff --git a/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d6/CommentVariablePerCommentTypeTest.php b/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d6/CommentVariablePerCommentTypeTest.php new file mode 100644 index 0000000..dd1b1fd --- /dev/null +++ b/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d6/CommentVariablePerCommentTypeTest.php @@ -0,0 +1,62 @@ + 'page', + ], + [ + 'type' => 'story', + ], + ]; + + $tests[0]['source_data']['variable'] = [ + [ + 'name' => 'comment_subject_field_page', + 'value' => serialize(1), + ], + [ + 'name' => 'comment_subject_field_story', + 'value' => serialize(0), + ], + ]; + + // The expected results. + // Each result will also include a label and description, but those are + // static values set by the source plugin and don't need to be asserted. + $tests[0]['expected_data'] = [ + [ + 'comment_type' => 'comment', + ], + [ + 'comment_type' => 'comment_no_subject', + ], + ]; + + return $tests; + } + +} diff --git a/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d6/CommentVariableTest.php b/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d6/CommentVariableTest.php new file mode 100644 index 0000000..a84c776 --- /dev/null +++ b/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d6/CommentVariableTest.php @@ -0,0 +1,92 @@ + 'page', + ], + ]; + + $tests[0]['source_data']['variable'] = [ + [ + 'name' => 'comment_page', + 'value' => serialize(1), + ], + [ + 'name' => 'comment_default_mode_page', + 'value' => serialize(1), + ], + [ + 'name' => 'comment_default_order_page', + 'value' => serialize(1), + ], + [ + 'name' => 'comment_default_per_page_page', + 'value' => serialize(50), + ], + [ + 'name' => 'comment_controls_page', + 'value' => serialize(1), + ], + [ + 'name' => 'comment_anonymous_page', + 'value' => serialize(1), + ], + [ + 'name' => 'comment_subject_field_page', + 'value' => serialize(1), + ], + [ + 'name' => 'comment_preview_page', + 'value' => serialize(1), + ], + [ + 'name' => 'comment_form_location_page', + 'value' => serialize(1), + ], + ]; + + // The expected results. + $tests[0]['expected_data'] = [ + [ + 'comment' => '1', + 'comment_default_mode' => '1', + 'comment_default_order' => '1', + 'comment_default_per_page' => '50', + 'comment_controls' => '1', + 'comment_anonymous' => '1', + 'comment_subject_field' => '1', + 'comment_preview' => '1', + 'comment_form_location' => '1', + 'node_type' => 'page', + 'comment_type' => 'comment', + ], + ]; + + return $tests; + } + +} diff --git a/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d7/CommentTest.php b/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d7/CommentTest.php new file mode 100644 index 0000000..9d9760b --- /dev/null +++ b/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d7/CommentTest.php @@ -0,0 +1,117 @@ + '1', + 'pid' => '0', + 'nid' => '1', + 'uid' => '1', + 'subject' => 'A comment', + 'hostname' => '::1', + 'created' => '1421727536', + 'changed' => '1421727536', + 'status' => '1', + 'thread' => '01/', + 'name' => 'admin', + 'mail' => '', + 'homepage' => '', + 'language' => 'und', + ], + ]; + $tests[0]['source_data']['node'] = [ + [ + 'nid' => '1', + 'vid' => '1', + 'type' => 'test_content_type', + 'language' => 'en', + 'title' => 'A Node', + 'uid' => '1', + 'status' => '1', + 'created' => '1421727515', + 'changed' => '1421727515', + 'comment' => '2', + 'promote' => '1', + 'sticky' => '0', + 'tnid' => '0', + 'translate' => '0', + ], + ]; + $tests[0]['source_data']['field_config_instance'] = [ + [ + 'id' => '14', + 'field_id' => '1', + 'field_name' => 'comment_body', + 'entity_type' => 'comment', + 'bundle' => 'comment_node_test_content_type', + 'data' => 'a:0:{}', + 'deleted' => '0', + ], + ]; + $tests[0]['source_data']['field_data_comment_body'] = [ + [ + 'entity_type' => 'comment', + 'bundle' => 'comment_node_test_content_type', + 'deleted' => '0', + 'entity_id' => '1', + 'revision_id' => '1', + 'language' => 'und', + 'delta' => '0', + 'comment_body_value' => 'This is a comment', + 'comment_body_format' => 'filtered_html', + ], + ]; + + // The expected results. + $tests[0]['expected_data'] = [ + [ + 'cid' => '1', + 'pid' => '0', + 'nid' => '1', + 'uid' => '1', + 'subject' => 'A comment', + 'hostname' => '::1', + 'created' => '1421727536', + 'changed' => '1421727536', + 'status' => '1', + 'thread' => '01/', + 'name' => 'admin', + 'mail' => '', + 'homepage' => '', + 'language' => 'und', + 'comment_body' => [ + [ + 'value' => 'This is a comment', + 'format' => 'filtered_html', + ], + ], + ], + ]; + + return $tests; + } + +} diff --git a/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d7/CommentTypeTest.php b/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d7/CommentTypeTest.php new file mode 100644 index 0000000..11f3db5 --- /dev/null +++ b/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/d7/CommentTypeTest.php @@ -0,0 +1,99 @@ + 'article', + 'name' => 'Article', + 'base' => 'node_content', + 'module' => 'node', + 'description' => 'Use articles for time-sensitive content like news, press releases or blog posts.', + 'help' => 'Help text for articles', + 'has_title' => '1', + 'title_label' => 'Title', + 'custom' => '1', + 'modified' => '1', + 'locked' => '0', + 'disabled' => '0', + 'orig_type' => 'article', + ], + ]; + $tests[0]['source_data']['field_config_instance'] = [ + [ + 'id' => '14', + 'field_id' => '1', + 'field_name' => 'comment_body', + 'entity_type' => 'comment', + 'bundle' => 'comment_node_article', + 'data' => 'a:0:{}', + 'deleted' => '0', + ], + ]; + $tests[0]['source_data']['variable'] = [ + [ + 'name' => 'comment_default_mode_article', + 'value' => serialize(1), + ], + [ + 'name' => 'comment_per_page_article', + 'value' => serialize(50), + ], + [ + 'name' => 'comment_anonymous_article', + 'value' => serialize(0), + ], + [ + 'name' => 'comment_form_location_article', + 'value' => serialize(1), + ], + [ + 'name' => 'comment_preview_article', + 'value' => serialize(0), + ], + [ + 'name' => 'comment_subject_article', + 'value' => serialize(1), + ], + ]; + + // The expected results. + $tests[0]['expected_data'] = [ + [ + 'bundle' => 'comment_node_article', + 'node_type' => 'article', + 'default_mode' => '1', + 'per_page' => '50', + 'anonymous' => '0', + 'form_location' => '1', + 'preview' => '0', + 'subject' => '1', + 'label' => 'Article comment', + ], + ]; + return $tests; + } + +} diff --git a/core/modules/comment/tests/src/Unit/Migrate/d6/CommentSourceWithHighWaterTest.php b/core/modules/comment/tests/src/Unit/Migrate/d6/CommentSourceWithHighWaterTest.php deleted file mode 100644 index b2cbaa7..0000000 --- a/core/modules/comment/tests/src/Unit/Migrate/d6/CommentSourceWithHighWaterTest.php +++ /dev/null @@ -1,23 +0,0 @@ -migrationConfiguration['source']['high_water_property']['name'] = 'timestamp'; - array_shift($this->expectedResults); - parent::setUp(); - } - -} diff --git a/core/modules/comment/tests/src/Unit/Migrate/d6/CommentTest.php b/core/modules/comment/tests/src/Unit/Migrate/d6/CommentTest.php deleted file mode 100644 index 2056d24..0000000 --- a/core/modules/comment/tests/src/Unit/Migrate/d6/CommentTest.php +++ /dev/null @@ -1,12 +0,0 @@ - 'test', - // This needs to be the identifier of the actual key: cid for comment, nid - // for node and so on. - 'source' => [ - 'plugin' => 'd6_comment', - ], - ]; - - // We need to set up the database contents; it's easier to do that below. - - protected $expectedResults = [ - [ - 'cid' => 1, - 'pid' => 0, - 'nid' => 2, - 'uid' => 3, - 'subject' => 'subject value 1', - 'comment' => 'comment value 1', - 'hostname' => 'hostname value 1', - 'timestamp' => 1382255613, - 'status' => 1, - 'thread' => '', - 'name' => '', - 'mail' => '', - 'homepage' => '', - 'format' => 'testformat1', - 'type' => 'story', - ], - [ - 'cid' => 2, - 'pid' => 1, - 'nid' => 3, - 'uid' => 4, - 'subject' => 'subject value 2', - 'comment' => 'comment value 2', - 'hostname' => 'hostname value 2', - 'timestamp' => 1382255662, - 'status' => 1, - 'thread' => '', - 'name' => '', - 'mail' => '', - 'homepage' => '', - 'format' => 'testformat2', - 'type' => 'page', - ], - ]; - - /** - * {@inheritdoc} - */ - protected function setUp() { - foreach ($this->expectedResults as $k => $row) { - $this->databaseContents['comments'][$k] = $row; - $this->databaseContents['comments'][$k]['status'] = 1 - $this->databaseContents['comments'][$k]['status']; - } - // Add node table data. - $this->databaseContents['node'][] = ['nid' => 2, 'type' => 'story']; - $this->databaseContents['node'][] = ['nid' => 3, 'type' => 'page']; - parent::setUp(); - } - -} diff --git a/core/modules/comment/tests/src/Unit/Migrate/d6/CommentVariablePerCommentTypeTest.php b/core/modules/comment/tests/src/Unit/Migrate/d6/CommentVariablePerCommentTypeTest.php deleted file mode 100644 index 892319a..0000000 --- a/core/modules/comment/tests/src/Unit/Migrate/d6/CommentVariablePerCommentTypeTest.php +++ /dev/null @@ -1,59 +0,0 @@ - 'test', - 'source' => [ - 'plugin' => 'd6_comment_variable_per_comment_type', - ], - ]; - - protected $expectedResults = [ - // Each result will also include a label and description, but those are - // static values set by the source plugin and don't need to be asserted. - [ - 'comment_type' => 'comment', - ], - [ - 'comment_type' => 'comment_no_subject', - ], - ]; - - /** - * {@inheritdoc} - */ - protected function setUp() { - $this->databaseContents['node_type'] = [ - [ - 'type' => 'page', - ], - [ - 'type' => 'story', - ], - ]; - $this->databaseContents['variable'] = [ - [ - 'name' => 'comment_subject_field_page', - 'value' => serialize(1), - ], - [ - 'name' => 'comment_subject_field_story', - 'value' => serialize(0), - ], - ]; - parent::setUp(); - } - -} diff --git a/core/modules/comment/tests/src/Unit/Migrate/d6/CommentVariableTest.php b/core/modules/comment/tests/src/Unit/Migrate/d6/CommentVariableTest.php deleted file mode 100644 index 406cd11..0000000 --- a/core/modules/comment/tests/src/Unit/Migrate/d6/CommentVariableTest.php +++ /dev/null @@ -1,89 +0,0 @@ - 'test', - 'source' => [ - 'plugin' => 'd6_comment_variable', - ], - ]; - - protected $expectedResults = [ - [ - 'comment' => '1', - 'comment_default_mode' => '1', - 'comment_default_order' => '1', - 'comment_default_per_page' => '50', - 'comment_controls' => '1', - 'comment_anonymous' => '1', - 'comment_subject_field' => '1', - 'comment_preview' => '1', - 'comment_form_location' => '1', - 'node_type' => 'page', - 'comment_type' => 'comment', - ], - ]; - - /** - * {@inheritdoc} - */ - protected function setUp() { - $this->databaseContents['node_type'] = [ - [ - 'type' => 'page', - ], - ]; - $this->databaseContents['variable'] = [ - [ - 'name' => 'comment_page', - 'value' => serialize(1), - ], - [ - 'name' => 'comment_default_mode_page', - 'value' => serialize(1), - ], - [ - 'name' => 'comment_default_order_page', - 'value' => serialize(1), - ], - [ - 'name' => 'comment_default_per_page_page', - 'value' => serialize(50), - ], - [ - 'name' => 'comment_controls_page', - 'value' => serialize(1), - ], - [ - 'name' => 'comment_anonymous_page', - 'value' => serialize(1), - ], - [ - 'name' => 'comment_subject_field_page', - 'value' => serialize(1), - ], - [ - 'name' => 'comment_preview_page', - 'value' => serialize(1), - ], - [ - 'name' => 'comment_form_location_page', - 'value' => serialize(1), - ], - ]; - parent::setUp(); - } - -} diff --git a/core/modules/comment/tests/src/Unit/Migrate/d7/CommentTest.php b/core/modules/comment/tests/src/Unit/Migrate/d7/CommentTest.php deleted file mode 100644 index aed2f28..0000000 --- a/core/modules/comment/tests/src/Unit/Migrate/d7/CommentTest.php +++ /dev/null @@ -1,100 +0,0 @@ - 'test', - 'source' => [ - 'plugin' => 'd7_comment', - ], - ]; - - protected $expectedResults = [ - [ - 'cid' => '1', - 'pid' => '0', - 'nid' => '1', - 'uid' => '1', - 'subject' => 'A comment', - 'hostname' => '::1', - 'created' => '1421727536', - 'changed' => '1421727536', - 'status' => '1', - 'thread' => '01/', - 'name' => 'admin', - 'mail' => '', - 'homepage' => '', - 'language' => 'und', - 'comment_body' => [ - [ - 'value' => 'This is a comment', - 'format' => 'filtered_html', - ], - ], - ], - ]; - - /** - * {@inheritdoc} - */ - protected function setUp() { - $this->databaseContents['comment'] = $this->expectedResults; - unset($this->databaseContents['comment'][0]['comment_body']); - - $this->databaseContents['node'] = [ - [ - 'nid' => '1', - 'vid' => '1', - 'type' => 'test_content_type', - 'language' => 'en', - 'title' => 'A Node', - 'uid' => '1', - 'status' => '1', - 'created' => '1421727515', - 'changed' => '1421727515', - 'comment' => '2', - 'promote' => '1', - 'sticky' => '0', - 'tnid' => '0', - 'translate' => '0', - ], - ]; - $this->databaseContents['field_config_instance'] = [ - [ - 'id' => '14', - 'field_id' => '1', - 'field_name' => 'comment_body', - 'entity_type' => 'comment', - 'bundle' => 'comment_node_test_content_type', - 'data' => 'a:0:{}', - 'deleted' => '0', - ], - ]; - $this->databaseContents['field_data_comment_body'] = [ - [ - 'entity_type' => 'comment', - 'bundle' => 'comment_node_test_content_type', - 'deleted' => '0', - 'entity_id' => '1', - 'revision_id' => '1', - 'language' => 'und', - 'delta' => '0', - 'comment_body_value' => 'This is a comment', - 'comment_body_format' => 'filtered_html', - ], - ]; - parent::setUp(); - } - -} diff --git a/core/modules/comment/tests/src/Unit/Migrate/d7/CommentTypeTest.php b/core/modules/comment/tests/src/Unit/Migrate/d7/CommentTypeTest.php deleted file mode 100644 index 106716d..0000000 --- a/core/modules/comment/tests/src/Unit/Migrate/d7/CommentTypeTest.php +++ /dev/null @@ -1,98 +0,0 @@ - 'test', - 'source' => [ - 'plugin' => 'd7_comment_type', - ], - ]; - - protected $expectedResults = [ - [ - 'bundle' => 'comment_node_article', - 'node_type' => 'article', - 'default_mode' => '1', - 'per_page' => '50', - 'anonymous' => '0', - 'form_location' => '1', - 'preview' => '0', - 'subject' => '1', - 'label' => 'Article comment', - ], - ]; - - /** - * {@inheritdoc} - */ - protected function setUp() { - $this->databaseContents['node_type'] = [ - [ - 'type' => 'article', - 'name' => 'Article', - 'base' => 'node_content', - 'module' => 'node', - 'description' => 'Use articles for time-sensitive content like news, press releases or blog posts.', - 'help' => 'Help text for articles', - 'has_title' => '1', - 'title_label' => 'Title', - 'custom' => '1', - 'modified' => '1', - 'locked' => '0', - 'disabled' => '0', - 'orig_type' => 'article', - ], - ]; - $this->databaseContents['field_config_instance'] = [ - [ - 'id' => '14', - 'field_id' => '1', - 'field_name' => 'comment_body', - 'entity_type' => 'comment', - 'bundle' => 'comment_node_article', - 'data' => 'a:0:{}', - 'deleted' => '0', - ], - ]; - $this->databaseContents['variable'] = [ - [ - 'name' => 'comment_default_mode_article', - 'value' => serialize(1), - ], - [ - 'name' => 'comment_per_page_article', - 'value' => serialize(50), - ], - [ - 'name' => 'comment_anonymous_article', - 'value' => serialize(0), - ], - [ - 'name' => 'comment_form_location_article', - 'value' => serialize(1), - ], - [ - 'name' => 'comment_preview_article', - 'value' => serialize(0), - ], - [ - 'name' => 'comment_subject_article', - 'value' => serialize(1), - ], - ]; - parent::setUp(); - } - -} diff --git a/core/modules/config/src/Tests/ConfigImportInstallProfileTest.php b/core/modules/config/src/Tests/ConfigImportInstallProfileTest.php index eaece36..f4521fa 100644 --- a/core/modules/config/src/Tests/ConfigImportInstallProfileTest.php +++ b/core/modules/config/src/Tests/ConfigImportInstallProfileTest.php @@ -56,7 +56,7 @@ public function testInstallProfileValidation() { $this->drupalPostForm('admin/config/development/configuration', [], t('Import all')); $this->assertText('The configuration cannot be imported because it failed validation for the following reasons:'); - $this->assertText('Unable to uninstall the Testing config import profile since it is the install profile.'); + $this->assertText('Unable to uninstall the Testing config import profile since it is an installed profile.'); // Uninstall dependencies of testing_config_import. $core['module']['testing_config_import'] = 0; diff --git a/core/modules/config/tests/config_install_double_dependency_test/config/install/config_test.dynamic.other_module_test_with_dependency.yml b/core/modules/config/tests/config_install_double_dependency_test/config/install/config_test.dynamic.other_module_test_with_dependency.yml new file mode 100644 index 0000000..1d62266 --- /dev/null +++ b/core/modules/config/tests/config_install_double_dependency_test/config/install/config_test.dynamic.other_module_test_with_dependency.yml @@ -0,0 +1,13 @@ +id: other_module_test_with_dependency +label: 'Other module test with dependency' +weight: 0 +style: '' +status: true +langcode: en +protected_property: Default +dependencies: + enforced: + module: + - config_other_module_config_test + config: + - config_test.dynamic.dotted.english diff --git a/core/modules/config/tests/config_install_double_dependency_test/config/install/config_test.dynamic.yet_another_module_test_with_dependency.yml b/core/modules/config/tests/config_install_double_dependency_test/config/install/config_test.dynamic.yet_another_module_test_with_dependency.yml new file mode 100644 index 0000000..c59c1e5 --- /dev/null +++ b/core/modules/config/tests/config_install_double_dependency_test/config/install/config_test.dynamic.yet_another_module_test_with_dependency.yml @@ -0,0 +1,13 @@ +id: yet_another_module_test_with_dependency +label: 'Yet anther module test with dependency' +weight: 0 +style: '' +status: true +langcode: en +protected_property: Default +dependencies: + enforced: + module: + - config_other_module_config_test + config: + - config_test.dynamic.dotted.english diff --git a/core/modules/config/tests/config_install_double_dependency_test/config_install_double_dependency_test.info.yml b/core/modules/config/tests/config_install_double_dependency_test/config_install_double_dependency_test.info.yml new file mode 100644 index 0000000..99404cf --- /dev/null +++ b/core/modules/config/tests/config_install_double_dependency_test/config_install_double_dependency_test.info.yml @@ -0,0 +1,5 @@ +name: 'Config install double dependency test' +type: module +package: Testing +version: VERSION +core: 8.x diff --git a/core/modules/config/tests/config_schema_test/config/schema/config_schema_test.schema.yml b/core/modules/config/tests/config_schema_test/config/schema/config_schema_test.schema.yml index c10e268..ce894c2 100644 --- a/core/modules/config/tests/config_schema_test/config/schema/config_schema_test.schema.yml +++ b/core/modules/config/tests/config_schema_test/config/schema/config_schema_test.schema.yml @@ -297,3 +297,41 @@ test.double_brackets.breed: mapping: breed: type: string + +config_schema_test.schema_sequence_sort: + type: config_object + mapping: + keyed_sort: + type: sequence + orderby: key + sequence: + - type: string + value_sort: + type: sequence + orderby: value + sequence: + - type: string + no_sort: + type: sequence + sequence: + - type: string + complex_sort_value: + type: sequence + orderby: value + sequence: + - type: mapping + mapping: + foo: + type: string + bar: + type: string + complex_sort_key: + type: sequence + orderby: key + sequence: + - type: mapping + mapping: + foo: + type: string + bar: + type: string diff --git a/core/modules/config/tests/config_test/config/install/config_test.validation.yml b/core/modules/config/tests/config_test/config/install/config_test.validation.yml new file mode 100644 index 0000000..3741468 --- /dev/null +++ b/core/modules/config/tests/config_test/config/install/config_test.validation.yml @@ -0,0 +1,7 @@ +llama: llama +cat: + type: kitten + count: 2 +giraffe: + hum1: hum1 + hum2: hum2 diff --git a/core/modules/config/tests/config_test/config/schema/config_test.schema.yml b/core/modules/config/tests/config_test/config/schema/config_test.schema.yml index e5b75d1..2cd68a4 100644 --- a/core/modules/config/tests/config_test/config/schema/config_test.schema.yml +++ b/core/modules/config/tests/config_test/config/schema/config_test.schema.yml @@ -158,3 +158,39 @@ config_test.foo: config_test.bar: type: config_test.foo + +config_test.validation: + type: config_object + label: 'Configuration type' + constraints: + Callback: + callback: [\Drupal\config_test\ConfigValidation, validateMapping] + mapping: + llama: + type: string + constraints: + Callback: + callback: [\Drupal\config_test\ConfigValidation, validateLlama] + cat: + type: mapping + mapping: + type: + type: string + constraints: + Callback: + callback: [\Drupal\config_test\ConfigValidation, validateCats] + count: + type: integer + constraints: + Callback: + callback: [\Drupal\config_test\ConfigValidation, validateCatCount] + giraffe: + type: sequence + constraints: + Callback: + callback: [\Drupal\config_test\ConfigValidation, validateSequence] + sequence: + type: string + constraints: + Callback: + callback: [\Drupal\config_test\ConfigValidation, validateGiraffes] diff --git a/core/modules/config/tests/config_test/src/ConfigValidation.php b/core/modules/config/tests/config_test/src/ConfigValidation.php new file mode 100644 index 0000000..f9493b6 --- /dev/null +++ b/core/modules/config/tests/config_test/src/ConfigValidation.php @@ -0,0 +1,96 @@ +addViolation('no valid llama'); + } + } + + /** + * Validates cats. + * + * @param string $string + * The string to validate. + * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context + * The validation execution context. + */ + public static function validateCats($string, ExecutionContextInterface $context) { + if (!in_array($string, ['kitten', 'cats', 'nyans'])) { + $context->addViolation('no valid cat'); + } + } + + /** + * Validates a number. + * + * @param int $count + * The integer to validate. + * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context + * The validation execution context. + */ + public static function validateCatCount($count, ExecutionContextInterface $context) { + if ($count <= 1) { + $context->addViolation('no enough cats'); + } + } + + /** + * Validates giraffes. + * + * @param string $string + * The string to validate. + * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context + * The validation execution context. + */ + public static function validateGiraffes($string, ExecutionContextInterface $context) { + if (strpos($string, 'hum') !== 0) { + $context->addViolation('Giraffes just hum'); + } + } + + /** + * Validates a mapping. + * + * @param array $mapping + * The data to validate. + * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context + * The validation execution context. + */ + public static function validateMapping($mapping, ExecutionContextInterface $context) { + if ($diff = array_diff(array_keys($mapping), ['llama', 'cat', 'giraffe', '_core'])) { + $context->addViolation('Missing giraffe.'); + } + } + + /** + * Validates a sequence. + * + * @param array $sequence + * The data to validate. + * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context + * The validation execution context. + */ + public static function validateSequence($sequence, ExecutionContextInterface $context) { + if (isset($sequence['invalid-key'])) { + $context->addViolation('Invalid giraffe key.'); + } + } + +} diff --git a/core/modules/content_moderation/content_moderation.module b/core/modules/content_moderation/content_moderation.module index 7345e93..77d822b 100644 --- a/core/modules/content_moderation/content_moderation.module +++ b/core/modules/content_moderation/content_moderation.module @@ -10,7 +10,6 @@ use Drupal\content_moderation\ContentPreprocess; use Drupal\content_moderation\Plugin\Action\ModerationOptOutPublishNode; use Drupal\content_moderation\Plugin\Action\ModerationOptOutUnpublishNode; -use Drupal\content_moderation\Plugin\Menu\EditTab; use Drupal\Core\Access\AccessResult; use Drupal\Core\Entity\Display\EntityViewDisplayInterface; use Drupal\Core\Entity\EntityInterface; @@ -100,22 +99,6 @@ function content_moderation_entity_update(EntityInterface $entity) { } /** - * Implements hook_local_tasks_alter(). - */ -function content_moderation_local_tasks_alter(&$local_tasks) { - $content_entity_type_ids = array_keys(array_filter(\Drupal::entityTypeManager()->getDefinitions(), function (EntityTypeInterface $entity_type) { - return $entity_type->isRevisionable(); - })); - - foreach ($content_entity_type_ids as $content_entity_type_id) { - if (isset($local_tasks["entity.$content_entity_type_id.edit_form"])) { - $local_tasks["entity.$content_entity_type_id.edit_form"]['class'] = EditTab::class; - $local_tasks["entity.$content_entity_type_id.edit_form"]['entity_type_id'] = $content_entity_type_id; - } - } -} - -/** * Implements hook_form_alter(). */ function content_moderation_form_alter(&$form, FormStateInterface $form_state, $form_id) { diff --git a/core/modules/content_moderation/src/EntityOperations.php b/core/modules/content_moderation/src/EntityOperations.php index af87f6f..5c8d5c0 100644 --- a/core/modules/content_moderation/src/EntityOperations.php +++ b/core/modules/content_moderation/src/EntityOperations.php @@ -105,9 +105,10 @@ public function entityPresave(EntityInterface $entity) { /** @var \Drupal\content_moderation\ContentModerationState $current_state */ $current_state = $workflow->getState($entity->moderation_state->value); - // This entity is default if it is new, the default revision, or the - // default revision is not published. + // This entity is default if it is new, a new translation, the default + // revision, or the default revision is not published. $update_default_revision = $entity->isNew() + || $entity->isNewTranslation() || $current_state->isDefaultRevisionState() || !$this->isDefaultRevisionPublished($entity, $workflow); @@ -250,8 +251,8 @@ public function entityView(array &$build, EntityInterface $entity, EntityViewDis * Check if the default revision for the given entity is published. * * The default revision is the same as the entity retrieved by "default" from - * the storage handler. If the entity is translated, use the default revision - * of the same language as the given entity. + * the storage handler. If the entity is translated, check if any of the + * translations are published. * * @param \Drupal\Core\Entity\EntityInterface $entity * The entity being saved. @@ -262,21 +263,22 @@ public function entityView(array &$build, EntityInterface $entity, EntityViewDis * TRUE if the default revision is published. FALSE otherwise. */ protected function isDefaultRevisionPublished(EntityInterface $entity, WorkflowInterface $workflow) { - $storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId()); - $default_revision = $storage->load($entity->id()); + $default_revision = $this->entityTypeManager->getStorage($entity->getEntityTypeId())->load($entity->id()); - // Ensure we are comparing the same translation as the current entity. + // Ensure we are checking all translations of the default revision. if ($default_revision instanceof TranslatableInterface && $default_revision->isTranslatable()) { - // If there is no translation, then there is no default revision and is - // therefore not published. - if (!$default_revision->hasTranslation($entity->language()->getId())) { - return FALSE; + // Loop through each language that has a translation. + foreach ($default_revision->getTranslationLanguages() as $language) { + // Load the translated revision. + $language_revision = $default_revision->getTranslation($language->getId()); + // Return TRUE if a translation with a published state is found. + if ($workflow->getState($language_revision->moderation_state->value)->isPublishedState()) { + return TRUE; + } } - - $default_revision = $default_revision->getTranslation($entity->language()->getId()); } - return $default_revision && $workflow->getState($default_revision->moderation_state->value)->isPublishedState(); + return $workflow->getState($default_revision->moderation_state->value)->isPublishedState(); } } diff --git a/core/modules/content_moderation/src/Plugin/Menu/EditTab.php b/core/modules/content_moderation/src/Plugin/Menu/EditTab.php deleted file mode 100644 index b94a459..0000000 --- a/core/modules/content_moderation/src/Plugin/Menu/EditTab.php +++ /dev/null @@ -1,105 +0,0 @@ -stringTranslation = $string_translation; - $this->moderationInfo = $moderation_information; - } - - /** - * {@inheritdoc} - */ - public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { - return new static( - $configuration, - $plugin_id, - $plugin_definition, - $container->get('string_translation'), - $container->get('content_moderation.moderation_information') - ); - } - - /** - * {@inheritdoc} - */ - public function getRouteParameters(RouteMatchInterface $route_match) { - $entity_parameter = $route_match->getParameter($this->pluginDefinition['entity_type_id']); - $this->entity = $entity_parameter instanceof ContentEntityInterface ? $route_match->getParameter($this->pluginDefinition['entity_type_id']) : FALSE; - return parent::getRouteParameters($route_match); - } - - /** - * {@inheritdoc} - */ - public function getTitle() { - // If the entity couldn't be loaded or moderation isn't enabled. - if (!$this->entity || !$this->moderationInfo->isModeratedEntity($this->entity)) { - return parent::getTitle(); - } - - return $this->moderationInfo->isLiveRevision($this->entity) - ? $this->t('New draft') - : $this->t('Edit draft'); - } - - /** - * {@inheritdoc} - */ - public function getCacheTags() { - $tags = parent::getCacheTags(); - // Tab changes if node or node-type is modified. - if ($this->entity) { - $tags = array_merge($tags, $this->entity->getCacheTags()); - $tags[] = $this->entity->getEntityType()->getBundleEntityType() . ':' . $this->entity->bundle(); - } - return $tags; - } - -} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php b/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php index 099b4dc..1911d9f 100644 --- a/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php +++ b/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php @@ -2,7 +2,6 @@ namespace Drupal\content_moderation\Tests; - /** * Tests moderation state node type integration. * @@ -30,10 +29,25 @@ public function testNotModerated() { * Tests enabling moderation on an existing node-type, with content. */ public function testEnablingOnExistingContent() { + $editor_permissions = [ + 'administer content moderation', + 'access administration pages', + 'administer content types', + 'administer nodes', + 'view latest version', + 'view any unpublished content', + 'access content overview', + 'use editorial transition create_new_draft', + ]; + $publish_permissions = array_merge($editor_permissions, ['use editorial transition publish']); + $editor = $this->drupalCreateUser($editor_permissions); + $editor_with_publish = $this->drupalCreateUser($publish_permissions); + // Create a node type that is not moderated. - $this->drupalLogin($this->adminUser); + $this->drupalLogin($editor); $this->createContentTypeFromUi('Not moderated', 'not_moderated'); - $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'not_moderated'); + $this->grantUserPermissionToCreateContentOfType($editor, 'not_moderated'); + $this->grantUserPermissionToCreateContentOfType($editor_with_publish, 'not_moderated'); // Create content. $this->drupalGet('node/add/not_moderated'); @@ -68,7 +82,13 @@ public function testEnablingOnExistingContent() { $this->drupalGet('node/' . $node->id() . '/edit'); $this->assertResponse(200); $this->assertRaw('Save and Create New Draft'); - $this->assertNoRaw('Save and publish'); + $this->assertNoRaw('Save and Publish'); + + $this->drupalLogin($editor_with_publish); + $this->drupalGet('node/' . $node->id() . '/edit'); + $this->assertResponse(200); + $this->assertRaw('Save and Create New Draft'); + $this->assertRaw('Save and Publish'); } } diff --git a/core/modules/content_moderation/tests/src/Functional/LocalTaskTest.php b/core/modules/content_moderation/tests/src/Functional/LocalTaskTest.php deleted file mode 100644 index 586a91a..0000000 --- a/core/modules/content_moderation/tests/src/Functional/LocalTaskTest.php +++ /dev/null @@ -1,96 +0,0 @@ -drupalPlaceBlock('local_tasks_block', ['id' => 'tabs_block']); - $this->drupalLogin($this->createUser(['bypass node access'])); - - $node_type = $this->createContentType([ - 'type' => 'test_content_type', - ]); - - // Now enable moderation for subsequent nodes. - $workflow = Workflow::load('editorial'); - $workflow->getTypePlugin()->addEntityTypeAndBundle('node', $node_type->id()); - $workflow->save(); - - $this->testNode = $this->createNode([ - 'type' => $node_type->id(), - 'moderation_state' => 'draft', - ]); - } - - /** - * Tests local tasks behave with content_moderation enabled. - */ - public function testLocalTasks() { - // The default state is a draft. - $this->drupalGet(sprintf('node/%s', $this->testNode->id())); - $this->assertTasks('Edit draft'); - - // When published as the live revision, the label changes. - $this->testNode->moderation_state = 'published'; - $this->testNode->save(); - $this->drupalGet(sprintf('node/%s', $this->testNode->id())); - $this->assertTasks('New draft'); - - $tags = $this->drupalGetHeader('X-Drupal-Cache-Tags'); - $this->assertContains('node:1', $tags); - $this->assertContains('node_type:test_content_type', $tags); - - // Without an upcast node, the state cannot be determined. - $this->clickLink('Task Without Upcast Node'); - $this->assertTasks('Edit'); - } - - /** - * Assert the correct tasks appear. - * - * @param string $edit_tab_label - * The edit tab label to assert. - */ - protected function assertTasks($edit_tab_label) { - $this->assertSession()->linkExists('View'); - $this->assertSession()->linkExists('Task Without Upcast Node'); - $this->assertSession()->linkExists($edit_tab_label); - $this->assertSession()->linkExists('Delete'); - } - -} diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php index 5151155..70a5df5 100644 --- a/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php +++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php @@ -53,6 +53,7 @@ protected function setUp() { $this->installEntitySchema('user'); $this->installEntitySchema('entity_test_with_bundle'); $this->installEntitySchema('entity_test_rev'); + $this->installEntitySchema('entity_test_no_bundle'); $this->installEntitySchema('entity_test_mulrevpub'); $this->installEntitySchema('block_content'); $this->installEntitySchema('content_moderation_state'); @@ -178,7 +179,10 @@ public function basicModerationTestCases() { ], 'Entity Test with revisions' => [ 'entity_test_rev', - ] + ], + 'Entity without bundle' => [ + 'entity_test_no_bundle', + ], ]; } @@ -400,6 +404,7 @@ public function testWorkflowDependencies() { // Test both a config and non-config based bundle and entity type. $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); $workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_rev', 'entity_test_rev'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_no_bundle', 'entity_test_no_bundle'); $workflow->save(); $this->assertEquals([ @@ -412,9 +417,11 @@ public function testWorkflowDependencies() { ], ], $workflow->getDependencies()); - $entity_types = $workflow->getTypePlugin()->getEntityTypes(); - $this->assertTrue(in_array('node', $entity_types)); - $this->assertTrue(in_array('entity_test_rev', $entity_types)); + $this->assertEquals([ + 'entity_test_no_bundle', + 'entity_test_rev', + 'node' + ], $workflow->getTypePlugin()->getEntityTypes()); // Delete the node type and ensure it is removed from the workflow. $node_type->delete(); @@ -426,7 +433,7 @@ public function testWorkflowDependencies() { $this->container->get('config.manager')->uninstall('module', 'entity_test'); $workflow = Workflow::load('editorial'); $entity_types = $workflow->getTypePlugin()->getEntityTypes(); - $this->assertFalse(in_array('entity_test_rev', $entity_types)); + $this->assertEquals([], $entity_types); } /** diff --git a/core/modules/content_moderation/tests/src/Kernel/DefaultRevisionStateTest.php b/core/modules/content_moderation/tests/src/Kernel/DefaultRevisionStateTest.php new file mode 100644 index 0000000..274e8b5 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/DefaultRevisionStateTest.php @@ -0,0 +1,109 @@ +installSchema('node', 'node_access'); + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + $this->installEntitySchema('entity_test_with_bundle'); + $this->installEntitySchema('entity_test_rev'); + $this->installEntitySchema('entity_test_mulrevpub'); + $this->installEntitySchema('block_content'); + $this->installEntitySchema('content_moderation_state'); + $this->installConfig('content_moderation'); + + $this->entityTypeManager = $this->container->get('entity_type.manager'); + } + + /** + * Tests a translatable Node. + */ + public function testMultilingual() { + // Enable French. + ConfigurableLanguage::createFromLangcode('fr')->save(); + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->save(); + + $this->container->get('content_translation.manager')->setEnabled('node', 'example', TRUE); + + $workflow = Workflow::load('editorial'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); + + $english_node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + ]); + // Revision 1 (en). + $english_node + ->setUnpublished() + ->save(); + $this->assertEquals('draft', $english_node->moderation_state->value); + $this->assertFalse($english_node->isPublished()); + $this->assertTrue($english_node->isDefaultRevision()); + + // Revision 2 (fr) + $french_node = $english_node->addTranslation('fr', ['title' => 'French title']); + $french_node->moderation_state->value = 'published'; + $french_node->save(); + $this->assertTrue($french_node->isPublished()); + $this->assertTrue($french_node->isDefaultRevision()); + + // Revision 3 (fr) + $node = Node::load($english_node->id())->getTranslation('fr'); + $node->moderation_state->value = 'draft'; + $node->save(); + $this->assertFalse($node->isPublished()); + $this->assertFalse($node->isDefaultRevision()); + + // Revision 4 (en) + $latest_revision = $this->entityTypeManager->getStorage('node')->loadRevision(3); + $latest_revision->moderation_state->value = 'draft'; + $latest_revision->save(); + $this->assertFalse($latest_revision->isPublished()); + $this->assertFalse($latest_revision->isDefaultRevision()); + } + +} diff --git a/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php b/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php index 5de0b2a..8084716 100644 --- a/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php +++ b/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php @@ -5,13 +5,14 @@ use Drupal\content_moderation\ContentPreprocess; use Drupal\Core\Routing\CurrentRouteMatch; use Drupal\node\Entity\Node; +use Drupal\Tests\UnitTestCase; /** * @coversDefaultClass \Drupal\content_moderation\ContentPreprocess * * @group content_moderation */ -class ContentPreprocessTest extends \PHPUnit_Framework_TestCase { +class ContentPreprocessTest extends UnitTestCase { /** * @covers ::isLatestVersionPage diff --git a/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php b/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php index 1f8838b..412a0ea 100644 --- a/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php +++ b/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php @@ -9,13 +9,14 @@ use Drupal\node\Entity\Node; use Drupal\content_moderation\Access\LatestRevisionCheck; use Drupal\content_moderation\ModerationInformation; +use Drupal\Tests\UnitTestCase; use Symfony\Component\Routing\Route; /** * @coversDefaultClass \Drupal\content_moderation\Access\LatestRevisionCheck * @group content_moderation */ -class LatestRevisionCheckTest extends \PHPUnit_Framework_TestCase { +class LatestRevisionCheckTest extends UnitTestCase { /** * Test the access check of the LatestRevisionCheck service. diff --git a/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php b/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php index 8d319ef..57fe761 100644 --- a/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php +++ b/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php @@ -10,13 +10,14 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Session\AccountInterface; use Drupal\content_moderation\ModerationInformation; +use Drupal\Tests\UnitTestCase; use Drupal\workflows\WorkflowInterface; /** * @coversDefaultClass \Drupal\content_moderation\ModerationInformation * @group content_moderation */ -class ModerationInformationTest extends \PHPUnit_Framework_TestCase { +class ModerationInformationTest extends UnitTestCase { /** * Builds a mock user. diff --git a/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php b/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php index 2e03447..b7c3942 100644 --- a/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php +++ b/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php @@ -7,6 +7,7 @@ use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Session\AccountInterface; use Drupal\content_moderation\StateTransitionValidation; +use Drupal\Tests\UnitTestCase; use Drupal\workflows\Entity\Workflow; use Drupal\workflows\WorkflowTypeInterface; use Drupal\workflows\WorkflowTypeManager; @@ -16,7 +17,7 @@ * @coversDefaultClass \Drupal\content_moderation\StateTransitionValidation * @group content_moderation */ -class StateTransitionValidationTest extends \PHPUnit_Framework_TestCase { +class StateTransitionValidationTest extends UnitTestCase { /** * Verifies user-aware transition validation. @@ -62,6 +63,9 @@ protected function setUpModerationInformation(ContentEntityInterface $entity) { // mocked. $container = new ContainerBuilder(); $workflow_type = $this->prophesize(WorkflowTypeInterface::class); + $workflow_type->setConfiguration(Argument::any())->will(function ($arguments) { + $this->getConfiguration()->willReturn($arguments[0]); + }); $workflow_type->decorateState(Argument::any())->willReturnArgument(0); $workflow_type->decorateTransition(Argument::any())->willReturnArgument(0); $workflow_manager = $this->prophesize(WorkflowTypeManager::class); diff --git a/core/modules/contextual/contextual.module b/core/modules/contextual/contextual.module index cf20318..a814c39 100644 --- a/core/modules/contextual/contextual.module +++ b/core/modules/contextual/contextual.module @@ -36,6 +36,7 @@ function contextual_toolbar() { '#attributes' => [ 'class' => ['toolbar-icon', 'toolbar-icon-edit'], 'aria-pressed' => 'false', + 'type' => 'button', ], ], '#wrapper_attributes' => [ diff --git a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeCustomFormatter.php b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeCustomFormatter.php index 445b7ee..984423d 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeCustomFormatter.php +++ b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeCustomFormatter.php @@ -32,30 +32,18 @@ public static function defaultSettings() { * {@inheritdoc} */ public function viewElements(FieldItemListInterface $items, $langcode) { + // @todo Evaluate removing this method in + // https://www.drupal.org/node/2793143 to determine if the behavior and + // markup in the base class implementation can be used instead. $elements = []; foreach ($items as $delta => $item) { - $output = ''; if (!empty($item->date)) { /** @var \Drupal\Core\Datetime\DrupalDateTime $date */ $date = $item->date; - if ($this->getFieldSetting('datetime_type') == 'date') { - // A date without time will pick up the current time, use the default. - datetime_date_default_time($date); - } - $this->setTimeZone($date); - - $output = $this->formatDate($date); + $elements[$delta] = $this->buildDate($date); } - $elements[$delta] = [ - '#markup' => $output, - '#cache' => [ - 'contexts' => [ - 'timezone', - ], - ], - ]; } return $elements; diff --git a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeDefaultFormatter.php b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeDefaultFormatter.php index 7ddecec..0b75b4e 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeDefaultFormatter.php +++ b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeDefaultFormatter.php @@ -3,7 +3,6 @@ namespace Drupal\datetime\Plugin\Field\FieldFormatter; use Drupal\Core\Datetime\DrupalDateTime; -use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Form\FormStateInterface; /** @@ -31,59 +30,6 @@ public static function defaultSettings() { /** * {@inheritdoc} */ - public function viewElements(FieldItemListInterface $items, $langcode) { - $elements = []; - - foreach ($items as $delta => $item) { - $output = ''; - $iso_date = ''; - - if ($item->date) { - /** @var \Drupal\Core\Datetime\DrupalDateTime $date */ - $date = $item->date; - - if ($this->getFieldSetting('datetime_type') == 'date') { - // A date without time will pick up the current time, use the default. - datetime_date_default_time($date); - } - - // Create the ISO date in Universal Time. - $iso_date = $date->format("Y-m-d\TH:i:s") . 'Z'; - - $this->setTimeZone($date); - - $output = $this->formatDate($date); - } - - // Display the date using theme datetime. - $elements[$delta] = [ - '#cache' => [ - 'contexts' => [ - 'timezone', - ], - ], - '#theme' => 'time', - '#text' => $output, - '#html' => FALSE, - '#attributes' => [ - 'datetime' => $iso_date, - ], - ]; - if (!empty($item->_attributes)) { - $elements[$delta]['#attributes'] += $item->_attributes; - // Unset field item attributes since they have been included in the - // formatter output and should not be rendered in the field template. - unset($item->_attributes); - } - } - - return $elements; - - } - - /** - * {@inheritdoc} - */ protected function formatDate($date) { $format_type = $this->getSetting('format_type'); $timezone = $this->getSetting('timezone_override') ?: $date->getTimezone()->getName(); diff --git a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeFormatterBase.php b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeFormatterBase.php index f4e5ff5..71b9467 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeFormatterBase.php +++ b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeFormatterBase.php @@ -6,6 +6,7 @@ use Drupal\Core\Datetime\DrupalDateTime; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\FormatterBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; @@ -118,6 +119,30 @@ public function settingsSummary() { } /** + * {@inheritdoc} + */ + public function viewElements(FieldItemListInterface $items, $langcode) { + $elements = []; + + foreach ($items as $delta => $item) { + if ($item->date) { + /** @var \Drupal\Core\Datetime\DrupalDateTime $date */ + $date = $item->date; + $elements[$delta] = $this->buildDateWithIsoAttribute($date); + + if (!empty($item->_attributes)) { + $elements[$delta]['#attributes'] += $item->_attributes; + // Unset field item attributes since they have been included in the + // formatter output and should not be rendered in the field template. + unset($item->_attributes); + } + } + } + + return $elements; + } + + /** * Creates a formatted date value as a string. * * @param object $date @@ -167,4 +192,69 @@ protected function getFormatSettings() { return $settings; } + /** + * Creates a render array from a date object. + * + * @param \Drupal\Core\Datetime\DrupalDateTime $date + * A date object. + * + * @return array + * A render array. + */ + protected function buildDate(DrupalDateTime $date) { + if ($this->getFieldSetting('datetime_type') == DateTimeItem::DATETIME_TYPE_DATE) { + // A date without time will pick up the current time, use the default. + datetime_date_default_time($date); + } + $this->setTimeZone($date); + + $build = [ + '#markup' => $this->formatDate($date), + '#cache' => [ + 'contexts' => [ + 'timezone', + ], + ], + ]; + + return $build; + } + + /** + * Creates a render array from a date object with ISO date attribute. + * + * @param \Drupal\Core\Datetime\DrupalDateTime $date + * A date object. + * + * @return array + * A render array. + */ + protected function buildDateWithIsoAttribute(DrupalDateTime $date) { + if ($this->getFieldSetting('datetime_type') == DateTimeItem::DATETIME_TYPE_DATE) { + // A date without time will pick up the current time, use the default. + datetime_date_default_time($date); + } + + // Create the ISO date in Universal Time. + $iso_date = $date->format("Y-m-d\TH:i:s") . 'Z'; + + $this->setTimeZone($date); + + $build = [ + '#theme' => 'time', + '#text' => $this->formatDate($date), + '#html' => FALSE, + '#attributes' => [ + 'datetime' => $iso_date, + ], + '#cache' => [ + 'contexts' => [ + 'timezone', + ], + ], + ]; + + return $build; + } + } diff --git a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimePlainFormatter.php b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimePlainFormatter.php index d35d380..7f0dee2 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimePlainFormatter.php +++ b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimePlainFormatter.php @@ -25,27 +25,12 @@ public function viewElements(FieldItemListInterface $items, $langcode) { $elements = []; foreach ($items as $delta => $item) { - $output = ''; if (!empty($item->date)) { /** @var \Drupal\Core\Datetime\DrupalDateTime $date */ $date = $item->date; - if ($this->getFieldSetting('datetime_type') == 'date') { - // A date without time will pick up the current time, use the default. - datetime_date_default_time($date); - } - $this->setTimeZone($date); - - $output = $this->formatDate($date); + $elements[$delta] = $this->buildDate($date); } - $elements[$delta] = [ - '#cache' => [ - 'contexts' => [ - 'timezone', - ], - ], - '#markup' => $output, - ]; } return $elements; diff --git a/core/modules/datetime/src/Tests/DateTestBase.php b/core/modules/datetime/src/Tests/DateTestBase.php index 81e7550..a234ae0 100644 --- a/core/modules/datetime/src/Tests/DateTestBase.php +++ b/core/modules/datetime/src/Tests/DateTestBase.php @@ -2,6 +2,8 @@ namespace Drupal\datetime\Tests; +@trigger_error('\Drupal\datetime\Tests\DateTestBase is deprecated in Drupal 8.4.0 and will be removed before Drupal 9.0.0. Use \Drupal\Tests\BrowserTestBase instead. See https://www.drupal.org/node/2780063.', E_USER_DEPRECATED); + use Drupal\Component\Utility\Unicode; use Drupal\Core\Entity\Entity\EntityFormDisplay; use Drupal\Core\Entity\Entity\EntityViewDisplay; @@ -13,6 +15,9 @@ /** * Provides a base class for testing Datetime field functionality. + * + * @deprecated in Drupal 8.4.0 and will be removed before Drupal 9.0.0. + * Use \Drupal\Tests\BrowserTestBase instead. */ abstract class DateTestBase extends WebTestBase { diff --git a/core/modules/datetime/src/Tests/DateTimeFieldTest.php b/core/modules/datetime/src/Tests/DateTimeFieldTest.php deleted file mode 100644 index cb487d6..0000000 --- a/core/modules/datetime/src/Tests/DateTimeFieldTest.php +++ /dev/null @@ -1,830 +0,0 @@ - '']; - - /** - * {@inheritdoc} - */ - protected function getTestFieldType() { - return 'datetime'; - } - - /** - * Tests date field functionality. - */ - public function testDateField() { - $field_name = $this->fieldStorage->getName(); - - // Loop through defined timezones to test that date-only fields work at the - // extremes. - foreach (static::$timezones as $timezone) { - - $this->setSiteTimezone($timezone); - - // Display creation form. - $this->drupalGet('entity_test/add'); - $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Date element found.'); - $this->assertFieldByXPath('//*[@id="edit-' . $field_name . '-wrapper"]//label[contains(@class,"js-form-required")]', TRUE, 'Required markup found'); - $this->assertNoFieldByName("{$field_name}[0][value][time]", '', 'Time element not found.'); - $this->assertFieldByXPath('//input[@aria-describedby="edit-' . $field_name . '-0-value--description"]', NULL, 'ARIA described-by found'); - $this->assertFieldByXPath('//div[@id="edit-' . $field_name . '-0-value--description"]', NULL, 'ARIA description found'); - - // Build up a date in the UTC timezone. Note that using this will also - // mimic the user in a different timezone simply entering '2012-12-31' via - // the UI. - $value = '2012-12-31 00:00:00'; - $date = new DrupalDateTime($value, DATETIME_STORAGE_TIMEZONE); - - // Submit a valid date and ensure it is accepted. - $date_format = DateFormat::load('html_date')->getPattern(); - $time_format = DateFormat::load('html_time')->getPattern(); - - $edit = [ - "{$field_name}[0][value][date]" => $date->format($date_format), - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); - $this->assertRaw($date->format($date_format)); - $this->assertNoRaw($date->format($time_format)); - - // Verify the date doesn't change if using a timezone that is UTC+12 when - // the entity is edited through the form. - $entity = EntityTest::load($id); - $this->assertEqual('2012-12-31', $entity->{$field_name}->value); - $this->drupalGet('entity_test/manage/' . $id . '/edit'); - $this->drupalPostForm(NULL, [], t('Save')); - $this->drupalGet('entity_test/manage/' . $id . '/edit'); - $this->drupalPostForm(NULL, [], t('Save')); - $this->drupalGet('entity_test/manage/' . $id . '/edit'); - $this->drupalPostForm(NULL, [], t('Save')); - $entity = EntityTest::load($id); - $this->assertEqual('2012-12-31', $entity->{$field_name}->value); - - // Reset display options since these get changed below. - $this->displayOptions = [ - 'type' => 'datetime_default', - 'label' => 'hidden', - 'settings' => ['format_type' => 'medium'] + $this->defaultSettings, - ]; - // Verify that the date is output according to the formatter settings. - $options = [ - 'format_type' => ['short', 'medium', 'long'], - ]; - // Formats that display a time component for date-only fields will display - // the default time, so that is applied before calculating the expected - // value. - datetime_date_default_time($date); - foreach ($options as $setting => $values) { - foreach ($values as $new_value) { - // Update the entity display settings. - $this->displayOptions['settings'] = [$setting => $new_value] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - - $this->renderTestEntity($id); - switch ($setting) { - case 'format_type': - // Verify that a date is displayed. Since this is a date-only - // field, it is expected to display the time as 00:00:00. - $expected = format_date($date->getTimestamp(), $new_value, '', DATETIME_STORAGE_TIMEZONE); - $expected_iso = format_date($date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', DATETIME_STORAGE_TIMEZONE); - $this->renderTestEntity($id); - $this->assertFieldByXPath('//time[@datetime="' . $expected_iso . '"]', $expected, SafeMarkup::format('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => $new_value, '%expected' => $expected, '%expected_iso' => $expected_iso])); - break; - } - } - } - - // Verify that the plain formatter works. - $this->displayOptions['type'] = 'datetime_plain'; - $this->displayOptions['settings'] = $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $date->format(DATETIME_DATE_STORAGE_FORMAT); - $this->renderTestEntity($id); - $this->assertText($expected, SafeMarkup::format('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); - - // Verify that the 'datetime_custom' formatter works. - $this->displayOptions['type'] = 'datetime_custom'; - $this->displayOptions['settings'] = ['date_format' => 'm/d/Y'] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $date->format($this->displayOptions['settings']['date_format']); - $this->renderTestEntity($id); - $this->assertText($expected, SafeMarkup::format('Formatted date field using datetime_custom format displayed as %expected.', ['%expected' => $expected])); - - // Verify that the 'datetime_time_ago' formatter works for intervals in the - // past. First update the test entity so that the date difference always - // has the same interval. Since the database always stores UTC, and the - // interval will use this, force the test date to use UTC and not the local - // or user timezome. - $timestamp = REQUEST_TIME - 87654321; - $entity = EntityTest::load($id); - $field_name = $this->fieldStorage->getName(); - $date = DrupalDateTime::createFromTimestamp($timestamp, 'UTC'); - $entity->{$field_name}->value = $date->format($date_format); - $entity->save(); - - $this->displayOptions['type'] = 'datetime_time_ago'; - $this->displayOptions['settings'] = [ - 'future_format' => '@interval in the future', - 'past_format' => '@interval in the past', - 'granularity' => 3, - ]; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = SafeMarkup::format($this->displayOptions['settings']['past_format'], [ - '@interval' => $this->dateFormatter->formatTimeDiffSince($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]) - ]); - $this->renderTestEntity($id); - $this->assertText($expected, SafeMarkup::format('Formatted date field using datetime_time_ago format displayed as %expected.', ['%expected' => $expected])); - - // Verify that the 'datetime_time_ago' formatter works for intervals in the - // future. First update the test entity so that the date difference always - // has the same interval. Since the database always stores UTC, and the - // interval will use this, force the test date to use UTC and not the local - // or user timezome. - $timestamp = REQUEST_TIME + 87654321; - $entity = EntityTest::load($id); - $field_name = $this->fieldStorage->getName(); - $date = DrupalDateTime::createFromTimestamp($timestamp, 'UTC'); - $entity->{$field_name}->value = $date->format($date_format); - $entity->save(); - - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = SafeMarkup::format($this->displayOptions['settings']['future_format'], [ - '@interval' => $this->dateFormatter->formatTimeDiffUntil($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]) - ]); - $this->renderTestEntity($id); - $this->assertText($expected, SafeMarkup::format('Formatted date field using datetime_time_ago format displayed as %expected.', ['%expected' => $expected])); - } - } - - /** - * Tests date and time field. - */ - public function testDatetimeField() { - $field_name = $this->fieldStorage->getName(); - // Change the field to a datetime field. - $this->fieldStorage->setSetting('datetime_type', 'datetime'); - $this->fieldStorage->save(); - - // Display creation form. - $this->drupalGet('entity_test/add'); - $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Date element found.'); - $this->assertFieldByName("{$field_name}[0][value][time]", '', 'Time element found.'); - $this->assertFieldByXPath('//fieldset[@id="edit-' . $field_name . '-0"]/legend//text()', $field_name, 'Fieldset and label found'); - $this->assertFieldByXPath('//fieldset[@aria-describedby="edit-' . $field_name . '-0--description"]', NULL, 'ARIA described-by found'); - $this->assertFieldByXPath('//div[@id="edit-' . $field_name . '-0--description"]', NULL, 'ARIA description found'); - - // Build up a date in the UTC timezone. - $value = '2012-12-31 00:00:00'; - $date = new DrupalDateTime($value, 'UTC'); - - // Update the timezone to the system default. - $date->setTimezone(timezone_open(drupal_get_user_timezone())); - - // Submit a valid date and ensure it is accepted. - $date_format = DateFormat::load('html_date')->getPattern(); - $time_format = DateFormat::load('html_time')->getPattern(); - - $edit = [ - "{$field_name}[0][value][date]" => $date->format($date_format), - "{$field_name}[0][value][time]" => $date->format($time_format), - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); - $this->assertRaw($date->format($date_format)); - $this->assertRaw($date->format($time_format)); - - // Verify that the date is output according to the formatter settings. - $options = [ - 'format_type' => ['short', 'medium', 'long'], - ]; - foreach ($options as $setting => $values) { - foreach ($values as $new_value) { - // Update the entity display settings. - $this->displayOptions['settings'] = [$setting => $new_value] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - - $this->renderTestEntity($id); - switch ($setting) { - case 'format_type': - // Verify that a date is displayed. - $expected = format_date($date->getTimestamp(), $new_value); - $expected_iso = format_date($date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); - $this->renderTestEntity($id); - $this->assertFieldByXPath('//time[@datetime="' . $expected_iso . '"]', $expected, SafeMarkup::format('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => $new_value, '%expected' => $expected, '%expected_iso' => $expected_iso])); - break; - } - } - } - - // Verify that the plain formatter works. - $this->displayOptions['type'] = 'datetime_plain'; - $this->displayOptions['settings'] = $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $date->format(DATETIME_DATETIME_STORAGE_FORMAT); - $this->renderTestEntity($id); - $this->assertText($expected, SafeMarkup::format('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); - - // Verify that the 'datetime_custom' formatter works. - $this->displayOptions['type'] = 'datetime_custom'; - $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A'] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $date->format($this->displayOptions['settings']['date_format']); - $this->renderTestEntity($id); - $this->assertText($expected, SafeMarkup::format('Formatted date field using datetime_custom format displayed as %expected.', ['%expected' => $expected])); - - // Verify that the 'timezone_override' setting works. - $this->displayOptions['type'] = 'datetime_custom'; - $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A', 'timezone_override' => 'America/New_York'] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $date->format($this->displayOptions['settings']['date_format'], ['timezone' => 'America/New_York']); - $this->renderTestEntity($id); - $this->assertText($expected, SafeMarkup::format('Formatted date field using datetime_custom format displayed as %expected.', ['%expected' => $expected])); - - // Verify that the 'datetime_time_ago' formatter works for intervals in the - // past. First update the test entity so that the date difference always - // has the same interval. Since the database always stores UTC, and the - // interval will use this, force the test date to use UTC and not the local - // or user timezome. - $timestamp = REQUEST_TIME - 87654321; - $entity = EntityTest::load($id); - $field_name = $this->fieldStorage->getName(); - $date = DrupalDateTime::createFromTimestamp($timestamp, 'UTC'); - $entity->{$field_name}->value = $date->format(DATETIME_DATETIME_STORAGE_FORMAT); - $entity->save(); - - $this->displayOptions['type'] = 'datetime_time_ago'; - $this->displayOptions['settings'] = [ - 'future_format' => '@interval from now', - 'past_format' => '@interval earlier', - 'granularity' => 3, - ]; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = SafeMarkup::format($this->displayOptions['settings']['past_format'], [ - '@interval' => $this->dateFormatter->formatTimeDiffSince($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]) - ]); - $this->renderTestEntity($id); - $this->assertText($expected, SafeMarkup::format('Formatted date field using datetime_time_ago format displayed as %expected.', ['%expected' => $expected])); - - // Verify that the 'datetime_time_ago' formatter works for intervals in the - // future. First update the test entity so that the date difference always - // has the same interval. Since the database always stores UTC, and the - // interval will use this, force the test date to use UTC and not the local - // or user timezome. - $timestamp = REQUEST_TIME + 87654321; - $entity = EntityTest::load($id); - $field_name = $this->fieldStorage->getName(); - $date = DrupalDateTime::createFromTimestamp($timestamp, 'UTC'); - $entity->{$field_name}->value = $date->format(DATETIME_DATETIME_STORAGE_FORMAT); - $entity->save(); - - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = SafeMarkup::format($this->displayOptions['settings']['future_format'], [ - '@interval' => $this->dateFormatter->formatTimeDiffUntil($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]) - ]); - $this->renderTestEntity($id); - $this->assertText($expected, SafeMarkup::format('Formatted date field using datetime_time_ago format displayed as %expected.', ['%expected' => $expected])); - } - - /** - * Tests Date List Widget functionality. - */ - public function testDatelistWidget() { - $field_name = $this->fieldStorage->getName(); - - // Ensure field is set to a date only field. - $this->fieldStorage->setSetting('datetime_type', 'date'); - $this->fieldStorage->save(); - - // Change the widget to a datelist widget. - entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') - ->setComponent($field_name, [ - 'type' => 'datetime_datelist', - 'settings' => [ - 'date_order' => 'YMD', - ], - ]) - ->save(); - \Drupal::entityManager()->clearCachedFieldDefinitions(); - - // Display creation form. - $this->drupalGet('entity_test/add'); - $this->assertFieldByXPath('//fieldset[@id="edit-' . $field_name . '-0"]/legend//text()', $field_name, 'Fieldset and label found'); - $this->assertFieldByXPath('//fieldset[@aria-describedby="edit-' . $field_name . '-0--description"]', NULL, 'ARIA described-by found'); - $this->assertFieldByXPath('//div[@id="edit-' . $field_name . '-0--description"]', NULL, 'ARIA description found'); - - // Assert that Hour and Minute Elements do not appear on Date Only - $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-hour\"]", NULL, 'Hour element not found on Date Only.'); - $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-minute\"]", NULL, 'Minute element not found on Date Only.'); - - // Go to the form display page to assert that increment option does not appear on Date Only - $fieldEditUrl = 'entity_test/structure/entity_test/form-display'; - $this->drupalGet($fieldEditUrl); - - // Click on the widget settings button to open the widget settings form. - $this->drupalPostAjaxForm(NULL, [], $field_name . "_settings_edit"); - $xpathIncr = "//select[starts-with(@id, \"edit-fields-$field_name-settings-edit-form-settings-increment\")]"; - $this->assertNoFieldByXPath($xpathIncr, NULL, 'Increment element not found for Date Only.'); - - // Change the field to a datetime field. - $this->fieldStorage->setSetting('datetime_type', 'datetime'); - $this->fieldStorage->save(); - - // Change the widget to a datelist widget. - entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') - ->setComponent($field_name, [ - 'type' => 'datetime_datelist', - 'settings' => [ - 'increment' => 1, - 'date_order' => 'YMD', - 'time_type' => '12', - ], - ]) - ->save(); - \Drupal::entityManager()->clearCachedFieldDefinitions(); - - // Go to the form display page to assert that increment option does appear on Date Time - $fieldEditUrl = 'entity_test/structure/entity_test/form-display'; - $this->drupalGet($fieldEditUrl); - - // Click on the widget settings button to open the widget settings form. - $this->drupalPostAjaxForm(NULL, [], $field_name . "_settings_edit"); - $this->assertFieldByXPath($xpathIncr, NULL, 'Increment element found for Date and time.'); - - // Display creation form. - $this->drupalGet('entity_test/add'); - - $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-year\"]", NULL, 'Year element found.'); - $this->assertOptionSelected("edit-$field_name-0-value-year", '', 'No year selected.'); - $this->assertOptionByText("edit-$field_name-0-value-year", t('Year')); - $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-month\"]", NULL, 'Month element found.'); - $this->assertOptionSelected("edit-$field_name-0-value-month", '', 'No month selected.'); - $this->assertOptionByText("edit-$field_name-0-value-month", t('Month')); - $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-day\"]", NULL, 'Day element found.'); - $this->assertOptionSelected("edit-$field_name-0-value-day", '', 'No day selected.'); - $this->assertOptionByText("edit-$field_name-0-value-day", t('Day')); - $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-hour\"]", NULL, 'Hour element found.'); - $this->assertOptionSelected("edit-$field_name-0-value-hour", '', 'No hour selected.'); - $this->assertOptionByText("edit-$field_name-0-value-hour", t('Hour')); - $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-minute\"]", NULL, 'Minute element found.'); - $this->assertOptionSelected("edit-$field_name-0-value-minute", '', 'No minute selected.'); - $this->assertOptionByText("edit-$field_name-0-value-minute", t('Minute')); - $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-second\"]", NULL, 'Second element not found.'); - $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-ampm\"]", NULL, 'AMPM element found.'); - $this->assertOptionSelected("edit-$field_name-0-value-ampm", '', 'No ampm selected.'); - $this->assertOptionByText("edit-$field_name-0-value-ampm", t('AM/PM')); - - // Submit a valid date and ensure it is accepted. - $date_value = ['year' => 2012, 'month' => 12, 'day' => 31, 'hour' => 5, 'minute' => 15]; - - $edit = []; - // Add the ampm indicator since we are testing 12 hour time. - $date_value['ampm'] = 'am'; - foreach ($date_value as $part => $value) { - $edit["{$field_name}[0][value][$part]"] = $value; - } - - $this->drupalPostForm(NULL, $edit, t('Save')); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); - - $this->assertOptionSelected("edit-$field_name-0-value-year", '2012', 'Correct year selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-month", '12', 'Correct month selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-day", '31', 'Correct day selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-hour", '5', 'Correct hour selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-minute", '15', 'Correct minute selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-ampm", 'am', 'Correct ampm selected.'); - - // Test the widget using increment other than 1 and 24 hour mode. - entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') - ->setComponent($field_name, [ - 'type' => 'datetime_datelist', - 'settings' => [ - 'increment' => 15, - 'date_order' => 'YMD', - 'time_type' => '24', - ], - ]) - ->save(); - \Drupal::entityManager()->clearCachedFieldDefinitions(); - - // Display creation form. - $this->drupalGet('entity_test/add'); - - // Other elements are unaffected by the changed settings. - $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-hour\"]", NULL, 'Hour element found.'); - $this->assertOptionSelected("edit-$field_name-0-value-hour", '', 'No hour selected.'); - $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-ampm\"]", NULL, 'AMPM element not found.'); - - // Submit a valid date and ensure it is accepted. - $date_value = ['year' => 2012, 'month' => 12, 'day' => 31, 'hour' => 17, 'minute' => 15]; - - $edit = []; - foreach ($date_value as $part => $value) { - $edit["{$field_name}[0][value][$part]"] = $value; - } - - $this->drupalPostForm(NULL, $edit, t('Save')); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); - - $this->assertOptionSelected("edit-$field_name-0-value-year", '2012', 'Correct year selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-month", '12', 'Correct month selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-day", '31', 'Correct day selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-hour", '17', 'Correct hour selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-minute", '15', 'Correct minute selected.'); - - // Test the widget for partial completion of fields. - entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') - ->setComponent($field_name, [ - 'type' => 'datetime_datelist', - 'settings' => [ - 'increment' => 1, - 'date_order' => 'YMD', - 'time_type' => '24', - ], - ]) - ->save(); - \Drupal::entityManager()->clearCachedFieldDefinitions(); - - // Test the widget for validation notifications. - foreach ($this->datelistDataProvider() as $data) { - list($date_value, $expected) = $data; - - // Display creation form. - $this->drupalGet('entity_test/add'); - - // Submit a partial date and ensure and error message is provided. - $edit = []; - foreach ($date_value as $part => $value) { - $edit["{$field_name}[0][value][$part]"] = $value; - } - - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertResponse(200); - foreach ($expected as $expected_text) { - $this->assertText(t($expected_text)); - } - } - - // Test the widget for complete input with zeros as part of selections. - $this->drupalGet('entity_test/add'); - - $date_value = ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '0', 'minute' => '0']; - $edit = []; - foreach ($date_value as $part => $value) { - $edit["{$field_name}[0][value][$part]"] = $value; - } - - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertResponse(200); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); - - // Test the widget to ensure zeros are not deselected on validation. - $this->drupalGet('entity_test/add'); - - $date_value = ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '', 'minute' => '0']; - $edit = []; - foreach ($date_value as $part => $value) { - $edit["{$field_name}[0][value][$part]"] = $value; - } - - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertResponse(200); - $this->assertOptionSelected("edit-$field_name-0-value-minute", '0', 'Correct minute selected.'); - } - - /** - * The data provider for testing the validation of the datelist widget. - * - * @return array - * An array of datelist input permutations to test. - */ - protected function datelistDataProvider() { - return [ - // Year only selected, validation error on Month, Day, Hour, Minute. - [['year' => 2012, 'month' => '', 'day' => '', 'hour' => '', 'minute' => ''], [ - 'A value must be selected for month.', - 'A value must be selected for day.', - 'A value must be selected for hour.', - 'A value must be selected for minute.', - ]], - // Year and Month selected, validation error on Day, Hour, Minute. - [['year' => 2012, 'month' => '12', 'day' => '', 'hour' => '', 'minute' => ''], [ - 'A value must be selected for day.', - 'A value must be selected for hour.', - 'A value must be selected for minute.', - ]], - // Year, Month and Day selected, validation error on Hour, Minute. - [['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '', 'minute' => ''], [ - 'A value must be selected for hour.', - 'A value must be selected for minute.', - ]], - // Year, Month, Day and Hour selected, validation error on Minute only. - [['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '0', 'minute' => ''], [ - 'A value must be selected for minute.', - ]], - ]; - } - - /** - * Test default value functionality. - */ - public function testDefaultValue() { - // Create a test content type. - $this->drupalCreateContentType(['type' => 'date_content']); - - // Create a field storage with settings to validate. - $field_name = Unicode::strtolower($this->randomMachineName()); - $field_storage = FieldStorageConfig::create([ - 'field_name' => $field_name, - 'entity_type' => 'node', - 'type' => 'datetime', - 'settings' => ['datetime_type' => 'date'], - ]); - $field_storage->save(); - - $field = FieldConfig::create([ - 'field_storage' => $field_storage, - 'bundle' => 'date_content', - ]); - $field->save(); - - // Loop through defined timezones to test that date-only defaults work at - // the extremes. - foreach (static::$timezones as $timezone) { - - $this->setSiteTimezone($timezone); - - // Set now as default_value. - $field_edit = [ - 'default_value_input[default_date_type]' => 'now', - ]; - $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); - - // Check that default value is selected in default value form. - $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name); - $this->assertOptionSelected('edit-default-value-input-default-date-type', 'now', 'The default value is selected in instance settings page'); - $this->assertFieldByName('default_value_input[default_date]', '', 'The relative default value is empty in instance settings page'); - - // Check if default_date has been stored successfully. - $config_entity = $this->config('field.field.node.date_content.' . $field_name) - ->get(); - $this->assertEqual($config_entity['default_value'][0], [ - 'default_date_type' => 'now', - 'default_date' => 'now', - ], 'Default value has been stored successfully'); - - // Clear field cache in order to avoid stale cache values. - \Drupal::entityManager()->clearCachedFieldDefinitions(); - - // Create a new node to check that datetime field default value is today. - $new_node = Node::create(['type' => 'date_content']); - $expected_date = new DrupalDateTime('now', drupal_get_user_timezone()); - $this->assertEqual($new_node->get($field_name) - ->offsetGet(0)->value, $expected_date->format(DATETIME_DATE_STORAGE_FORMAT)); - - // Set an invalid relative default_value to test validation. - $field_edit = [ - 'default_value_input[default_date_type]' => 'relative', - 'default_value_input[default_date]' => 'invalid date', - ]; - $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); - - $this->assertText('The relative date value entered is invalid.'); - - // Set a relative default_value. - $field_edit = [ - 'default_value_input[default_date_type]' => 'relative', - 'default_value_input[default_date]' => '+90 days', - ]; - $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); - - // Check that default value is selected in default value form. - $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name); - $this->assertOptionSelected('edit-default-value-input-default-date-type', 'relative', 'The default value is selected in instance settings page'); - $this->assertFieldByName('default_value_input[default_date]', '+90 days', 'The relative default value is displayed in instance settings page'); - - // Check if default_date has been stored successfully. - $config_entity = $this->config('field.field.node.date_content.' . $field_name) - ->get(); - $this->assertEqual($config_entity['default_value'][0], [ - 'default_date_type' => 'relative', - 'default_date' => '+90 days', - ], 'Default value has been stored successfully'); - - // Clear field cache in order to avoid stale cache values. - \Drupal::entityManager()->clearCachedFieldDefinitions(); - - // Create a new node to check that datetime field default value is +90 - // days. - $new_node = Node::create(['type' => 'date_content']); - $expected_date = new DrupalDateTime('+90 days', drupal_get_user_timezone()); - $this->assertEqual($new_node->get($field_name) - ->offsetGet(0)->value, $expected_date->format(DATETIME_DATE_STORAGE_FORMAT)); - - // Remove default value. - $field_edit = [ - 'default_value_input[default_date_type]' => '', - ]; - $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); - - // Check that default value is selected in default value form. - $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name); - $this->assertOptionSelected('edit-default-value-input-default-date-type', '', 'The default value is selected in instance settings page'); - $this->assertFieldByName('default_value_input[default_date]', '', 'The relative default value is empty in instance settings page'); - - // Check if default_date has been stored successfully. - $config_entity = $this->config('field.field.node.date_content.' . $field_name) - ->get(); - $this->assertTrue(empty($config_entity['default_value']), 'Empty default value has been stored successfully'); - - // Clear field cache in order to avoid stale cache values. - \Drupal::entityManager()->clearCachedFieldDefinitions(); - - // Create a new node to check that datetime field default value is not - // set. - $new_node = Node::create(['type' => 'date_content']); - $this->assertNull($new_node->get($field_name)->value, 'Default value is not set'); - } - } - - /** - * Test that invalid values are caught and marked as invalid. - */ - public function testInvalidField() { - // Change the field to a datetime field. - $this->fieldStorage->setSetting('datetime_type', 'datetime'); - $this->fieldStorage->save(); - $field_name = $this->fieldStorage->getName(); - - // Display creation form. - $this->drupalGet('entity_test/add'); - $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Date element found.'); - $this->assertFieldByName("{$field_name}[0][value][time]", '', 'Time element found.'); - - // Submit invalid dates and ensure they is not accepted. - $date_value = ''; - $edit = [ - "{$field_name}[0][value][date]" => $date_value, - "{$field_name}[0][value][time]" => '12:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', 'Empty date value has been caught.'); - - $date_value = 'aaaa-12-01'; - $edit = [ - "{$field_name}[0][value][date]" => $date_value, - "{$field_name}[0][value][time]" => '00:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', format_string('Invalid year value %date has been caught.', ['%date' => $date_value])); - - $date_value = '2012-75-01'; - $edit = [ - "{$field_name}[0][value][date]" => $date_value, - "{$field_name}[0][value][time]" => '00:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', format_string('Invalid month value %date has been caught.', ['%date' => $date_value])); - - $date_value = '2012-12-99'; - $edit = [ - "{$field_name}[0][value][date]" => $date_value, - "{$field_name}[0][value][time]" => '00:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', format_string('Invalid day value %date has been caught.', ['%date' => $date_value])); - - $date_value = '2012-12-01'; - $time_value = ''; - $edit = [ - "{$field_name}[0][value][date]" => $date_value, - "{$field_name}[0][value][time]" => $time_value, - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', 'Empty time value has been caught.'); - - $date_value = '2012-12-01'; - $time_value = '49:00:00'; - $edit = [ - "{$field_name}[0][value][date]" => $date_value, - "{$field_name}[0][value][time]" => $time_value, - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', format_string('Invalid hour value %time has been caught.', ['%time' => $time_value])); - - $date_value = '2012-12-01'; - $time_value = '12:99:00'; - $edit = [ - "{$field_name}[0][value][date]" => $date_value, - "{$field_name}[0][value][time]" => $time_value, - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', format_string('Invalid minute value %time has been caught.', ['%time' => $time_value])); - - $date_value = '2012-12-01'; - $time_value = '12:15:99'; - $edit = [ - "{$field_name}[0][value][date]" => $date_value, - "{$field_name}[0][value][time]" => $time_value, - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', format_string('Invalid second value %time has been caught.', ['%time' => $time_value])); - } - - /** - * Tests that 'Date' field storage setting form is disabled if field has data. - */ - public function testDateStorageSettings() { - // Create a test content type. - $this->drupalCreateContentType(['type' => 'date_content']); - - // Create a field storage with settings to validate. - $field_name = Unicode::strtolower($this->randomMachineName()); - $field_storage = FieldStorageConfig::create([ - 'field_name' => $field_name, - 'entity_type' => 'node', - 'type' => 'datetime', - 'settings' => [ - 'datetime_type' => 'date', - ], - ]); - $field_storage->save(); - $field = FieldConfig::create([ - 'field_storage' => $field_storage, - 'field_name' => $field_name, - 'bundle' => 'date_content', - ]); - $field->save(); - - entity_get_form_display('node', 'date_content', 'default') - ->setComponent($field_name, [ - 'type' => 'datetime_default', - ]) - ->save(); - $edit = [ - 'title[0][value]' => $this->randomString(), - 'body[0][value]' => $this->randomString(), - $field_name . '[0][value][date]' => '2016-04-01', - ]; - $this->drupalPostForm('node/add/date_content', $edit, t('Save')); - $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name . '/storage'); - $result = $this->xpath("//*[@id='edit-settings-datetime-type' and contains(@disabled, 'disabled')]"); - $this->assertEqual(count($result), 1, "Changing datetime setting is disabled."); - $this->assertText('There is data for this field in the database. The field settings can no longer be changed.'); - } - -} diff --git a/core/modules/datetime/tests/src/Functional/DateTestBase.php b/core/modules/datetime/tests/src/Functional/DateTestBase.php new file mode 100644 index 0000000..369e793 --- /dev/null +++ b/core/modules/datetime/tests/src/Functional/DateTestBase.php @@ -0,0 +1,184 @@ +drupalCreateUser([ + 'access content', + 'view test entity', + 'administer entity_test content', + 'administer entity_test form display', + 'administer content types', + 'administer node fields', + ]); + $this->drupalLogin($web_user); + + // Create a field with settings to validate. + $this->createField(); + + $this->dateFormatter = $this->container->get('date.formatter'); + } + + /** + * Creates a date test field. + */ + protected function createField() { + $field_name = Unicode::strtolower($this->randomMachineName()); + $type = $this->getTestFieldType(); + $widget_type = $formatter_type = $type . '_default'; + + $this->fieldStorage = FieldStorageConfig::create([ + 'field_name' => $field_name, + 'entity_type' => 'entity_test', + 'type' => $type, + 'settings' => ['datetime_type' => DateTimeItem::DATETIME_TYPE_DATE], + ]); + $this->fieldStorage->save(); + $this->field = FieldConfig::create([ + 'field_storage' => $this->fieldStorage, + 'bundle' => 'entity_test', + 'description' => 'Description for ' . $field_name, + 'required' => TRUE, + ]); + $this->field->save(); + + EntityFormDisplay::load('entity_test.entity_test.default') + ->setComponent($field_name, ['type' => $widget_type]) + ->save(); + + $this->displayOptions = [ + 'type' => $formatter_type, + 'label' => 'hidden', + 'settings' => ['format_type' => 'medium'] + $this->defaultSettings, + ]; + EntityViewDisplay::create([ + 'targetEntityType' => $this->field->getTargetEntityTypeId(), + 'bundle' => $this->field->getTargetBundle(), + 'mode' => 'full', + 'status' => TRUE, + ])->setComponent($field_name, $this->displayOptions) + ->save(); + } + + /** + * Renders a entity_test and sets the output in the internal browser. + * + * @param int $id + * The entity_test ID to render. + * @param string $view_mode + * (optional) The view mode to use for rendering. Defaults to 'full'. + * @param bool $reset + * (optional) Whether to reset the entity_test controller cache. Defaults to + * TRUE to simplify testing. + * + * @return string + * The rendered HTML output. + */ + protected function renderTestEntity($id, $view_mode = 'full', $reset = TRUE) { + if ($reset) { + $this->container->get('entity_type.manager')->getStorage('entity_test')->resetCache([$id]); + } + $entity = EntityTest::load($id); + $display = EntityViewDisplay::collectRenderDisplay($entity, $view_mode); + $build = $display->build($entity); + return (string) $this->container->get('renderer')->renderRoot($build); + } + + /** + * Sets the site timezone to a given timezone. + * + * @param string $timezone + * The timezone identifier to set. + */ + protected function setSiteTimezone($timezone) { + // Set an explicit site timezone, and disallow per-user timezones. + $this->config('system.date') + ->set('timezone.user.configurable', 0) + ->set('timezone.default', $timezone) + ->save(); + } + +} diff --git a/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php b/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php new file mode 100644 index 0000000..ab03ff5 --- /dev/null +++ b/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php @@ -0,0 +1,843 @@ + '']; + + /** + * {@inheritdoc} + */ + protected function getTestFieldType() { + return 'datetime'; + } + + /** + * Tests date field functionality. + */ + public function testDateField() { + $field_name = $this->fieldStorage->getName(); + + // Loop through defined timezones to test that date-only fields work at the + // extremes. + foreach (static::$timezones as $timezone) { + + $this->setSiteTimezone($timezone); + + // Display creation form. + $this->drupalGet('entity_test/add'); + $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Date element found.'); + $this->assertFieldByXPath('//*[@id="edit-' . $field_name . '-wrapper"]//label[contains(@class,"js-form-required")]', TRUE, 'Required markup found'); + $this->assertNoFieldByName("{$field_name}[0][value][time]", '', 'Time element not found.'); + $this->assertFieldByXPath('//input[@aria-describedby="edit-' . $field_name . '-0-value--description"]', NULL, 'ARIA described-by found'); + $this->assertFieldByXPath('//div[@id="edit-' . $field_name . '-0-value--description"]', NULL, 'ARIA description found'); + + // Build up a date in the UTC timezone. Note that using this will also + // mimic the user in a different timezone simply entering '2012-12-31' via + // the UI. + $value = '2012-12-31 00:00:00'; + $date = new DrupalDateTime($value, DATETIME_STORAGE_TIMEZONE); + + // Submit a valid date and ensure it is accepted. + $date_format = DateFormat::load('html_date')->getPattern(); + $time_format = DateFormat::load('html_time')->getPattern(); + + $edit = [ + "{$field_name}[0][value][date]" => $date->format($date_format), + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + $this->assertRaw($date->format($date_format)); + $this->assertNoRaw($date->format($time_format)); + + // Verify the date doesn't change if using a timezone that is UTC+12 when + // the entity is edited through the form. + $entity = EntityTest::load($id); + $this->assertEqual('2012-12-31', $entity->{$field_name}->value); + $this->drupalGet('entity_test/manage/' . $id . '/edit'); + $this->drupalPostForm(NULL, [], t('Save')); + $this->drupalGet('entity_test/manage/' . $id . '/edit'); + $this->drupalPostForm(NULL, [], t('Save')); + $this->drupalGet('entity_test/manage/' . $id . '/edit'); + $this->drupalPostForm(NULL, [], t('Save')); + $entity = EntityTest::load($id); + $this->assertEqual('2012-12-31', $entity->{$field_name}->value); + + // Reset display options since these get changed below. + $this->displayOptions = [ + 'type' => 'datetime_default', + 'label' => 'hidden', + 'settings' => ['format_type' => 'medium'] + $this->defaultSettings, + ]; + // Verify that the date is output according to the formatter settings. + $options = [ + 'format_type' => ['short', 'medium', 'long'], + ]; + // Formats that display a time component for date-only fields will display + // the default time, so that is applied before calculating the expected + // value. + datetime_date_default_time($date); + foreach ($options as $setting => $values) { + foreach ($values as $new_value) { + // Update the entity display settings. + $this->displayOptions['settings'] = [$setting => $new_value] + $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + + $this->renderTestEntity($id); + switch ($setting) { + case 'format_type': + // Verify that a date is displayed. Since this is a date-only + // field, it is expected to display the time as 00:00:00. + $expected = format_date($date->getTimestamp(), $new_value, '', DATETIME_STORAGE_TIMEZONE); + $expected_iso = format_date($date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', DATETIME_STORAGE_TIMEZONE); + $output = $this->renderTestEntity($id); + $expected_markup = ''; + $this->assertContains($expected_markup, $output, SafeMarkup::format('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => $new_value, '%expected' => $expected, '%expected_iso' => $expected_iso])); + break; + } + } + } + + // Verify that the plain formatter works. + $this->displayOptions['type'] = 'datetime_plain'; + $this->displayOptions['settings'] = $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $date->format(DATETIME_DATE_STORAGE_FORMAT); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, SafeMarkup::format('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); + + // Verify that the 'datetime_custom' formatter works. + $this->displayOptions['type'] = 'datetime_custom'; + $this->displayOptions['settings'] = ['date_format' => 'm/d/Y'] + $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $date->format($this->displayOptions['settings']['date_format']); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, SafeMarkup::format('Formatted date field using datetime_custom format displayed as %expected.', ['%expected' => $expected])); + + // Test that allowed markup in custom format is preserved and XSS is + // removed. + $this->displayOptions['settings']['date_format'] = '\\<\\s\\t\\r\\o\\n\\g\\>m/d/Y\\<\\/\\s\\t\\r\\o\\n\\g\\>\\<\\s\\c\\r\\i\\p\\t\\>\\a\\l\\e\\r\\t\\(\\S\\t\\r\\i\\n\\g\\.\\f\\r\\o\\m\\C\\h\\a\\r\\C\\o\\d\\e\\(\\8\\8\\,\\8\\3\\,\\8\\3\\)\\)\\<\\/\\s\\c\\r\\i\\p\\t\\>'; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = '' . $date->format('m/d/Y') . 'alert(String.fromCharCode(88,83,83))'; + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); + + // Verify that the 'datetime_time_ago' formatter works for intervals in the + // past. First update the test entity so that the date difference always + // has the same interval. Since the database always stores UTC, and the + // interval will use this, force the test date to use UTC and not the local + // or user timezome. + $timestamp = REQUEST_TIME - 87654321; + $entity = EntityTest::load($id); + $field_name = $this->fieldStorage->getName(); + $date = DrupalDateTime::createFromTimestamp($timestamp, 'UTC'); + $entity->{$field_name}->value = $date->format($date_format); + $entity->save(); + + $this->displayOptions['type'] = 'datetime_time_ago'; + $this->displayOptions['settings'] = [ + 'future_format' => '@interval in the future', + 'past_format' => '@interval in the past', + 'granularity' => 3, + ]; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = SafeMarkup::format($this->displayOptions['settings']['past_format'], [ + '@interval' => $this->dateFormatter->formatTimeDiffSince($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]) + ]); + $output = $this->renderTestEntity($id); + $this->assertContains((string) $expected, $output, SafeMarkup::format('Formatted date field using datetime_time_ago format displayed as %expected.', ['%expected' => $expected])); + + // Verify that the 'datetime_time_ago' formatter works for intervals in the + // future. First update the test entity so that the date difference always + // has the same interval. Since the database always stores UTC, and the + // interval will use this, force the test date to use UTC and not the local + // or user timezome. + $timestamp = REQUEST_TIME + 87654321; + $entity = EntityTest::load($id); + $field_name = $this->fieldStorage->getName(); + $date = DrupalDateTime::createFromTimestamp($timestamp, 'UTC'); + $entity->{$field_name}->value = $date->format($date_format); + $entity->save(); + + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = SafeMarkup::format($this->displayOptions['settings']['future_format'], [ + '@interval' => $this->dateFormatter->formatTimeDiffUntil($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]) + ]); + $output = $this->renderTestEntity($id); + $this->assertContains((string) $expected, $output, SafeMarkup::format('Formatted date field using datetime_time_ago format displayed as %expected.', ['%expected' => $expected])); + } + } + + /** + * Tests date and time field. + */ + public function testDatetimeField() { + $field_name = $this->fieldStorage->getName(); + // Change the field to a datetime field. + $this->fieldStorage->setSetting('datetime_type', 'datetime'); + $this->fieldStorage->save(); + + // Display creation form. + $this->drupalGet('entity_test/add'); + $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Date element found.'); + $this->assertFieldByName("{$field_name}[0][value][time]", '', 'Time element found.'); + $this->assertFieldByXPath('//fieldset[@id="edit-' . $field_name . '-0"]/legend', $field_name, 'Fieldset and label found'); + $this->assertFieldByXPath('//fieldset[@aria-describedby="edit-' . $field_name . '-0--description"]', NULL, 'ARIA described-by found'); + $this->assertFieldByXPath('//div[@id="edit-' . $field_name . '-0--description"]', NULL, 'ARIA description found'); + + // Build up a date in the UTC timezone. + $value = '2012-12-31 00:00:00'; + $date = new DrupalDateTime($value, 'UTC'); + + // Update the timezone to the system default. + $date->setTimezone(timezone_open(drupal_get_user_timezone())); + + // Submit a valid date and ensure it is accepted. + $date_format = DateFormat::load('html_date')->getPattern(); + $time_format = DateFormat::load('html_time')->getPattern(); + + $edit = [ + "{$field_name}[0][value][date]" => $date->format($date_format), + "{$field_name}[0][value][time]" => $date->format($time_format), + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + $this->assertRaw($date->format($date_format)); + $this->assertRaw($date->format($time_format)); + + // Verify that the date is output according to the formatter settings. + $options = [ + 'format_type' => ['short', 'medium', 'long'], + ]; + foreach ($options as $setting => $values) { + foreach ($values as $new_value) { + // Update the entity display settings. + $this->displayOptions['settings'] = [$setting => $new_value] + $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + + $this->renderTestEntity($id); + switch ($setting) { + case 'format_type': + // Verify that a date is displayed. + $expected = format_date($date->getTimestamp(), $new_value); + $expected_iso = format_date($date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); + $output = $this->renderTestEntity($id); + $expected_markup = ''; + $this->assertContains($expected_markup, $output, SafeMarkup::format('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => $new_value, '%expected' => $expected, '%expected_iso' => $expected_iso])); + break; + } + } + } + + // Verify that the plain formatter works. + $this->displayOptions['type'] = 'datetime_plain'; + $this->displayOptions['settings'] = $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $date->format(DATETIME_DATETIME_STORAGE_FORMAT); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, SafeMarkup::format('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); + + // Verify that the 'datetime_custom' formatter works. + $this->displayOptions['type'] = 'datetime_custom'; + $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A'] + $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $date->format($this->displayOptions['settings']['date_format']); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, SafeMarkup::format('Formatted date field using datetime_custom format displayed as %expected.', ['%expected' => $expected])); + + // Verify that the 'timezone_override' setting works. + $this->displayOptions['type'] = 'datetime_custom'; + $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A', 'timezone_override' => 'America/New_York'] + $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $date->format($this->displayOptions['settings']['date_format'], ['timezone' => 'America/New_York']); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, SafeMarkup::format('Formatted date field using datetime_custom format displayed as %expected.', ['%expected' => $expected])); + + // Verify that the 'datetime_time_ago' formatter works for intervals in the + // past. First update the test entity so that the date difference always + // has the same interval. Since the database always stores UTC, and the + // interval will use this, force the test date to use UTC and not the local + // or user timezome. + $timestamp = REQUEST_TIME - 87654321; + $entity = EntityTest::load($id); + $field_name = $this->fieldStorage->getName(); + $date = DrupalDateTime::createFromTimestamp($timestamp, 'UTC'); + $entity->{$field_name}->value = $date->format(DATETIME_DATETIME_STORAGE_FORMAT); + $entity->save(); + + $this->displayOptions['type'] = 'datetime_time_ago'; + $this->displayOptions['settings'] = [ + 'future_format' => '@interval from now', + 'past_format' => '@interval earlier', + 'granularity' => 3, + ]; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = SafeMarkup::format($this->displayOptions['settings']['past_format'], [ + '@interval' => $this->dateFormatter->formatTimeDiffSince($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]) + ]); + $output = $this->renderTestEntity($id); + $this->assertContains((string) $expected, $output, SafeMarkup::format('Formatted date field using datetime_time_ago format displayed as %expected.', ['%expected' => $expected])); + + // Verify that the 'datetime_time_ago' formatter works for intervals in the + // future. First update the test entity so that the date difference always + // has the same interval. Since the database always stores UTC, and the + // interval will use this, force the test date to use UTC and not the local + // or user timezome. + $timestamp = REQUEST_TIME + 87654321; + $entity = EntityTest::load($id); + $field_name = $this->fieldStorage->getName(); + $date = DrupalDateTime::createFromTimestamp($timestamp, 'UTC'); + $entity->{$field_name}->value = $date->format(DATETIME_DATETIME_STORAGE_FORMAT); + $entity->save(); + + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = SafeMarkup::format($this->displayOptions['settings']['future_format'], [ + '@interval' => $this->dateFormatter->formatTimeDiffUntil($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]) + ]); + $output = $this->renderTestEntity($id); + $this->assertContains((string) $expected, $output, SafeMarkup::format('Formatted date field using datetime_time_ago format displayed as %expected.', ['%expected' => $expected])); + } + + /** + * Tests Date List Widget functionality. + */ + public function testDatelistWidget() { + $field_name = $this->fieldStorage->getName(); + + // Ensure field is set to a date only field. + $this->fieldStorage->setSetting('datetime_type', 'date'); + $this->fieldStorage->save(); + + // Change the widget to a datelist widget. + entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') + ->setComponent($field_name, [ + 'type' => 'datetime_datelist', + 'settings' => [ + 'date_order' => 'YMD', + ], + ]) + ->save(); + \Drupal::entityManager()->clearCachedFieldDefinitions(); + + // Display creation form. + $this->drupalGet('entity_test/add'); + $this->assertFieldByXPath('//fieldset[@id="edit-' . $field_name . '-0"]/legend', $field_name, 'Fieldset and label found'); + $this->assertFieldByXPath('//fieldset[@aria-describedby="edit-' . $field_name . '-0--description"]', NULL, 'ARIA described-by found'); + $this->assertFieldByXPath('//div[@id="edit-' . $field_name . '-0--description"]', NULL, 'ARIA description found'); + + // Assert that Hour and Minute Elements do not appear on Date Only + $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-hour\"]", NULL, 'Hour element not found on Date Only.'); + $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-minute\"]", NULL, 'Minute element not found on Date Only.'); + + // Go to the form display page to assert that increment option does not appear on Date Only + $fieldEditUrl = 'entity_test/structure/entity_test/form-display'; + $this->drupalGet($fieldEditUrl); + + // Click on the widget settings button to open the widget settings form. + $this->drupalPostForm(NULL, [], $field_name . "_settings_edit"); + $xpathIncr = "//select[starts-with(@id, \"edit-fields-$field_name-settings-edit-form-settings-increment\")]"; + $this->assertNoFieldByXPath($xpathIncr, NULL, 'Increment element not found for Date Only.'); + + // Change the field to a datetime field. + $this->fieldStorage->setSetting('datetime_type', 'datetime'); + $this->fieldStorage->save(); + + // Change the widget to a datelist widget. + entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') + ->setComponent($field_name, [ + 'type' => 'datetime_datelist', + 'settings' => [ + 'increment' => 1, + 'date_order' => 'YMD', + 'time_type' => '12', + ], + ]) + ->save(); + \Drupal::entityManager()->clearCachedFieldDefinitions(); + + // Go to the form display page to assert that increment option does appear on Date Time + $fieldEditUrl = 'entity_test/structure/entity_test/form-display'; + $this->drupalGet($fieldEditUrl); + + // Click on the widget settings button to open the widget settings form. + $this->drupalPostForm(NULL, [], $field_name . "_settings_edit"); + $this->assertFieldByXPath($xpathIncr, NULL, 'Increment element found for Date and time.'); + + // Display creation form. + $this->drupalGet('entity_test/add'); + + $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-year\"]", NULL, 'Year element found.'); + $this->assertOptionSelected("edit-$field_name-0-value-year", '', 'No year selected.'); + $this->assertOptionByText("edit-$field_name-0-value-year", t('Year')); + $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-month\"]", NULL, 'Month element found.'); + $this->assertOptionSelected("edit-$field_name-0-value-month", '', 'No month selected.'); + $this->assertOptionByText("edit-$field_name-0-value-month", t('Month')); + $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-day\"]", NULL, 'Day element found.'); + $this->assertOptionSelected("edit-$field_name-0-value-day", '', 'No day selected.'); + $this->assertOptionByText("edit-$field_name-0-value-day", t('Day')); + $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-hour\"]", NULL, 'Hour element found.'); + $this->assertOptionSelected("edit-$field_name-0-value-hour", '', 'No hour selected.'); + $this->assertOptionByText("edit-$field_name-0-value-hour", t('Hour')); + $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-minute\"]", NULL, 'Minute element found.'); + $this->assertOptionSelected("edit-$field_name-0-value-minute", '', 'No minute selected.'); + $this->assertOptionByText("edit-$field_name-0-value-minute", t('Minute')); + $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-second\"]", NULL, 'Second element not found.'); + $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-ampm\"]", NULL, 'AMPM element found.'); + $this->assertOptionSelected("edit-$field_name-0-value-ampm", '', 'No ampm selected.'); + $this->assertOptionByText("edit-$field_name-0-value-ampm", t('AM/PM')); + + // Submit a valid date and ensure it is accepted. + $date_value = ['year' => 2012, 'month' => 12, 'day' => 31, 'hour' => 5, 'minute' => 15]; + + $edit = []; + // Add the ampm indicator since we are testing 12 hour time. + $date_value['ampm'] = 'am'; + foreach ($date_value as $part => $value) { + $edit["{$field_name}[0][value][$part]"] = $value; + } + + $this->drupalPostForm(NULL, $edit, t('Save')); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + + $this->assertOptionSelected("edit-$field_name-0-value-year", '2012', 'Correct year selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-month", '12', 'Correct month selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-day", '31', 'Correct day selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-hour", '5', 'Correct hour selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-minute", '15', 'Correct minute selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-ampm", 'am', 'Correct ampm selected.'); + + // Test the widget using increment other than 1 and 24 hour mode. + entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') + ->setComponent($field_name, [ + 'type' => 'datetime_datelist', + 'settings' => [ + 'increment' => 15, + 'date_order' => 'YMD', + 'time_type' => '24', + ], + ]) + ->save(); + \Drupal::entityManager()->clearCachedFieldDefinitions(); + + // Display creation form. + $this->drupalGet('entity_test/add'); + + // Other elements are unaffected by the changed settings. + $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-hour\"]", NULL, 'Hour element found.'); + $this->assertOptionSelected("edit-$field_name-0-value-hour", '', 'No hour selected.'); + $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-ampm\"]", NULL, 'AMPM element not found.'); + + // Submit a valid date and ensure it is accepted. + $date_value = ['year' => 2012, 'month' => 12, 'day' => 31, 'hour' => 17, 'minute' => 15]; + + $edit = []; + foreach ($date_value as $part => $value) { + $edit["{$field_name}[0][value][$part]"] = $value; + } + + $this->drupalPostForm(NULL, $edit, t('Save')); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + + $this->assertOptionSelected("edit-$field_name-0-value-year", '2012', 'Correct year selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-month", '12', 'Correct month selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-day", '31', 'Correct day selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-hour", '17', 'Correct hour selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-minute", '15', 'Correct minute selected.'); + + // Test the widget for partial completion of fields. + entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') + ->setComponent($field_name, [ + 'type' => 'datetime_datelist', + 'settings' => [ + 'increment' => 1, + 'date_order' => 'YMD', + 'time_type' => '24', + ], + ]) + ->save(); + \Drupal::entityManager()->clearCachedFieldDefinitions(); + + // Test the widget for validation notifications. + foreach ($this->datelistDataProvider() as $data) { + list($date_value, $expected) = $data; + + // Display creation form. + $this->drupalGet('entity_test/add'); + + // Submit a partial date and ensure and error message is provided. + $edit = []; + foreach ($date_value as $part => $value) { + $edit["{$field_name}[0][value][$part]"] = $value; + } + + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertResponse(200); + foreach ($expected as $expected_text) { + $this->assertText(t($expected_text)); + } + } + + // Test the widget for complete input with zeros as part of selections. + $this->drupalGet('entity_test/add'); + + $date_value = ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '0', 'minute' => '0']; + $edit = []; + foreach ($date_value as $part => $value) { + $edit["{$field_name}[0][value][$part]"] = $value; + } + + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertResponse(200); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + + // Test the widget to ensure zeros are not deselected on validation. + $this->drupalGet('entity_test/add'); + + $date_value = ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '', 'minute' => '0']; + $edit = []; + foreach ($date_value as $part => $value) { + $edit["{$field_name}[0][value][$part]"] = $value; + } + + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertResponse(200); + $this->assertOptionSelected("edit-$field_name-0-value-minute", '0', 'Correct minute selected.'); + } + + /** + * The data provider for testing the validation of the datelist widget. + * + * @return array + * An array of datelist input permutations to test. + */ + protected function datelistDataProvider() { + return [ + // Year only selected, validation error on Month, Day, Hour, Minute. + [['year' => 2012, 'month' => '', 'day' => '', 'hour' => '', 'minute' => ''], [ + 'A value must be selected for month.', + 'A value must be selected for day.', + 'A value must be selected for hour.', + 'A value must be selected for minute.', + ]], + // Year and Month selected, validation error on Day, Hour, Minute. + [['year' => 2012, 'month' => '12', 'day' => '', 'hour' => '', 'minute' => ''], [ + 'A value must be selected for day.', + 'A value must be selected for hour.', + 'A value must be selected for minute.', + ]], + // Year, Month and Day selected, validation error on Hour, Minute. + [['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '', 'minute' => ''], [ + 'A value must be selected for hour.', + 'A value must be selected for minute.', + ]], + // Year, Month, Day and Hour selected, validation error on Minute only. + [['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '0', 'minute' => ''], [ + 'A value must be selected for minute.', + ]], + ]; + } + + /** + * Test default value functionality. + */ + public function testDefaultValue() { + // Create a test content type. + $this->drupalCreateContentType(['type' => 'date_content']); + + // Create a field storage with settings to validate. + $field_name = Unicode::strtolower($this->randomMachineName()); + $field_storage = FieldStorageConfig::create([ + 'field_name' => $field_name, + 'entity_type' => 'node', + 'type' => 'datetime', + 'settings' => ['datetime_type' => 'date'], + ]); + $field_storage->save(); + + $field = FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => 'date_content', + ]); + $field->save(); + + // Loop through defined timezones to test that date-only defaults work at + // the extremes. + foreach (static::$timezones as $timezone) { + + $this->setSiteTimezone($timezone); + + // Set now as default_value. + $field_edit = [ + 'default_value_input[default_date_type]' => 'now', + ]; + $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); + + // Check that default value is selected in default value form. + $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name); + $this->assertOptionSelected('edit-default-value-input-default-date-type', 'now', 'The default value is selected in instance settings page'); + $this->assertFieldByName('default_value_input[default_date]', '', 'The relative default value is empty in instance settings page'); + + // Check if default_date has been stored successfully. + $config_entity = $this->config('field.field.node.date_content.' . $field_name) + ->get(); + $this->assertEqual($config_entity['default_value'][0], [ + 'default_date_type' => 'now', + 'default_date' => 'now', + ], 'Default value has been stored successfully'); + + // Clear field cache in order to avoid stale cache values. + \Drupal::entityManager()->clearCachedFieldDefinitions(); + + // Create a new node to check that datetime field default value is today. + $new_node = Node::create(['type' => 'date_content']); + $expected_date = new DrupalDateTime('now', drupal_get_user_timezone()); + $this->assertEqual($new_node->get($field_name) + ->offsetGet(0)->value, $expected_date->format(DATETIME_DATE_STORAGE_FORMAT)); + + // Set an invalid relative default_value to test validation. + $field_edit = [ + 'default_value_input[default_date_type]' => 'relative', + 'default_value_input[default_date]' => 'invalid date', + ]; + $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); + + $this->assertText('The relative date value entered is invalid.'); + + // Set a relative default_value. + $field_edit = [ + 'default_value_input[default_date_type]' => 'relative', + 'default_value_input[default_date]' => '+90 days', + ]; + $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); + + // Check that default value is selected in default value form. + $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name); + $this->assertOptionSelected('edit-default-value-input-default-date-type', 'relative', 'The default value is selected in instance settings page'); + $this->assertFieldByName('default_value_input[default_date]', '+90 days', 'The relative default value is displayed in instance settings page'); + + // Check if default_date has been stored successfully. + $config_entity = $this->config('field.field.node.date_content.' . $field_name) + ->get(); + $this->assertEqual($config_entity['default_value'][0], [ + 'default_date_type' => 'relative', + 'default_date' => '+90 days', + ], 'Default value has been stored successfully'); + + // Clear field cache in order to avoid stale cache values. + \Drupal::entityManager()->clearCachedFieldDefinitions(); + + // Create a new node to check that datetime field default value is +90 + // days. + $new_node = Node::create(['type' => 'date_content']); + $expected_date = new DrupalDateTime('+90 days', drupal_get_user_timezone()); + $this->assertEqual($new_node->get($field_name) + ->offsetGet(0)->value, $expected_date->format(DATETIME_DATE_STORAGE_FORMAT)); + + // Remove default value. + $field_edit = [ + 'default_value_input[default_date_type]' => '', + ]; + $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); + + // Check that default value is selected in default value form. + $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name); + $this->assertOptionSelected('edit-default-value-input-default-date-type', '', 'The default value is selected in instance settings page'); + $this->assertFieldByName('default_value_input[default_date]', '', 'The relative default value is empty in instance settings page'); + + // Check if default_date has been stored successfully. + $config_entity = $this->config('field.field.node.date_content.' . $field_name) + ->get(); + $this->assertTrue(empty($config_entity['default_value']), 'Empty default value has been stored successfully'); + + // Clear field cache in order to avoid stale cache values. + \Drupal::entityManager()->clearCachedFieldDefinitions(); + + // Create a new node to check that datetime field default value is not + // set. + $new_node = Node::create(['type' => 'date_content']); + $this->assertNull($new_node->get($field_name)->value, 'Default value is not set'); + } + } + + /** + * Test that invalid values are caught and marked as invalid. + */ + public function testInvalidField() { + // Change the field to a datetime field. + $this->fieldStorage->setSetting('datetime_type', 'datetime'); + $this->fieldStorage->save(); + $field_name = $this->fieldStorage->getName(); + + // Display creation form. + $this->drupalGet('entity_test/add'); + $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Date element found.'); + $this->assertFieldByName("{$field_name}[0][value][time]", '', 'Time element found.'); + + // Submit invalid dates and ensure they is not accepted. + $date_value = ''; + $edit = [ + "{$field_name}[0][value][date]" => $date_value, + "{$field_name}[0][value][time]" => '12:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', 'Empty date value has been caught.'); + + $date_value = 'aaaa-12-01'; + $edit = [ + "{$field_name}[0][value][date]" => $date_value, + "{$field_name}[0][value][time]" => '00:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', format_string('Invalid year value %date has been caught.', ['%date' => $date_value])); + + $date_value = '2012-75-01'; + $edit = [ + "{$field_name}[0][value][date]" => $date_value, + "{$field_name}[0][value][time]" => '00:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', format_string('Invalid month value %date has been caught.', ['%date' => $date_value])); + + $date_value = '2012-12-99'; + $edit = [ + "{$field_name}[0][value][date]" => $date_value, + "{$field_name}[0][value][time]" => '00:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', format_string('Invalid day value %date has been caught.', ['%date' => $date_value])); + + $date_value = '2012-12-01'; + $time_value = ''; + $edit = [ + "{$field_name}[0][value][date]" => $date_value, + "{$field_name}[0][value][time]" => $time_value, + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', 'Empty time value has been caught.'); + + $date_value = '2012-12-01'; + $time_value = '49:00:00'; + $edit = [ + "{$field_name}[0][value][date]" => $date_value, + "{$field_name}[0][value][time]" => $time_value, + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', format_string('Invalid hour value %time has been caught.', ['%time' => $time_value])); + + $date_value = '2012-12-01'; + $time_value = '12:99:00'; + $edit = [ + "{$field_name}[0][value][date]" => $date_value, + "{$field_name}[0][value][time]" => $time_value, + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', format_string('Invalid minute value %time has been caught.', ['%time' => $time_value])); + + $date_value = '2012-12-01'; + $time_value = '12:15:99'; + $edit = [ + "{$field_name}[0][value][date]" => $date_value, + "{$field_name}[0][value][time]" => $time_value, + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', format_string('Invalid second value %time has been caught.', ['%time' => $time_value])); + } + + /** + * Tests that 'Date' field storage setting form is disabled if field has data. + */ + public function testDateStorageSettings() { + // Create a test content type. + $this->drupalCreateContentType(['type' => 'date_content']); + + // Create a field storage with settings to validate. + $field_name = Unicode::strtolower($this->randomMachineName()); + $field_storage = FieldStorageConfig::create([ + 'field_name' => $field_name, + 'entity_type' => 'node', + 'type' => 'datetime', + 'settings' => [ + 'datetime_type' => 'date', + ], + ]); + $field_storage->save(); + $field = FieldConfig::create([ + 'field_storage' => $field_storage, + 'field_name' => $field_name, + 'bundle' => 'date_content', + ]); + $field->save(); + + entity_get_form_display('node', 'date_content', 'default') + ->setComponent($field_name, [ + 'type' => 'datetime_default', + ]) + ->save(); + $edit = [ + 'title[0][value]' => $this->randomString(), + 'body[0][value]' => $this->randomString(), + $field_name . '[0][value][date]' => '2016-04-01', + ]; + $this->drupalPostForm('node/add/date_content', $edit, t('Save')); + $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name . '/storage'); + $result = $this->xpath("//*[@id='edit-settings-datetime-type' and contains(@disabled, 'disabled')]"); + $this->assertEqual(count($result), 1, "Changing datetime setting is disabled."); + $this->assertText('There is data for this field in the database. The field settings can no longer be changed.'); + } + +} diff --git a/core/modules/datetime_range/src/DateTimeRangeTrait.php b/core/modules/datetime_range/src/DateTimeRangeTrait.php index 5a34f2c..3f05b82 100644 --- a/core/modules/datetime_range/src/DateTimeRangeTrait.php +++ b/core/modules/datetime_range/src/DateTimeRangeTrait.php @@ -2,8 +2,7 @@ namespace Drupal\datetime_range; -use Drupal\Core\Datetime\DrupalDateTime; -use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; +use Drupal\Core\Field\FieldItemListInterface; /** * Provides friendly methods for datetime range. @@ -11,68 +10,40 @@ trait DateTimeRangeTrait { /** - * Creates a render array from a date object. - * - * @param \Drupal\Core\Datetime\DrupalDateTime $date - * A date object. - * - * @return array - * A render array. + * {@inheritdoc} */ - protected function buildDate(DrupalDateTime $date) { - if ($this->getFieldSetting('datetime_type') == DateTimeItem::DATETIME_TYPE_DATE) { - // A date without time will pick up the current time, use the default. - datetime_date_default_time($date); + public function viewElements(FieldItemListInterface $items, $langcode) { + $elements = []; + $separator = $this->getSetting('separator'); + + foreach ($items as $delta => $item) { + if (!empty($item->start_date) && !empty($item->end_date)) { + /** @var \Drupal\Core\Datetime\DrupalDateTime $start_date */ + $start_date = $item->start_date; + /** @var \Drupal\Core\Datetime\DrupalDateTime $end_date */ + $end_date = $item->end_date; + + if ($start_date->getTimestamp() !== $end_date->getTimestamp()) { + $elements[$delta] = [ + 'start_date' => $this->buildDateWithIsoAttribute($start_date), + 'separator' => ['#plain_text' => ' ' . $separator . ' '], + 'end_date' => $this->buildDateWithIsoAttribute($end_date), + ]; + } + else { + $elements[$delta] = $this->buildDateWithIsoAttribute($start_date); + + if (!empty($item->_attributes)) { + $elements[$delta]['#attributes'] += $item->_attributes; + // Unset field item attributes since they have been included in the + // formatter output and should not be rendered in the field template. + unset($item->_attributes); + } + } + } } - $this->setTimeZone($date); - $build = [ - '#plain_text' => $this->formatDate($date), - '#cache' => [ - 'contexts' => [ - 'timezone', - ], - ], - ]; - - return $build; - } - - /** - * Creates a render array from a date object with ISO date attribute. - * - * @param \Drupal\Core\Datetime\DrupalDateTime $date - * A date object. - * - * @return array - * A render array. - */ - protected function buildDateWithIsoAttribute(DrupalDateTime $date) { - if ($this->getFieldSetting('datetime_type') == DateTimeItem::DATETIME_TYPE_DATE) { - // A date without time will pick up the current time, use the default. - datetime_date_default_time($date); - } - - // Create the ISO date in Universal Time. - $iso_date = $date->format("Y-m-d\TH:i:s") . 'Z'; - - $this->setTimeZone($date); - - $build = [ - '#theme' => 'time', - '#text' => $this->formatDate($date), - '#html' => FALSE, - '#attributes' => [ - 'datetime' => $iso_date, - ], - '#cache' => [ - 'contexts' => [ - 'timezone', - ], - ], - ]; - - return $build; + return $elements; } } diff --git a/core/modules/datetime_range/src/Plugin/Field/FieldFormatter/DateRangeCustomFormatter.php b/core/modules/datetime_range/src/Plugin/Field/FieldFormatter/DateRangeCustomFormatter.php index a26bbef..78aa8aa 100644 --- a/core/modules/datetime_range/src/Plugin/Field/FieldFormatter/DateRangeCustomFormatter.php +++ b/core/modules/datetime_range/src/Plugin/Field/FieldFormatter/DateRangeCustomFormatter.php @@ -38,6 +38,9 @@ public static function defaultSettings() { * {@inheritdoc} */ public function viewElements(FieldItemListInterface $items, $langcode) { + // @todo Evaluate removing this method in + // https://www.drupal.org/node/2793143 to determine if the behavior and + // markup in the base class implementation can be used instead. $elements = []; $separator = $this->getSetting('separator'); diff --git a/core/modules/datetime_range/src/Plugin/Field/FieldFormatter/DateRangeDefaultFormatter.php b/core/modules/datetime_range/src/Plugin/Field/FieldFormatter/DateRangeDefaultFormatter.php index f07e834..b93851b 100644 --- a/core/modules/datetime_range/src/Plugin/Field/FieldFormatter/DateRangeDefaultFormatter.php +++ b/core/modules/datetime_range/src/Plugin/Field/FieldFormatter/DateRangeDefaultFormatter.php @@ -2,7 +2,6 @@ namespace Drupal\datetime_range\Plugin\Field\FieldFormatter; -use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\datetime\Plugin\Field\FieldFormatter\DateTimeDefaultFormatter; use Drupal\datetime_range\DateTimeRangeTrait; @@ -38,42 +37,6 @@ public static function defaultSettings() { /** * {@inheritdoc} */ - public function viewElements(FieldItemListInterface $items, $langcode) { - $elements = []; - $separator = $this->getSetting('separator'); - - foreach ($items as $delta => $item) { - if (!empty($item->start_date) && !empty($item->end_date)) { - /** @var \Drupal\Core\Datetime\DrupalDateTime $start_date */ - $start_date = $item->start_date; - /** @var \Drupal\Core\Datetime\DrupalDateTime $end_date */ - $end_date = $item->end_date; - - if ($start_date->getTimestamp() !== $end_date->getTimestamp()) { - $elements[$delta] = [ - 'start_date' => $this->buildDateWithIsoAttribute($start_date), - 'separator' => ['#plain_text' => ' ' . $separator . ' '], - 'end_date' => $this->buildDateWithIsoAttribute($end_date), - ]; - } - else { - $elements[$delta] = $this->buildDateWithIsoAttribute($start_date); - if (!empty($item->_attributes)) { - $elements[$delta]['#attributes'] += $item->_attributes; - // Unset field item attributes since they have been included in the - // formatter output and should not be rendered in the field template. - unset($item->_attributes); - } - } - } - } - - return $elements; - } - - /** - * {@inheritdoc} - */ public function settingsForm(array $form, FormStateInterface $form_state) { $form = parent::settingsForm($form, $form_state); diff --git a/core/modules/datetime_range/src/Plugin/Field/FieldFormatter/DateRangePlainFormatter.php b/core/modules/datetime_range/src/Plugin/Field/FieldFormatter/DateRangePlainFormatter.php index 1fb4702..0f74fba 100644 --- a/core/modules/datetime_range/src/Plugin/Field/FieldFormatter/DateRangePlainFormatter.php +++ b/core/modules/datetime_range/src/Plugin/Field/FieldFormatter/DateRangePlainFormatter.php @@ -57,6 +57,13 @@ public function viewElements(FieldItemListInterface $items, $langcode) { } else { $elements[$delta] = $this->buildDate($start_date); + + if (!empty($item->_attributes)) { + $elements[$delta]['#attributes'] += $item->_attributes; + // Unset field item attributes since they have been included in the + // formatter output and should not be rendered in the field template. + unset($item->_attributes); + } } } } diff --git a/core/modules/datetime_range/src/Tests/DateRangeFieldTest.php b/core/modules/datetime_range/src/Tests/DateRangeFieldTest.php deleted file mode 100644 index bf75fc0..0000000 --- a/core/modules/datetime_range/src/Tests/DateRangeFieldTest.php +++ /dev/null @@ -1,1337 +0,0 @@ - '', 'separator' => '-']; - - /** - * {@inheritdoc} - */ - protected function getTestFieldType() { - return 'daterange'; - } - - /** - * Tests date field functionality. - */ - public function testDateRangeField() { - $field_name = $this->fieldStorage->getName(); - - // Loop through defined timezones to test that date-only fields work at the - // extremes. - foreach (static::$timezones as $timezone) { - - $this->setSiteTimezone($timezone); - - // Ensure field is set to a date-only field. - $this->fieldStorage->setSetting('datetime_type', DateRangeItem::DATETIME_TYPE_DATE); - $this->fieldStorage->save(); - - // Display creation form. - $this->drupalGet('entity_test/add'); - $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Start date element found.'); - $this->assertFieldByName("{$field_name}[0][end_value][date]", '', 'End date element found.'); - $this->assertFieldByXPath('//*[@id="edit-' . $field_name . '-wrapper"]//label[contains(@class, "js-form-required")]', TRUE, 'Required markup found'); - $this->assertNoFieldByName("{$field_name}[0][value][time]", '', 'Start time element not found.'); - $this->assertNoFieldByName("{$field_name}[0][end_value][time]", '', 'End time element not found.'); - $this->assertFieldByXPath('//fieldset[@id="edit-' . $field_name . '-0"]/legend//text()', $field_name, 'Fieldset and label found'); - $this->assertFieldByXPath('//fieldset[@aria-describedby="edit-' . $field_name . '-0--description"]', NULL, 'ARIA described-by found'); - $this->assertFieldByXPath('//div[@id="edit-' . $field_name . '-0--description"]', NULL, 'ARIA description found'); - - // Build up dates in the UTC timezone. - $value = '2012-12-31 00:00:00'; - $start_date = new DrupalDateTime($value, 'UTC'); - $end_value = '2013-06-06 00:00:00'; - $end_date = new DrupalDateTime($end_value, 'UTC'); - - // Submit a valid date and ensure it is accepted. - $date_format = DateFormat::load('html_date')->getPattern(); - $time_format = DateFormat::load('html_time')->getPattern(); - - $edit = [ - "{$field_name}[0][value][date]" => $start_date->format($date_format), - "{$field_name}[0][end_value][date]" => $end_date->format($date_format), - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); - $this->assertRaw($start_date->format($date_format)); - $this->assertNoRaw($start_date->format($time_format)); - $this->assertRaw($end_date->format($date_format)); - $this->assertNoRaw($end_date->format($time_format)); - - // Verify the date doesn't change when entity is edited through the form. - $entity = EntityTest::load($id); - $this->assertEqual('2012-12-31', $entity->{$field_name}->value); - $this->assertEqual('2013-06-06', $entity->{$field_name}->end_value); - $this->drupalGet('entity_test/manage/' . $id . '/edit'); - $this->drupalPostForm(NULL, [], t('Save')); - $this->drupalGet('entity_test/manage/' . $id . '/edit'); - $this->drupalPostForm(NULL, [], t('Save')); - $this->drupalGet('entity_test/manage/' . $id . '/edit'); - $this->drupalPostForm(NULL, [], t('Save')); - $entity = EntityTest::load($id); - $this->assertEqual('2012-12-31', $entity->{$field_name}->value); - $this->assertEqual('2013-06-06', $entity->{$field_name}->end_value); - - // Formats that display a time component for date-only fields will display - // the default time, so that is applied before calculating the expected - // value. - datetime_date_default_time($start_date); - datetime_date_default_time($end_date); - - // Reset display options since these get changed below. - $this->displayOptions = [ - 'type' => 'daterange_default', - 'label' => 'hidden', - 'settings' => [ - 'format_type' => 'long', - 'separator' => 'THESEPARATOR', - ] + $this->defaultSettings, - ]; - - // Verify that the default formatter works. - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - - $start_expected = $this->dateFormatter->format($start_date->getTimestamp(), 'long', '', DATETIME_STORAGE_TIMEZONE); - $start_expected_iso = $this->dateFormatter->format($start_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', DATETIME_STORAGE_TIMEZONE); - $end_expected = $this->dateFormatter->format($end_date->getTimestamp(), 'long', '', DATETIME_STORAGE_TIMEZONE); - $end_expected_iso = $this->dateFormatter->format($end_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', DATETIME_STORAGE_TIMEZONE); - $this->renderTestEntity($id); - $this->assertFieldByXPath('//time[@datetime="' . $start_expected_iso . '"]', $start_expected, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', [ - '%value' => 'long', - '%expected' => $start_expected, - '%expected_iso' => $start_expected_iso, - ])); - $this->assertFieldByXPath('//time[@datetime="' . $end_expected_iso . '"]', $end_expected, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', [ - '%value' => 'long', - '%expected' => $end_expected, - '%expected_iso' => $end_expected_iso, - ])); - $this->assertText(' THESEPARATOR ', 'Found proper separator'); - - // Verify that hook_entity_prepare_view can add attributes. - // @see entity_test_entity_prepare_view() - $this->drupalGet('entity_test/' . $id); - $this->assertFieldByXPath('//div[@data-field-item-attr="foobar"]'); - - // Verify that the plain formatter works. - $this->displayOptions['type'] = 'daterange_plain'; - $this->displayOptions['settings'] = $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $start_date->format(DATETIME_DATE_STORAGE_FORMAT) . ' - ' . $end_date->format(DATETIME_DATE_STORAGE_FORMAT); - $this->renderTestEntity($id); - $this->assertText($expected, new FormattableMarkup('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); - - // Verify that the custom formatter works. - $this->displayOptions['type'] = 'daterange_custom'; - $this->displayOptions['settings'] = ['date_format' => 'm/d/Y'] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $start_date->format($this->displayOptions['settings']['date_format']) . ' - ' . $end_date->format($this->displayOptions['settings']['date_format']); - $this->renderTestEntity($id); - $this->assertText($expected, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); - - // Test formatters when start date and end date are the same - $this->drupalGet('entity_test/add'); - $value = '2012-12-31 00:00:00'; - $start_date = new DrupalDateTime($value, 'UTC'); - - $date_format = DateFormat::load('html_date')->getPattern(); - $time_format = DateFormat::load('html_time')->getPattern(); - - $edit = [ - "{$field_name}[0][value][date]" => $start_date->format($date_format), - "{$field_name}[0][end_value][date]" => $start_date->format($date_format), - ]; - - $this->drupalPostForm(NULL, $edit, t('Save')); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); - - datetime_date_default_time($start_date); - - $this->displayOptions = [ - 'type' => 'daterange_default', - 'label' => 'hidden', - 'settings' => [ - 'format_type' => 'long', - 'separator' => 'THESEPARATOR', - ] + $this->defaultSettings, - ]; - - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - - $start_expected = $this->dateFormatter->format($start_date->getTimestamp(), 'long', '', DATETIME_STORAGE_TIMEZONE); - $start_expected_iso = $this->dateFormatter->format($start_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', DATETIME_STORAGE_TIMEZONE); - $this->renderTestEntity($id); - $this->assertFieldByXPath('//time[@datetime="' . $start_expected_iso . '"]', $start_expected, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', [ - '%value' => 'long', - '%expected' => $start_expected, - '%expected_iso' => $start_expected_iso, - ])); - $this->assertNoText(' THESEPARATOR ', 'Separator not found on page'); - - // Verify that hook_entity_prepare_view can add attributes. - // @see entity_test_entity_prepare_view() - $this->drupalGet('entity_test/' . $id); - $this->assertFieldByXPath('//time[@data-field-item-attr="foobar"]'); - - $this->displayOptions['type'] = 'daterange_plain'; - $this->displayOptions['settings'] = $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $start_date->format(DATETIME_DATE_STORAGE_FORMAT); - $this->renderTestEntity($id); - $this->assertText($expected, new FormattableMarkup('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); - $this->assertNoText(' THESEPARATOR ', 'Separator not found on page'); - - $this->displayOptions['type'] = 'daterange_custom'; - $this->displayOptions['settings'] = ['date_format' => 'm/d/Y'] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $start_date->format($this->displayOptions['settings']['date_format']); - $this->renderTestEntity($id); - $this->assertText($expected, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); - $this->assertNoText(' THESEPARATOR ', 'Separator not found on page'); - } - } - - /** - * Tests date and time field. - */ - public function testDatetimeRangeField() { - $field_name = $this->fieldStorage->getName(); - - // Ensure the field to a datetime field. - $this->fieldStorage->setSetting('datetime_type', DateRangeItem::DATETIME_TYPE_DATETIME); - $this->fieldStorage->save(); - - // Display creation form. - $this->drupalGet('entity_test/add'); - $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Start date element found.'); - $this->assertFieldByName("{$field_name}[0][value][time]", '', 'Start time element found.'); - $this->assertFieldByName("{$field_name}[0][end_value][date]", '', 'End date element found.'); - $this->assertFieldByName("{$field_name}[0][end_value][time]", '', 'End time element found.'); - $this->assertFieldByXPath('//fieldset[@id="edit-' . $field_name . '-0"]/legend//text()', $field_name, 'Fieldset and label found'); - $this->assertFieldByXPath('//fieldset[@aria-describedby="edit-' . $field_name . '-0--description"]', NULL, 'ARIA described-by found'); - $this->assertFieldByXPath('//div[@id="edit-' . $field_name . '-0--description"]', NULL, 'ARIA description found'); - - // Build up dates in the UTC timezone. - $value = '2012-12-31 00:00:00'; - $start_date = new DrupalDateTime($value, 'UTC'); - $end_value = '2013-06-06 00:00:00'; - $end_date = new DrupalDateTime($end_value, 'UTC'); - - // Update the timezone to the system default. - $start_date->setTimezone(timezone_open(drupal_get_user_timezone())); - $end_date->setTimezone(timezone_open(drupal_get_user_timezone())); - - // Submit a valid date and ensure it is accepted. - $date_format = DateFormat::load('html_date')->getPattern(); - $time_format = DateFormat::load('html_time')->getPattern(); - - $edit = [ - "{$field_name}[0][value][date]" => $start_date->format($date_format), - "{$field_name}[0][value][time]" => $start_date->format($time_format), - "{$field_name}[0][end_value][date]" => $end_date->format($date_format), - "{$field_name}[0][end_value][time]" => $end_date->format($time_format), - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); - $this->assertRaw($start_date->format($date_format)); - $this->assertRaw($start_date->format($time_format)); - $this->assertRaw($end_date->format($date_format)); - $this->assertRaw($end_date->format($time_format)); - - // Verify that the default formatter works. - $this->displayOptions['settings'] = [ - 'format_type' => 'long', - 'separator' => 'THESEPARATOR', - ] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - - $start_expected = $this->dateFormatter->format($start_date->getTimestamp(), 'long'); - $start_expected_iso = $this->dateFormatter->format($start_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); - $end_expected = $this->dateFormatter->format($end_date->getTimestamp(), 'long'); - $end_expected_iso = $this->dateFormatter->format($end_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); - $this->renderTestEntity($id); - $this->assertFieldByXPath('//time[@datetime="' . $start_expected_iso . '"]', $start_expected, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => 'long', '%expected' => $start_expected, '%expected_iso' => $start_expected_iso])); - $this->assertFieldByXPath('//time[@datetime="' . $end_expected_iso . '"]', $end_expected, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => 'long', '%expected' => $end_expected, '%expected_iso' => $end_expected_iso])); - $this->assertText(' THESEPARATOR ', 'Found proper separator'); - - // Verify that hook_entity_prepare_view can add attributes. - // @see entity_test_entity_prepare_view() - $this->drupalGet('entity_test/' . $id); - $this->assertFieldByXPath('//div[@data-field-item-attr="foobar"]'); - - // Verify that the plain formatter works. - $this->displayOptions['type'] = 'daterange_plain'; - $this->displayOptions['settings'] = $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $start_date->format(DATETIME_DATETIME_STORAGE_FORMAT) . ' - ' . $end_date->format(DATETIME_DATETIME_STORAGE_FORMAT); - $this->renderTestEntity($id); - $this->assertText($expected, new FormattableMarkup('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); - - // Verify that the 'datetime_custom' formatter works. - $this->displayOptions['type'] = 'daterange_custom'; - $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A'] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $start_date->format($this->displayOptions['settings']['date_format']) . ' - ' . $end_date->format($this->displayOptions['settings']['date_format']); - $this->renderTestEntity($id); - $this->assertText($expected, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); - - // Verify that the 'timezone_override' setting works. - $this->displayOptions['type'] = 'daterange_custom'; - $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A', 'timezone_override' => 'America/New_York'] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $start_date->format($this->displayOptions['settings']['date_format'], ['timezone' => 'America/New_York']); - $expected .= ' - ' . $end_date->format($this->displayOptions['settings']['date_format'], ['timezone' => 'America/New_York']); - $this->renderTestEntity($id); - $this->assertText($expected, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); - - // Test formatters when start date and end date are the same - $this->drupalGet('entity_test/add'); - $value = '2012-12-31 00:00:00'; - $start_date = new DrupalDateTime($value, 'UTC'); - $start_date->setTimezone(timezone_open(drupal_get_user_timezone())); - - $date_format = DateFormat::load('html_date')->getPattern(); - $time_format = DateFormat::load('html_time')->getPattern(); - - $edit = [ - "{$field_name}[0][value][date]" => $start_date->format($date_format), - "{$field_name}[0][value][time]" => $start_date->format($time_format), - "{$field_name}[0][end_value][date]" => $start_date->format($date_format), - "{$field_name}[0][end_value][time]" => $start_date->format($time_format), - ]; - - $this->drupalPostForm(NULL, $edit, t('Save')); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); - - $this->displayOptions = [ - 'type' => 'daterange_default', - 'label' => 'hidden', - 'settings' => [ - 'format_type' => 'long', - 'separator' => 'THESEPARATOR', - ] + $this->defaultSettings, - ]; - - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - - $start_expected = $this->dateFormatter->format($start_date->getTimestamp(), 'long'); - $start_expected_iso = $this->dateFormatter->format($start_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); - $this->renderTestEntity($id); - $this->assertFieldByXPath('//time[@datetime="' . $start_expected_iso . '"]', $start_expected, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => 'long', '%expected' => $start_expected, '%expected_iso' => $start_expected_iso])); - $this->assertNoText(' THESEPARATOR ', 'Separator not found on page'); - - // Verify that hook_entity_prepare_view can add attributes. - // @see entity_test_entity_prepare_view() - $this->drupalGet('entity_test/' . $id); - $this->assertFieldByXPath('//time[@data-field-item-attr="foobar"]'); - - $this->displayOptions['type'] = 'daterange_plain'; - $this->displayOptions['settings'] = $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $start_date->format(DATETIME_DATETIME_STORAGE_FORMAT); - $this->renderTestEntity($id); - $this->assertText($expected, new FormattableMarkup('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); - $this->assertNoText(' THESEPARATOR ', 'Separator not found on page'); - - $this->displayOptions['type'] = 'daterange_custom'; - $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A'] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $start_date->format($this->displayOptions['settings']['date_format']); - $this->renderTestEntity($id); - $this->assertText($expected, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); - $this->assertNoText(' THESEPARATOR ', 'Separator not found on page'); - } - - /** - * Tests all-day field. - */ - public function testAlldayRangeField() { - $field_name = $this->fieldStorage->getName(); - - // Ensure field is set to a all-day field. - $this->fieldStorage->setSetting('datetime_type', DateRangeItem::DATETIME_TYPE_ALLDAY); - $this->fieldStorage->save(); - - // Display creation form. - $this->drupalGet('entity_test/add'); - $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Start date element found.'); - $this->assertFieldByName("{$field_name}[0][end_value][date]", '', 'End date element found.'); - $this->assertFieldByXPath('//*[@id="edit-' . $field_name . '-wrapper"]//label[contains(@class, "js-form-required")]', TRUE, 'Required markup found'); - $this->assertNoFieldByName("{$field_name}[0][value][time]", '', 'Start time element not found.'); - $this->assertNoFieldByName("{$field_name}[0][end_value][time]", '', 'End time element not found.'); - $this->assertFieldByXPath('//fieldset[@id="edit-' . $field_name . '-0"]/legend//text()', $field_name, 'Fieldset and label found'); - $this->assertFieldByXPath('//fieldset[@aria-describedby="edit-' . $field_name . '-0--description"]', NULL, 'ARIA described-by found'); - $this->assertFieldByXPath('//div[@id="edit-' . $field_name . '-0--description"]', NULL, 'ARIA description found'); - - // Build up dates in the proper timezone. - $value = '2012-12-31 00:00:00'; - $start_date = new DrupalDateTime($value, timezone_open(drupal_get_user_timezone())); - $end_value = '2013-06-06 23:59:59'; - $end_date = new DrupalDateTime($end_value, timezone_open(drupal_get_user_timezone())); - - // Submit a valid date and ensure it is accepted. - $date_format = DateFormat::load('html_date')->getPattern(); - $time_format = DateFormat::load('html_time')->getPattern(); - - $edit = [ - "{$field_name}[0][value][date]" => $start_date->format($date_format), - "{$field_name}[0][end_value][date]" => $end_date->format($date_format), - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); - $this->assertRaw($start_date->format($date_format)); - $this->assertNoRaw($start_date->format($time_format)); - $this->assertRaw($end_date->format($date_format)); - $this->assertNoRaw($end_date->format($time_format)); - - // Verify that the default formatter works. - $this->displayOptions['settings'] = [ - 'format_type' => 'long', - 'separator' => 'THESEPARATOR', - ] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - - $start_expected = $this->dateFormatter->format($start_date->getTimestamp(), 'long'); - $start_expected_iso = $this->dateFormatter->format($start_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); - $end_expected = $this->dateFormatter->format($end_date->getTimestamp(), 'long'); - $end_expected_iso = $this->dateFormatter->format($end_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); - $this->renderTestEntity($id); - $this->assertFieldByXPath('//time[@datetime="' . $start_expected_iso . '"]', $start_expected, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => 'long', '%expected' => $start_expected, '%expected_iso' => $start_expected_iso])); - $this->assertFieldByXPath('//time[@datetime="' . $end_expected_iso . '"]', $end_expected, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => 'long', '%expected' => $end_expected, '%expected_iso' => $end_expected_iso])); - $this->assertText(' THESEPARATOR ', 'Found proper separator'); - - // Verify that hook_entity_prepare_view can add attributes. - // @see entity_test_entity_prepare_view() - $this->drupalGet('entity_test/' . $id); - $this->assertFieldByXPath('//div[@data-field-item-attr="foobar"]'); - - // Verify that the plain formatter works. - $this->displayOptions['type'] = 'daterange_plain'; - $this->displayOptions['settings'] = $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $start_date->format(DATETIME_DATETIME_STORAGE_FORMAT) . ' - ' . $end_date->format(DATETIME_DATETIME_STORAGE_FORMAT); - $this->renderTestEntity($id); - $this->assertText($expected, new FormattableMarkup('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); - - // Verify that the custom formatter works. - $this->displayOptions['type'] = 'daterange_custom'; - $this->displayOptions['settings'] = ['date_format' => 'm/d/Y'] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $start_date->format($this->displayOptions['settings']['date_format']) . ' - ' . $end_date->format($this->displayOptions['settings']['date_format']); - $this->renderTestEntity($id); - $this->assertText($expected, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); - - // Verify that the 'timezone_override' setting works. - $this->displayOptions['type'] = 'daterange_custom'; - $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A', 'timezone_override' => 'America/New_York'] + $this->defaultSettings; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $start_date->format($this->displayOptions['settings']['date_format'], ['timezone' => 'America/New_York']); - $expected .= ' - ' . $end_date->format($this->displayOptions['settings']['date_format'], ['timezone' => 'America/New_York']); - $this->renderTestEntity($id); - $this->assertText($expected, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); - - // Test formatters when start date and end date are the same - $this->drupalGet('entity_test/add'); - - $value = '2012-12-31 00:00:00'; - $start_date = new DrupalDateTime($value, timezone_open(drupal_get_user_timezone())); - $end_value = '2012-12-31 23:59:59'; - $end_date = new DrupalDateTime($end_value, timezone_open(drupal_get_user_timezone())); - - $date_format = DateFormat::load('html_date')->getPattern(); - $time_format = DateFormat::load('html_time')->getPattern(); - - $edit = [ - "{$field_name}[0][value][date]" => $start_date->format($date_format), - "{$field_name}[0][end_value][date]" => $start_date->format($date_format), - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); - - $this->displayOptions = [ - 'type' => 'daterange_default', - 'label' => 'hidden', - 'settings' => [ - 'format_type' => 'long', - 'separator' => 'THESEPARATOR', - ] + $this->defaultSettings, - ]; - - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - - $start_expected = $this->dateFormatter->format($start_date->getTimestamp(), 'long'); - $start_expected_iso = $this->dateFormatter->format($start_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); - $end_expected = $this->dateFormatter->format($end_date->getTimestamp(), 'long'); - $end_expected_iso = $this->dateFormatter->format($end_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); - $this->renderTestEntity($id); - $this->assertFieldByXPath('//time[@datetime="' . $start_expected_iso . '"]', $start_expected, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => 'long', '%expected' => $start_expected, '%expected_iso' => $start_expected_iso])); - $this->assertFieldByXPath('//time[@datetime="' . $end_expected_iso . '"]', $end_expected, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => 'long', '%expected' => $end_expected, '%expected_iso' => $end_expected_iso])); - $this->assertText(' THESEPARATOR ', 'Found proper separator'); - - // Verify that hook_entity_prepare_view can add attributes. - // @see entity_test_entity_prepare_view() - $this->drupalGet('entity_test/' . $id); - $this->assertFieldByXPath('//div[@data-field-item-attr="foobar"]'); - - $this->displayOptions['type'] = 'daterange_plain'; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $start_date->format(DATETIME_DATETIME_STORAGE_FORMAT) . ' THESEPARATOR ' . $end_date->format(DATETIME_DATETIME_STORAGE_FORMAT); - $this->renderTestEntity($id); - $this->assertText($expected, new FormattableMarkup('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); - $this->assertText(' THESEPARATOR ', 'Found proper separator'); - - $this->displayOptions['type'] = 'daterange_custom'; - $this->displayOptions['settings']['date_format'] = 'm/d/Y'; - entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') - ->setComponent($field_name, $this->displayOptions) - ->save(); - $expected = $start_date->format($this->displayOptions['settings']['date_format']) . ' THESEPARATOR ' . $end_date->format($this->displayOptions['settings']['date_format']); - $this->renderTestEntity($id); - $this->assertText($expected, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); - $this->assertText(' THESEPARATOR ', 'Found proper separator'); - - } - - /** - * Tests Date Range List Widget functionality. - */ - public function testDatelistWidget() { - $field_name = $this->fieldStorage->getName(); - - // Ensure field is set to a date only field. - $this->fieldStorage->setSetting('datetime_type', DateRangeItem::DATETIME_TYPE_DATE); - $this->fieldStorage->save(); - - // Change the widget to a datelist widget. - entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') - ->setComponent($field_name, [ - 'type' => 'daterange_datelist', - 'settings' => [ - 'date_order' => 'YMD', - ], - ]) - ->save(); - \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); - - // Display creation form. - $this->drupalGet('entity_test/add'); - $this->assertFieldByXPath('//fieldset[@id="edit-' . $field_name . '-0"]/legend//text()', $field_name, 'Fieldset and label found'); - $this->assertFieldByXPath('//fieldset[@aria-describedby="edit-' . $field_name . '-0--description"]', NULL, 'ARIA described-by found'); - $this->assertFieldByXPath('//div[@id="edit-' . $field_name . '-0--description"]', NULL, 'ARIA description found'); - - // Assert that Hour and Minute Elements do not appear on Date Only. - $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-hour\"]", NULL, 'Hour element not found on Date Only.'); - $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-minute\"]", NULL, 'Minute element not found on Date Only.'); - $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-end-value-hour\"]", NULL, 'Hour element not found on Date Only.'); - $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-end-value-minute\"]", NULL, 'Minute element not found on Date Only.'); - - // Go to the form display page to assert that increment option does not - // appear on Date Only. - $fieldEditUrl = 'entity_test/structure/entity_test/form-display'; - $this->drupalGet($fieldEditUrl); - - // Click on the widget settings button to open the widget settings form. - $this->drupalPostAjaxForm(NULL, [], $field_name . "_settings_edit"); - $xpathIncr = "//select[starts-with(@id, \"edit-fields-$field_name-settings-edit-form-settings-increment\")]"; - $this->assertNoFieldByXPath($xpathIncr, NULL, 'Increment element not found for Date Only.'); - - // Change the field is set to an all day field. - $this->fieldStorage->setSetting('datetime_type', DateRangeItem::DATETIME_TYPE_ALLDAY); - $this->fieldStorage->save(); - - // Change the widget to a datelist widget. - entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') - ->setComponent($field_name, [ - 'type' => 'daterange_datelist', - 'settings' => [ - 'date_order' => 'YMD', - ], - ]) - ->save(); - \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); - - // Display creation form. - $this->drupalGet('entity_test/add'); - - // Assert that Hour and Minute Elements do not appear on Date Only. - $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-hour\"]", NULL, 'Hour element not found on Date Only.'); - $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-minute\"]", NULL, 'Minute element not found on Date Only.'); - $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-end-value-hour\"]", NULL, 'Hour element not found on Date Only.'); - $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-end-value-minute\"]", NULL, 'Minute element not found on Date Only.'); - - // Go to the form display page to assert that increment option does not - // appear on Date Only. - $fieldEditUrl = 'entity_test/structure/entity_test/form-display'; - $this->drupalGet($fieldEditUrl); - - // Click on the widget settings button to open the widget settings form. - $this->drupalPostAjaxForm(NULL, [], $field_name . "_settings_edit"); - $xpathIncr = "//select[starts-with(@id, \"edit-fields-$field_name-settings-edit-form-settings-increment\")]"; - $this->assertNoFieldByXPath($xpathIncr, NULL, 'Increment element not found for Date Only.'); - - // Change the field to a datetime field. - $this->fieldStorage->setSetting('datetime_type', DateRangeItem::DATETIME_TYPE_DATETIME); - $this->fieldStorage->save(); - - // Change the widget to a datelist widget. - entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') - ->setComponent($field_name, [ - 'type' => 'daterange_datelist', - 'settings' => [ - 'increment' => 1, - 'date_order' => 'YMD', - 'time_type' => '12', - ], - ]) - ->save(); - \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); - - // Go to the form display page to assert that increment option does appear - // on Date Time. - $fieldEditUrl = 'entity_test/structure/entity_test/form-display'; - $this->drupalGet($fieldEditUrl); - - // Click on the widget settings button to open the widget settings form. - $this->drupalPostAjaxForm(NULL, [], $field_name . "_settings_edit"); - $this->assertFieldByXPath($xpathIncr, NULL, 'Increment element found for Date and time.'); - - // Display creation form. - $this->drupalGet('entity_test/add'); - - foreach (['value', 'end-value'] as $column) { - foreach (['year', 'month', 'day', 'hour', 'minute', 'ampm'] as $element) { - $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-$column-$element\"]", NULL, $element . ' element found.'); - $this->assertOptionSelected("edit-$field_name-0-$column-$element", '', 'No ' . $element . ' selected.'); - } - } - - // Submit a valid date and ensure it is accepted. - $start_date_value = ['year' => 2012, 'month' => 12, 'day' => 31, 'hour' => 5, 'minute' => 15]; - $end_date_value = ['year' => 2013, 'month' => 1, 'day' => 15, 'hour' => 3, 'minute' => 30]; - - $edit = []; - // Add the ampm indicator since we are testing 12 hour time. - $start_date_value['ampm'] = 'am'; - $end_date_value['ampm'] = 'pm'; - foreach ($start_date_value as $part => $value) { - $edit["{$field_name}[0][value][$part]"] = $value; - } - foreach ($end_date_value as $part => $value) { - $edit["{$field_name}[0][end_value][$part]"] = $value; - } - - $this->drupalPostForm(NULL, $edit, t('Save')); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); - - $this->assertOptionSelected("edit-$field_name-0-value-year", '2012', 'Correct year selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-month", '12', 'Correct month selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-day", '31', 'Correct day selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-hour", '5', 'Correct hour selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-minute", '15', 'Correct minute selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-ampm", 'am', 'Correct ampm selected.'); - - $this->assertOptionSelected("edit-$field_name-0-end-value-year", '2013', 'Correct year selected.'); - $this->assertOptionSelected("edit-$field_name-0-end-value-month", '1', 'Correct month selected.'); - $this->assertOptionSelected("edit-$field_name-0-end-value-day", '15', 'Correct day selected.'); - $this->assertOptionSelected("edit-$field_name-0-end-value-hour", '3', 'Correct hour selected.'); - $this->assertOptionSelected("edit-$field_name-0-end-value-minute", '30', 'Correct minute selected.'); - $this->assertOptionSelected("edit-$field_name-0-end-value-ampm", 'pm', 'Correct ampm selected.'); - - // Test the widget using increment other than 1 and 24 hour mode. - entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') - ->setComponent($field_name, [ - 'type' => 'daterange_datelist', - 'settings' => [ - 'increment' => 15, - 'date_order' => 'YMD', - 'time_type' => '24', - ], - ]) - ->save(); - \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); - - // Display creation form. - $this->drupalGet('entity_test/add'); - - // Other elements are unaffected by the changed settings. - $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-hour\"]", NULL, 'Hour element found.'); - $this->assertOptionSelected("edit-$field_name-0-value-hour", '', 'No hour selected.'); - $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-ampm\"]", NULL, 'AMPM element not found.'); - $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-end-value-hour\"]", NULL, 'Hour element found.'); - $this->assertOptionSelected("edit-$field_name-0-end-value-hour", '', 'No hour selected.'); - $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-end-value-ampm\"]", NULL, 'AMPM element not found.'); - - // Submit a valid date and ensure it is accepted. - $start_date_value = ['year' => 2012, 'month' => 12, 'day' => 31, 'hour' => 17, 'minute' => 15]; - $end_date_value = ['year' => 2013, 'month' => 1, 'day' => 15, 'hour' => 3, 'minute' => 30]; - - $edit = []; - foreach ($start_date_value as $part => $value) { - $edit["{$field_name}[0][value][$part]"] = $value; - } - foreach ($end_date_value as $part => $value) { - $edit["{$field_name}[0][end_value][$part]"] = $value; - } - - $this->drupalPostForm(NULL, $edit, t('Save')); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); - - $this->assertOptionSelected("edit-$field_name-0-value-year", '2012', 'Correct year selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-month", '12', 'Correct month selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-day", '31', 'Correct day selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-hour", '17', 'Correct hour selected.'); - $this->assertOptionSelected("edit-$field_name-0-value-minute", '15', 'Correct minute selected.'); - - $this->assertOptionSelected("edit-$field_name-0-end-value-year", '2013', 'Correct year selected.'); - $this->assertOptionSelected("edit-$field_name-0-end-value-month", '1', 'Correct month selected.'); - $this->assertOptionSelected("edit-$field_name-0-end-value-day", '15', 'Correct day selected.'); - $this->assertOptionSelected("edit-$field_name-0-end-value-hour", '3', 'Correct hour selected.'); - $this->assertOptionSelected("edit-$field_name-0-end-value-minute", '30', 'Correct minute selected.'); - - // Test the widget for partial completion of fields. - entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') - ->setComponent($field_name, [ - 'type' => 'daterange_datelist', - 'settings' => [ - 'increment' => 1, - 'date_order' => 'YMD', - 'time_type' => '24', - ], - ]) - ->save(); - \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); - - // Test the widget for validation notifications. - foreach ($this->datelistDataProvider() as $data) { - list($start_date_value, $end_date_value, $expected) = $data; - - // Display creation form. - $this->drupalGet('entity_test/add'); - - // Submit a partial date and ensure and error message is provided. - $edit = []; - foreach ($start_date_value as $part => $value) { - $edit["{$field_name}[0][value][$part]"] = $value; - } - foreach ($end_date_value as $part => $value) { - $edit["{$field_name}[0][end_value][$part]"] = $value; - } - - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertResponse(200); - foreach ($expected as $expected_text) { - $this->assertText(t($expected_text)); - } - } - - // Test the widget for complete input with zeros as part of selections. - $this->drupalGet('entity_test/add'); - - $start_date_value = ['year' => 2012, 'month' => 12, 'day' => 31, 'hour' => 0, 'minute' => 0]; - $end_date_value = ['year' => 2013, 'month' => 1, 'day' => 15, 'hour' => 3, 'minute' => 30]; - $edit = []; - foreach ($start_date_value as $part => $value) { - $edit["{$field_name}[0][value][$part]"] = $value; - } - foreach ($end_date_value as $part => $value) { - $edit["{$field_name}[0][end_value][$part]"] = $value; - } - - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertResponse(200); - preg_match('|entity_test/manage/(\d+)|', $this->url, $match); - $id = $match[1]; - $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); - - // Test the widget to ensure zeros are not deselected on validation. - $this->drupalGet('entity_test/add'); - - $start_date_value = ['year' => 2012, 'month' => 12, 'day' => 31, 'hour' => 0, 'minute' => 0]; - $end_date_value = ['year' => 2013, 'month' => 1, 'day' => 15, 'hour' => 3, 'minute' => 0]; - $edit = []; - foreach ($start_date_value as $part => $value) { - $edit["{$field_name}[0][value][$part]"] = $value; - } - foreach ($end_date_value as $part => $value) { - $edit["{$field_name}[0][end_value][$part]"] = $value; - } - - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertResponse(200); - $this->assertOptionSelected("edit-$field_name-0-value-minute", '0', 'Correct minute selected.'); - $this->assertOptionSelected("edit-$field_name-0-end-value-minute", '0', 'Correct minute selected.'); - } - - /** - * The data provider for testing the validation of the datelist widget. - * - * @return array - * An array of datelist input permutations to test. - */ - protected function datelistDataProvider() { - return [ - // Year only selected, validation error on Month, Day, Hour, Minute. - [ - ['year' => 2012, 'month' => '', 'day' => '', 'hour' => '', 'minute' => ''], - ['year' => 2013, 'month' => '1', 'day' => '15', 'hour' => '3', 'minute' => '30'], [ - 'A value must be selected for month.', - 'A value must be selected for day.', - 'A value must be selected for hour.', - 'A value must be selected for minute.', - ], - ], - // Year and Month selected, validation error on Day, Hour, Minute. - [ - ['year' => 2012, 'month' => '12', 'day' => '', 'hour' => '', 'minute' => ''], - ['year' => 2013, 'month' => '1', 'day' => '15', 'hour' => '3', 'minute' => '30'], [ - 'A value must be selected for day.', - 'A value must be selected for hour.', - 'A value must be selected for minute.', - ], - ], - // Year, Month and Day selected, validation error on Hour, Minute. - [ - ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '', 'minute' => ''], - ['year' => 2013, 'month' => '1', 'day' => '15', 'hour' => '3', 'minute' => '30'], [ - 'A value must be selected for hour.', - 'A value must be selected for minute.', - ], - ], - // Year, Month, Day and Hour selected, validation error on Minute only. - [ - ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '0', 'minute' => ''], - ['year' => 2013, 'month' => '1', 'day' => '15', 'hour' => '3', 'minute' => '30'], [ - 'A value must be selected for minute.', - ], - ], - // Year selected, validation error on Month, Day, Hour, Minute. - [ - ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '0', 'minute' => '0'], - ['year' => 2013, 'month' => '', 'day' => '', 'hour' => '', 'minute' => ''], [ - 'A value must be selected for month.', - 'A value must be selected for day.', - 'A value must be selected for hour.', - 'A value must be selected for minute.', - ], - ], - // Year and Month selected, validation error on Day, Hour, Minute. - [ - ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '0', 'minute' => '0'], - ['year' => 2013, 'month' => '1', 'day' => '', 'hour' => '', 'minute' => ''], [ - 'A value must be selected for day.', - 'A value must be selected for hour.', - 'A value must be selected for minute.', - ], - ], - // Year, Month and Day selected, validation error on Hour, Minute. - [ - ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '0', 'minute' => '0'], - ['year' => 2013, 'month' => '1', 'day' => '15', 'hour' => '', 'minute' => ''], [ - 'A value must be selected for hour.', - 'A value must be selected for minute.', - ], - ], - // Year, Month, Day and Hour selected, validation error on Minute only. - [ - ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '0', 'minute' => '0'], - ['year' => 2013, 'month' => '1', 'day' => '15', 'hour' => '3', 'minute' => ''], [ - 'A value must be selected for minute.', - ], - ], - ]; - } - - /** - * Test default value functionality. - */ - public function testDefaultValue() { - // Create a test content type. - $this->drupalCreateContentType(['type' => 'date_content']); - - // Create a field storage with settings to validate. - $field_name = Unicode::strtolower($this->randomMachineName()); - $field_storage = FieldStorageConfig::create([ - 'field_name' => $field_name, - 'entity_type' => 'node', - 'type' => 'daterange', - 'settings' => ['datetime_type' => DateRangeItem::DATETIME_TYPE_DATE], - ]); - $field_storage->save(); - - $field = FieldConfig::create([ - 'field_storage' => $field_storage, - 'bundle' => 'date_content', - ]); - $field->save(); - - // Set now as default_value. - $field_edit = [ - 'default_value_input[default_date_type]' => 'now', - 'default_value_input[default_end_date_type]' => 'now', - ]; - $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); - - // Check that default value is selected in default value form. - $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name); - $this->assertOptionSelected('edit-default-value-input-default-date-type', 'now', 'The default start value is selected in instance settings page'); - $this->assertFieldByName('default_value_input[default_date]', '', 'The relative start default value is empty in instance settings page'); - $this->assertOptionSelected('edit-default-value-input-default-end-date-type', 'now', 'The default end value is selected in instance settings page'); - $this->assertFieldByName('default_value_input[default_end_date]', '', 'The relative end default value is empty in instance settings page'); - - // Check if default_date has been stored successfully. - $config_entity = $this->config('field.field.node.date_content.' . $field_name)->get(); - $this->assertEqual($config_entity['default_value'][0], [ - 'default_date_type' => 'now', - 'default_date' => 'now', - 'default_end_date_type' => 'now', - 'default_end_date' => 'now', - ], 'Default value has been stored successfully'); - - // Clear field cache in order to avoid stale cache values. - \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); - - // Create a new node to check that datetime field default value is today. - $new_node = Node::create(['type' => 'date_content']); - $expected_date = new DrupalDateTime('now', DATETIME_STORAGE_TIMEZONE); - $this->assertEqual($new_node->get($field_name)->offsetGet(0)->value, $expected_date->format(DATETIME_DATE_STORAGE_FORMAT)); - $this->assertEqual($new_node->get($field_name)->offsetGet(0)->end_value, $expected_date->format(DATETIME_DATE_STORAGE_FORMAT)); - - // Set an invalid relative default_value to test validation. - $field_edit = [ - 'default_value_input[default_date_type]' => 'relative', - 'default_value_input[default_date]' => 'invalid date', - 'default_value_input[default_end_date_type]' => 'relative', - 'default_value_input[default_end_date]' => '+1 day', - ]; - $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); - $this->assertText('The relative start date value entered is invalid.'); - - $field_edit = [ - 'default_value_input[default_date_type]' => 'relative', - 'default_value_input[default_date]' => '+1 day', - 'default_value_input[default_end_date_type]' => 'relative', - 'default_value_input[default_end_date]' => 'invalid date', - ]; - $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); - $this->assertText('The relative end date value entered is invalid.'); - - // Set a relative default_value. - $field_edit = [ - 'default_value_input[default_date_type]' => 'relative', - 'default_value_input[default_date]' => '+45 days', - 'default_value_input[default_end_date_type]' => 'relative', - 'default_value_input[default_end_date]' => '+90 days', - ]; - $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); - - // Check that default value is selected in default value form. - $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name); - $this->assertOptionSelected('edit-default-value-input-default-date-type', 'relative', 'The default start value is selected in instance settings page'); - $this->assertFieldByName('default_value_input[default_date]', '+45 days', 'The relative default start value is displayed in instance settings page'); - $this->assertOptionSelected('edit-default-value-input-default-end-date-type', 'relative', 'The default end value is selected in instance settings page'); - $this->assertFieldByName('default_value_input[default_end_date]', '+90 days', 'The relative default end value is displayed in instance settings page'); - - // Check if default_date has been stored successfully. - $config_entity = $this->config('field.field.node.date_content.' . $field_name)->get(); - $this->assertEqual($config_entity['default_value'][0], [ - 'default_date_type' => 'relative', - 'default_date' => '+45 days', - 'default_end_date_type' => 'relative', - 'default_end_date' => '+90 days', - ], 'Default value has been stored successfully'); - - // Clear field cache in order to avoid stale cache values. - \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); - - // Create a new node to check that datetime field default value is +90 days. - $new_node = Node::create(['type' => 'date_content']); - $expected_start_date = new DrupalDateTime('+45 days', DATETIME_STORAGE_TIMEZONE); - $expected_end_date = new DrupalDateTime('+90 days', DATETIME_STORAGE_TIMEZONE); - $this->assertEqual($new_node->get($field_name)->offsetGet(0)->value, $expected_start_date->format(DATETIME_DATE_STORAGE_FORMAT)); - $this->assertEqual($new_node->get($field_name)->offsetGet(0)->end_value, $expected_end_date->format(DATETIME_DATE_STORAGE_FORMAT)); - - // Remove default value. - $field_edit = [ - 'default_value_input[default_date_type]' => '', - 'default_value_input[default_end_date_type]' => '', - ]; - $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); - - // Check that default value is selected in default value form. - $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name); - $this->assertOptionSelected('edit-default-value-input-default-date-type', '', 'The default start value is selected in instance settings page'); - $this->assertFieldByName('default_value_input[default_date]', '', 'The relative default start value is empty in instance settings page'); - $this->assertOptionSelected('edit-default-value-input-default-end-date-type', '', 'The default end value is selected in instance settings page'); - $this->assertFieldByName('default_value_input[default_end_date]', '', 'The relative default end value is empty in instance settings page'); - - // Check if default_date has been stored successfully. - $config_entity = $this->config('field.field.node.date_content.' . $field_name)->get(); - $this->assertTrue(empty($config_entity['default_value']), 'Empty default value has been stored successfully'); - - // Clear field cache in order to avoid stale cache values. - \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); - - // Create a new node to check that datetime field default value is not set. - $new_node = Node::create(['type' => 'date_content']); - $this->assertNull($new_node->get($field_name)->value, 'Default value is not set'); - - // Set now as default_value for start date only. - entity_get_form_display('node', 'date_content', 'default') - ->setComponent($field_name, [ - 'type' => 'datetime_default', - ]) - ->save(); - - $expected_date = new DrupalDateTime('now', DATETIME_STORAGE_TIMEZONE); - - $field_edit = [ - 'default_value_input[default_date_type]' => 'now', - 'default_value_input[default_end_date_type]' => '', - ]; - $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); - - // Make sure only the start value is populated on node add page. - $this->drupalGet('node/add/date_content'); - $this->assertFieldByName("{$field_name}[0][value][date]", $expected_date->format(DATETIME_DATE_STORAGE_FORMAT), 'Start date element populated.'); - $this->assertFieldByName("{$field_name}[0][end_value][date]", '', 'End date element empty.'); - - // Set now as default_value for end date only. - $field_edit = [ - 'default_value_input[default_date_type]' => '', - 'default_value_input[default_end_date_type]' => 'now', - ]; - $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); - - // Make sure only the start value is populated on node add page. - $this->drupalGet('node/add/date_content'); - $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Start date element empty.'); - $this->assertFieldByName("{$field_name}[0][end_value][date]", $expected_date->format(DATETIME_DATE_STORAGE_FORMAT), 'End date element populated.'); - } - - /** - * Test that invalid values are caught and marked as invalid. - */ - public function testInvalidField() { - // Change the field to a datetime field. - $this->fieldStorage->setSetting('datetime_type', DateRangeItem::DATETIME_TYPE_DATETIME); - $this->fieldStorage->save(); - $field_name = $this->fieldStorage->getName(); - - $this->drupalGet('entity_test/add'); - $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Start date element found.'); - $this->assertFieldByName("{$field_name}[0][value][time]", '', 'Start time element found.'); - $this->assertFieldByName("{$field_name}[0][end_value][date]", '', 'End date element found.'); - $this->assertFieldByName("{$field_name}[0][end_value][time]", '', 'End time element found.'); - - // Submit invalid start dates and ensure they is not accepted. - $date_value = ''; - $edit = [ - "{$field_name}[0][value][date]" => $date_value, - "{$field_name}[0][value][time]" => '12:00:00', - "{$field_name}[0][end_value][date]" => '2012-12-01', - "{$field_name}[0][end_value][time]" => '12:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', 'Empty start date value has been caught.'); - - $date_value = 'aaaa-12-01'; - $edit = [ - "{$field_name}[0][value][date]" => $date_value, - "{$field_name}[0][value][time]" => '00:00:00', - "{$field_name}[0][end_value][date]" => '2012-12-01', - "{$field_name}[0][end_value][time]" => '12:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', new FormattableMarkup('Invalid start year value %date has been caught.', ['%date' => $date_value])); - - $date_value = '2012-75-01'; - $edit = [ - "{$field_name}[0][value][date]" => $date_value, - "{$field_name}[0][value][time]" => '00:00:00', - "{$field_name}[0][end_value][date]" => '2012-12-01', - "{$field_name}[0][end_value][time]" => '12:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', new FormattableMarkup('Invalid start month value %date has been caught.', ['%date' => $date_value])); - - $date_value = '2012-12-99'; - $edit = [ - "{$field_name}[0][value][date]" => $date_value, - "{$field_name}[0][value][time]" => '00:00:00', - "{$field_name}[0][end_value][date]" => '2012-12-01', - "{$field_name}[0][end_value][time]" => '12:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', new FormattableMarkup('Invalid start day value %date has been caught.', ['%date' => $date_value])); - - // Submit invalid start times and ensure they is not accepted. - $time_value = ''; - $edit = [ - "{$field_name}[0][value][date]" => '2012-12-01', - "{$field_name}[0][value][time]" => $time_value, - "{$field_name}[0][end_value][date]" => '2012-12-01', - "{$field_name}[0][end_value][time]" => '12:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', 'Empty start time value has been caught.'); - - $time_value = '49:00:00'; - $edit = [ - "{$field_name}[0][value][date]" => '2012-12-01', - "{$field_name}[0][value][time]" => $time_value, - "{$field_name}[0][end_value][date]" => '2012-12-01', - "{$field_name}[0][end_value][time]" => '12:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', new FormattableMarkup('Invalid start hour value %time has been caught.', ['%time' => $time_value])); - - $time_value = '12:99:00'; - $edit = [ - "{$field_name}[0][value][date]" => '2012-12-01', - "{$field_name}[0][value][time]" => $time_value, - "{$field_name}[0][end_value][date]" => '2012-12-01', - "{$field_name}[0][end_value][time]" => '12:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', new FormattableMarkup('Invalid start minute value %time has been caught.', ['%time' => $time_value])); - - $time_value = '12:15:99'; - $edit = [ - "{$field_name}[0][value][date]" => '2012-12-01', - "{$field_name}[0][value][time]" => $time_value, - "{$field_name}[0][end_value][date]" => '2012-12-01', - "{$field_name}[0][end_value][time]" => '12:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', new FormattableMarkup('Invalid start second value %time has been caught.', ['%time' => $time_value])); - - // Submit invalid end dates and ensure they is not accepted. - $date_value = ''; - $edit = [ - "{$field_name}[0][value][date]" => '2012-12-01', - "{$field_name}[0][value][time]" => '12:00:00', - "{$field_name}[0][end_value][date]" => $date_value, - "{$field_name}[0][end_value][time]" => '12:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', 'Empty end date value has been caught.'); - - $date_value = 'aaaa-12-01'; - $edit = [ - "{$field_name}[0][value][date]" => '2012-12-01', - "{$field_name}[0][value][time]" => '12:00:00', - "{$field_name}[0][end_value][date]" => $date_value, - "{$field_name}[0][end_value][time]" => '00:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', new FormattableMarkup('Invalid end year value %date has been caught.', ['%date' => $date_value])); - - $date_value = '2012-75-01'; - $edit = [ - "{$field_name}[0][value][date]" => '2012-12-01', - "{$field_name}[0][value][time]" => '12:00:00', - "{$field_name}[0][end_value][date]" => $date_value, - "{$field_name}[0][end_value][time]" => '00:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', new FormattableMarkup('Invalid end month value %date has been caught.', ['%date' => $date_value])); - - $date_value = '2012-12-99'; - $edit = [ - "{$field_name}[0][value][date]" => '2012-12-01', - "{$field_name}[0][value][time]" => '12:00:00', - "{$field_name}[0][end_value][date]" => $date_value, - "{$field_name}[0][end_value][time]" => '00:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', new FormattableMarkup('Invalid end day value %date has been caught.', ['%date' => $date_value])); - - // Submit invalid start times and ensure they is not accepted. - $time_value = ''; - $edit = [ - "{$field_name}[0][value][date]" => '2012-12-01', - "{$field_name}[0][value][time]" => '12:00:00', - "{$field_name}[0][end_value][date]" => '2012-12-01', - "{$field_name}[0][end_value][time]" => $time_value, - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', 'Empty end time value has been caught.'); - - $time_value = '49:00:00'; - $edit = [ - "{$field_name}[0][value][date]" => '2012-12-01', - "{$field_name}[0][value][time]" => '12:00:00', - "{$field_name}[0][end_value][date]" => '2012-12-01', - "{$field_name}[0][end_value][time]" => $time_value, - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', new FormattableMarkup('Invalid end hour value %time has been caught.', ['%time' => $time_value])); - - $time_value = '12:99:00'; - $edit = [ - "{$field_name}[0][value][date]" => '2012-12-01', - "{$field_name}[0][value][time]" => '12:00:00', - "{$field_name}[0][end_value][date]" => '2012-12-01', - "{$field_name}[0][end_value][time]" => $time_value, - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', new FormattableMarkup('Invalid end minute value %time has been caught.', ['%time' => $time_value])); - - $time_value = '12:15:99'; - $edit = [ - "{$field_name}[0][value][date]" => '2012-12-01', - "{$field_name}[0][value][time]" => '12:00:00', - "{$field_name}[0][end_value][date]" => '2012-12-01', - "{$field_name}[0][end_value][time]" => $time_value, - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText('date is invalid', new FormattableMarkup('Invalid end second value %time has been caught.', ['%time' => $time_value])); - - $edit = [ - "{$field_name}[0][value][date]" => '2012-12-01', - "{$field_name}[0][value][time]" => '12:00:00', - "{$field_name}[0][end_value][date]" => '2010-12-01', - "{$field_name}[0][end_value][time]" => '12:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText(new FormattableMarkup('The @title end date cannot be before the start date', ['@title' => $field_name]), 'End date before start date has been caught.'); - - $edit = [ - "{$field_name}[0][value][date]" => '2012-12-01', - "{$field_name}[0][value][time]" => '12:00:00', - "{$field_name}[0][end_value][date]" => '2012-12-01', - "{$field_name}[0][end_value][time]" => '11:00:00', - ]; - $this->drupalPostForm(NULL, $edit, t('Save')); - $this->assertText(new FormattableMarkup('The @title end date cannot be before the start date', ['@title' => $field_name]), 'End time before start time has been caught.'); - } - - /** - * Tests that 'Date' field storage setting form is disabled if field has data. - */ - public function testDateStorageSettings() { - // Create a test content type. - $this->drupalCreateContentType(['type' => 'date_content']); - - // Create a field storage with settings to validate. - $field_name = Unicode::strtolower($this->randomMachineName()); - $field_storage = FieldStorageConfig::create([ - 'field_name' => $field_name, - 'entity_type' => 'node', - 'type' => 'daterange', - 'settings' => [ - 'datetime_type' => DateRangeItem::DATETIME_TYPE_DATE, - ], - ]); - $field_storage->save(); - $field = FieldConfig::create([ - 'field_storage' => $field_storage, - 'field_name' => $field_name, - 'bundle' => 'date_content', - ]); - $field->save(); - - entity_get_form_display('node', 'date_content', 'default') - ->setComponent($field_name, [ - 'type' => 'datetime_default', - ]) - ->save(); - $edit = [ - 'title[0][value]' => $this->randomString(), - 'body[0][value]' => $this->randomString(), - $field_name . '[0][value][date]' => '2016-04-01', - $field_name . '[0][end_value][date]' => '2016-04-02', - ]; - $this->drupalPostForm('node/add/date_content', $edit, t('Save')); - $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name . '/storage'); - $result = $this->xpath("//*[@id='edit-settings-datetime-type' and contains(@disabled, 'disabled')]"); - $this->assertEqual(count($result), 1, "Changing datetime setting is disabled."); - $this->assertText('There is data for this field in the database. The field settings can no longer be changed.'); - } - -} diff --git a/core/modules/datetime_range/tests/src/Functional/DateRangeFieldTest.php b/core/modules/datetime_range/tests/src/Functional/DateRangeFieldTest.php new file mode 100644 index 0000000..23ba8fa --- /dev/null +++ b/core/modules/datetime_range/tests/src/Functional/DateRangeFieldTest.php @@ -0,0 +1,1357 @@ + '', 'separator' => '-']; + + /** + * {@inheritdoc} + */ + protected function getTestFieldType() { + return 'daterange'; + } + + /** + * Tests date field functionality. + */ + public function testDateRangeField() { + $field_name = $this->fieldStorage->getName(); + + // Loop through defined timezones to test that date-only fields work at the + // extremes. + foreach (static::$timezones as $timezone) { + + $this->setSiteTimezone($timezone); + + // Ensure field is set to a date-only field. + $this->fieldStorage->setSetting('datetime_type', DateRangeItem::DATETIME_TYPE_DATE); + $this->fieldStorage->save(); + + // Display creation form. + $this->drupalGet('entity_test/add'); + $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Start date element found.'); + $this->assertFieldByName("{$field_name}[0][end_value][date]", '', 'End date element found.'); + $this->assertFieldByXPath('//*[@id="edit-' . $field_name . '-wrapper"]//label[contains(@class, "js-form-required")]', TRUE, 'Required markup found'); + $this->assertNoFieldByName("{$field_name}[0][value][time]", '', 'Start time element not found.'); + $this->assertNoFieldByName("{$field_name}[0][end_value][time]", '', 'End time element not found.'); + $this->assertFieldByXPath('//fieldset[@id="edit-' . $field_name . '-0"]/legend', $field_name, 'Fieldset and label found'); + $this->assertFieldByXPath('//fieldset[@aria-describedby="edit-' . $field_name . '-0--description"]', NULL, 'ARIA described-by found'); + $this->assertFieldByXPath('//div[@id="edit-' . $field_name . '-0--description"]', NULL, 'ARIA description found'); + + // Build up dates in the UTC timezone. + $value = '2012-12-31 00:00:00'; + $start_date = new DrupalDateTime($value, 'UTC'); + $end_value = '2013-06-06 00:00:00'; + $end_date = new DrupalDateTime($end_value, 'UTC'); + + // Submit a valid date and ensure it is accepted. + $date_format = DateFormat::load('html_date')->getPattern(); + $time_format = DateFormat::load('html_time')->getPattern(); + + $edit = [ + "{$field_name}[0][value][date]" => $start_date->format($date_format), + "{$field_name}[0][end_value][date]" => $end_date->format($date_format), + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + $this->assertRaw($start_date->format($date_format)); + $this->assertNoRaw($start_date->format($time_format)); + $this->assertRaw($end_date->format($date_format)); + $this->assertNoRaw($end_date->format($time_format)); + + // Verify the date doesn't change when entity is edited through the form. + $entity = EntityTest::load($id); + $this->assertEqual('2012-12-31', $entity->{$field_name}->value); + $this->assertEqual('2013-06-06', $entity->{$field_name}->end_value); + $this->drupalGet('entity_test/manage/' . $id . '/edit'); + $this->drupalPostForm(NULL, [], t('Save')); + $this->drupalGet('entity_test/manage/' . $id . '/edit'); + $this->drupalPostForm(NULL, [], t('Save')); + $this->drupalGet('entity_test/manage/' . $id . '/edit'); + $this->drupalPostForm(NULL, [], t('Save')); + $entity = EntityTest::load($id); + $this->assertEqual('2012-12-31', $entity->{$field_name}->value); + $this->assertEqual('2013-06-06', $entity->{$field_name}->end_value); + + // Formats that display a time component for date-only fields will display + // the default time, so that is applied before calculating the expected + // value. + datetime_date_default_time($start_date); + datetime_date_default_time($end_date); + + // Reset display options since these get changed below. + $this->displayOptions = [ + 'type' => 'daterange_default', + 'label' => 'hidden', + 'settings' => [ + 'format_type' => 'long', + 'separator' => 'THESEPARATOR', + ] + $this->defaultSettings, + ]; + + // Verify that the default formatter works. + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + + $start_expected = $this->dateFormatter->format($start_date->getTimestamp(), 'long', '', DATETIME_STORAGE_TIMEZONE); + $start_expected_iso = $this->dateFormatter->format($start_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', DATETIME_STORAGE_TIMEZONE); + $start_expected_markup = ''; + $end_expected = $this->dateFormatter->format($end_date->getTimestamp(), 'long', '', DATETIME_STORAGE_TIMEZONE); + $end_expected_iso = $this->dateFormatter->format($end_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', DATETIME_STORAGE_TIMEZONE); + $end_expected_markup = ''; + $output = $this->renderTestEntity($id); + $this->assertContains($start_expected_markup, $output, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', [ + '%value' => 'long', + '%expected' => $start_expected, + '%expected_iso' => $start_expected_iso, + ])); + $this->assertContains($end_expected_markup, $output, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', [ + '%value' => 'long', + '%expected' => $end_expected, + '%expected_iso' => $end_expected_iso, + ])); + $this->assertContains(' THESEPARATOR ', $output, 'Found proper separator'); + + // Verify that hook_entity_prepare_view can add attributes. + // @see entity_test_entity_prepare_view() + $this->drupalGet('entity_test/' . $id); + $this->assertFieldByXPath('//div[@data-field-item-attr="foobar"]'); + + // Verify that the plain formatter works. + $this->displayOptions['type'] = 'daterange_plain'; + $this->displayOptions['settings'] = $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $start_date->format(DATETIME_DATE_STORAGE_FORMAT) . ' - ' . $end_date->format(DATETIME_DATE_STORAGE_FORMAT); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); + + // Verify that the custom formatter works. + $this->displayOptions['type'] = 'daterange_custom'; + $this->displayOptions['settings'] = ['date_format' => 'm/d/Y'] + $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $start_date->format($this->displayOptions['settings']['date_format']) . ' - ' . $end_date->format($this->displayOptions['settings']['date_format']); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); + + // Test that allowed markup in custom format is preserved and XSS is + // removed. + $this->displayOptions['settings']['date_format'] = '\\<\\s\\t\\r\\o\\n\\g\\>m/d/Y\\<\\/\\s\\t\\r\\o\\n\\g\\>\\<\\s\\c\\r\\i\\p\\t\\>\\a\\l\\e\\r\\t\\(\\S\\t\\r\\i\\n\\g\\.\\f\\r\\o\\m\\C\\h\\a\\r\\C\\o\\d\\e\\(\\8\\8\\,\\8\\3\\,\\8\\3\\)\\)\\<\\/\\s\\c\\r\\i\\p\\t\\>'; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = '' . $start_date->format('m/d/Y') . 'alert(String.fromCharCode(88,83,83)) - ' . $end_date->format('m/d/Y') . 'alert(String.fromCharCode(88,83,83))'; + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); + + // Test formatters when start date and end date are the same + $this->drupalGet('entity_test/add'); + $value = '2012-12-31 00:00:00'; + $start_date = new DrupalDateTime($value, 'UTC'); + + $date_format = DateFormat::load('html_date')->getPattern(); + $time_format = DateFormat::load('html_time')->getPattern(); + + $edit = [ + "{$field_name}[0][value][date]" => $start_date->format($date_format), + "{$field_name}[0][end_value][date]" => $start_date->format($date_format), + ]; + + $this->drupalPostForm(NULL, $edit, t('Save')); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + + datetime_date_default_time($start_date); + + $this->displayOptions = [ + 'type' => 'daterange_default', + 'label' => 'hidden', + 'settings' => [ + 'format_type' => 'long', + 'separator' => 'THESEPARATOR', + ] + $this->defaultSettings, + ]; + + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + + $start_expected = $this->dateFormatter->format($start_date->getTimestamp(), 'long', '', DATETIME_STORAGE_TIMEZONE); + $start_expected_iso = $this->dateFormatter->format($start_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', DATETIME_STORAGE_TIMEZONE); + $start_expected_markup = ''; + $output = $this->renderTestEntity($id); + $this->assertContains($start_expected_markup, $output, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', [ + '%value' => 'long', + '%expected' => $start_expected, + '%expected_iso' => $start_expected_iso, + ])); + $this->assertNotContains(' THESEPARATOR ', $output, 'Separator not found on page'); + + // Verify that hook_entity_prepare_view can add attributes. + // @see entity_test_entity_prepare_view() + $this->drupalGet('entity_test/' . $id); + $this->assertFieldByXPath('//time[@data-field-item-attr="foobar"]'); + + $this->displayOptions['type'] = 'daterange_plain'; + $this->displayOptions['settings'] = $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $start_date->format(DATETIME_DATE_STORAGE_FORMAT); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); + $this->assertNotContains(' THESEPARATOR ', $output, 'Separator not found on page'); + + $this->displayOptions['type'] = 'daterange_custom'; + $this->displayOptions['settings'] = ['date_format' => 'm/d/Y'] + $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $start_date->format($this->displayOptions['settings']['date_format']); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); + $this->assertNotContains(' THESEPARATOR ', $output, 'Separator not found on page'); + } + } + + /** + * Tests date and time field. + */ + public function testDatetimeRangeField() { + $field_name = $this->fieldStorage->getName(); + + // Ensure the field to a datetime field. + $this->fieldStorage->setSetting('datetime_type', DateRangeItem::DATETIME_TYPE_DATETIME); + $this->fieldStorage->save(); + + // Display creation form. + $this->drupalGet('entity_test/add'); + $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Start date element found.'); + $this->assertFieldByName("{$field_name}[0][value][time]", '', 'Start time element found.'); + $this->assertFieldByName("{$field_name}[0][end_value][date]", '', 'End date element found.'); + $this->assertFieldByName("{$field_name}[0][end_value][time]", '', 'End time element found.'); + $this->assertFieldByXPath('//fieldset[@id="edit-' . $field_name . '-0"]/legend', $field_name, 'Fieldset and label found'); + $this->assertFieldByXPath('//fieldset[@aria-describedby="edit-' . $field_name . '-0--description"]', NULL, 'ARIA described-by found'); + $this->assertFieldByXPath('//div[@id="edit-' . $field_name . '-0--description"]', NULL, 'ARIA description found'); + + // Build up dates in the UTC timezone. + $value = '2012-12-31 00:00:00'; + $start_date = new DrupalDateTime($value, 'UTC'); + $end_value = '2013-06-06 00:00:00'; + $end_date = new DrupalDateTime($end_value, 'UTC'); + + // Update the timezone to the system default. + $start_date->setTimezone(timezone_open(drupal_get_user_timezone())); + $end_date->setTimezone(timezone_open(drupal_get_user_timezone())); + + // Submit a valid date and ensure it is accepted. + $date_format = DateFormat::load('html_date')->getPattern(); + $time_format = DateFormat::load('html_time')->getPattern(); + + $edit = [ + "{$field_name}[0][value][date]" => $start_date->format($date_format), + "{$field_name}[0][value][time]" => $start_date->format($time_format), + "{$field_name}[0][end_value][date]" => $end_date->format($date_format), + "{$field_name}[0][end_value][time]" => $end_date->format($time_format), + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + $this->assertRaw($start_date->format($date_format)); + $this->assertRaw($start_date->format($time_format)); + $this->assertRaw($end_date->format($date_format)); + $this->assertRaw($end_date->format($time_format)); + + // Verify that the default formatter works. + $this->displayOptions['settings'] = [ + 'format_type' => 'long', + 'separator' => 'THESEPARATOR', + ] + $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + + $start_expected = $this->dateFormatter->format($start_date->getTimestamp(), 'long'); + $start_expected_iso = $this->dateFormatter->format($start_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); + $start_expected_markup = ''; + $end_expected = $this->dateFormatter->format($end_date->getTimestamp(), 'long'); + $end_expected_iso = $this->dateFormatter->format($end_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); + $end_expected_markup = ''; + $output = $this->renderTestEntity($id); + $this->assertContains($start_expected_markup, $output, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => 'long', '%expected' => $start_expected, '%expected_iso' => $start_expected_iso])); + $this->assertContains($end_expected_markup, $output, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => 'long', '%expected' => $end_expected, '%expected_iso' => $end_expected_iso])); + $this->assertContains(' THESEPARATOR ', $output, 'Found proper separator'); + + // Verify that hook_entity_prepare_view can add attributes. + // @see entity_test_entity_prepare_view() + $this->drupalGet('entity_test/' . $id); + $this->assertFieldByXPath('//div[@data-field-item-attr="foobar"]'); + + // Verify that the plain formatter works. + $this->displayOptions['type'] = 'daterange_plain'; + $this->displayOptions['settings'] = $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $start_date->format(DATETIME_DATETIME_STORAGE_FORMAT) . ' - ' . $end_date->format(DATETIME_DATETIME_STORAGE_FORMAT); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); + + // Verify that the 'datetime_custom' formatter works. + $this->displayOptions['type'] = 'daterange_custom'; + $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A'] + $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $start_date->format($this->displayOptions['settings']['date_format']) . ' - ' . $end_date->format($this->displayOptions['settings']['date_format']); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); + + // Verify that the 'timezone_override' setting works. + $this->displayOptions['type'] = 'daterange_custom'; + $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A', 'timezone_override' => 'America/New_York'] + $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $start_date->format($this->displayOptions['settings']['date_format'], ['timezone' => 'America/New_York']); + $expected .= ' - ' . $end_date->format($this->displayOptions['settings']['date_format'], ['timezone' => 'America/New_York']); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); + + // Test formatters when start date and end date are the same + $this->drupalGet('entity_test/add'); + $value = '2012-12-31 00:00:00'; + $start_date = new DrupalDateTime($value, 'UTC'); + $start_date->setTimezone(timezone_open(drupal_get_user_timezone())); + + $date_format = DateFormat::load('html_date')->getPattern(); + $time_format = DateFormat::load('html_time')->getPattern(); + + $edit = [ + "{$field_name}[0][value][date]" => $start_date->format($date_format), + "{$field_name}[0][value][time]" => $start_date->format($time_format), + "{$field_name}[0][end_value][date]" => $start_date->format($date_format), + "{$field_name}[0][end_value][time]" => $start_date->format($time_format), + ]; + + $this->drupalPostForm(NULL, $edit, t('Save')); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + + $this->displayOptions = [ + 'type' => 'daterange_default', + 'label' => 'hidden', + 'settings' => [ + 'format_type' => 'long', + 'separator' => 'THESEPARATOR', + ] + $this->defaultSettings, + ]; + + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + + $start_expected = $this->dateFormatter->format($start_date->getTimestamp(), 'long'); + $start_expected_iso = $this->dateFormatter->format($start_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); + $start_expected_markup = ''; + $output = $this->renderTestEntity($id); + $this->assertContains($start_expected_markup, $output, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => 'long', '%expected' => $start_expected, '%expected_iso' => $start_expected_iso])); + $this->assertNotContains(' THESEPARATOR ', $output, 'Separator not found on page'); + + // Verify that hook_entity_prepare_view can add attributes. + // @see entity_test_entity_prepare_view() + $this->drupalGet('entity_test/' . $id); + $this->assertFieldByXPath('//time[@data-field-item-attr="foobar"]'); + + $this->displayOptions['type'] = 'daterange_plain'; + $this->displayOptions['settings'] = $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $start_date->format(DATETIME_DATETIME_STORAGE_FORMAT); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); + $this->assertNotContains(' THESEPARATOR ', $output, 'Separator not found on page'); + + $this->displayOptions['type'] = 'daterange_custom'; + $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A'] + $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $start_date->format($this->displayOptions['settings']['date_format']); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); + $this->assertNotContains(' THESEPARATOR ', $output, 'Separator not found on page'); + } + + /** + * Tests all-day field. + */ + public function testAlldayRangeField() { + $field_name = $this->fieldStorage->getName(); + + // Ensure field is set to a all-day field. + $this->fieldStorage->setSetting('datetime_type', DateRangeItem::DATETIME_TYPE_ALLDAY); + $this->fieldStorage->save(); + + // Display creation form. + $this->drupalGet('entity_test/add'); + $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Start date element found.'); + $this->assertFieldByName("{$field_name}[0][end_value][date]", '', 'End date element found.'); + $this->assertFieldByXPath('//*[@id="edit-' . $field_name . '-wrapper"]//label[contains(@class, "js-form-required")]', TRUE, 'Required markup found'); + $this->assertNoFieldByName("{$field_name}[0][value][time]", '', 'Start time element not found.'); + $this->assertNoFieldByName("{$field_name}[0][end_value][time]", '', 'End time element not found.'); + $this->assertFieldByXPath('//fieldset[@id="edit-' . $field_name . '-0"]/legend', $field_name, 'Fieldset and label found'); + $this->assertFieldByXPath('//fieldset[@aria-describedby="edit-' . $field_name . '-0--description"]', NULL, 'ARIA described-by found'); + $this->assertFieldByXPath('//div[@id="edit-' . $field_name . '-0--description"]', NULL, 'ARIA description found'); + + // Build up dates in the proper timezone. + $value = '2012-12-31 00:00:00'; + $start_date = new DrupalDateTime($value, timezone_open(drupal_get_user_timezone())); + $end_value = '2013-06-06 23:59:59'; + $end_date = new DrupalDateTime($end_value, timezone_open(drupal_get_user_timezone())); + + // Submit a valid date and ensure it is accepted. + $date_format = DateFormat::load('html_date')->getPattern(); + $time_format = DateFormat::load('html_time')->getPattern(); + + $edit = [ + "{$field_name}[0][value][date]" => $start_date->format($date_format), + "{$field_name}[0][end_value][date]" => $end_date->format($date_format), + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + $this->assertRaw($start_date->format($date_format)); + $this->assertNoRaw($start_date->format($time_format)); + $this->assertRaw($end_date->format($date_format)); + $this->assertNoRaw($end_date->format($time_format)); + + // Verify that the default formatter works. + $this->displayOptions['settings'] = [ + 'format_type' => 'long', + 'separator' => 'THESEPARATOR', + ] + $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + + $start_expected = $this->dateFormatter->format($start_date->getTimestamp(), 'long'); + $start_expected_iso = $this->dateFormatter->format($start_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); + $start_expected_markup = ''; + $end_expected = $this->dateFormatter->format($end_date->getTimestamp(), 'long'); + $end_expected_iso = $this->dateFormatter->format($end_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); + $end_expected_markup = ''; + $output = $this->renderTestEntity($id); + $this->assertContains($start_expected_markup, $output, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => 'long', '%expected' => $start_expected, '%expected_iso' => $start_expected_iso])); + $this->assertContains($end_expected_markup, $output, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => 'long', '%expected' => $end_expected, '%expected_iso' => $end_expected_iso])); + $this->assertContains(' THESEPARATOR ', $output, 'Found proper separator'); + + // Verify that hook_entity_prepare_view can add attributes. + // @see entity_test_entity_prepare_view() + $this->drupalGet('entity_test/' . $id); + $this->assertFieldByXPath('//div[@data-field-item-attr="foobar"]'); + + // Verify that the plain formatter works. + $this->displayOptions['type'] = 'daterange_plain'; + $this->displayOptions['settings'] = $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $start_date->format(DATETIME_DATETIME_STORAGE_FORMAT) . ' - ' . $end_date->format(DATETIME_DATETIME_STORAGE_FORMAT); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); + + // Verify that the custom formatter works. + $this->displayOptions['type'] = 'daterange_custom'; + $this->displayOptions['settings'] = ['date_format' => 'm/d/Y'] + $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $start_date->format($this->displayOptions['settings']['date_format']) . ' - ' . $end_date->format($this->displayOptions['settings']['date_format']); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); + + // Verify that the 'timezone_override' setting works. + $this->displayOptions['type'] = 'daterange_custom'; + $this->displayOptions['settings'] = ['date_format' => 'm/d/Y g:i:s A', 'timezone_override' => 'America/New_York'] + $this->defaultSettings; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $start_date->format($this->displayOptions['settings']['date_format'], ['timezone' => 'America/New_York']); + $expected .= ' - ' . $end_date->format($this->displayOptions['settings']['date_format'], ['timezone' => 'America/New_York']); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); + + // Test formatters when start date and end date are the same + $this->drupalGet('entity_test/add'); + + $value = '2012-12-31 00:00:00'; + $start_date = new DrupalDateTime($value, timezone_open(drupal_get_user_timezone())); + $end_value = '2012-12-31 23:59:59'; + $end_date = new DrupalDateTime($end_value, timezone_open(drupal_get_user_timezone())); + + $date_format = DateFormat::load('html_date')->getPattern(); + $time_format = DateFormat::load('html_time')->getPattern(); + + $edit = [ + "{$field_name}[0][value][date]" => $start_date->format($date_format), + "{$field_name}[0][end_value][date]" => $start_date->format($date_format), + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + + $this->displayOptions = [ + 'type' => 'daterange_default', + 'label' => 'hidden', + 'settings' => [ + 'format_type' => 'long', + 'separator' => 'THESEPARATOR', + ] + $this->defaultSettings, + ]; + + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + + $start_expected = $this->dateFormatter->format($start_date->getTimestamp(), 'long'); + $start_expected_iso = $this->dateFormatter->format($start_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); + $start_expected_markup = ''; + $end_expected = $this->dateFormatter->format($end_date->getTimestamp(), 'long'); + $end_expected_iso = $this->dateFormatter->format($end_date->getTimestamp(), 'custom', 'Y-m-d\TH:i:s\Z', 'UTC'); + $end_expected_markup = ''; + $output = $this->renderTestEntity($id); + $this->assertContains($start_expected_markup, $output, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => 'long', '%expected' => $start_expected, '%expected_iso' => $start_expected_iso])); + $this->assertContains($end_expected_markup, $output, new FormattableMarkup('Formatted date field using %value format displayed as %expected with %expected_iso attribute.', ['%value' => 'long', '%expected' => $end_expected, '%expected_iso' => $end_expected_iso])); + $this->assertContains(' THESEPARATOR ', $output, 'Found proper separator'); + + // Verify that hook_entity_prepare_view can add attributes. + // @see entity_test_entity_prepare_view() + $this->drupalGet('entity_test/' . $id); + $this->assertFieldByXPath('//div[@data-field-item-attr="foobar"]'); + + $this->displayOptions['type'] = 'daterange_plain'; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $start_date->format(DATETIME_DATETIME_STORAGE_FORMAT) . ' THESEPARATOR ' . $end_date->format(DATETIME_DATETIME_STORAGE_FORMAT); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using plain format displayed as %expected.', ['%expected' => $expected])); + $this->assertContains(' THESEPARATOR ', $output, 'Found proper separator'); + + $this->displayOptions['type'] = 'daterange_custom'; + $this->displayOptions['settings']['date_format'] = 'm/d/Y'; + entity_get_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') + ->setComponent($field_name, $this->displayOptions) + ->save(); + $expected = $start_date->format($this->displayOptions['settings']['date_format']) . ' THESEPARATOR ' . $end_date->format($this->displayOptions['settings']['date_format']); + $output = $this->renderTestEntity($id); + $this->assertContains($expected, $output, new FormattableMarkup('Formatted date field using daterange_custom format displayed as %expected.', ['%expected' => $expected])); + $this->assertContains(' THESEPARATOR ', $output, 'Found proper separator'); + + } + + /** + * Tests Date Range List Widget functionality. + */ + public function testDatelistWidget() { + $field_name = $this->fieldStorage->getName(); + + // Ensure field is set to a date only field. + $this->fieldStorage->setSetting('datetime_type', DateRangeItem::DATETIME_TYPE_DATE); + $this->fieldStorage->save(); + + // Change the widget to a datelist widget. + entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') + ->setComponent($field_name, [ + 'type' => 'daterange_datelist', + 'settings' => [ + 'date_order' => 'YMD', + ], + ]) + ->save(); + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); + + // Display creation form. + $this->drupalGet('entity_test/add'); + $this->assertFieldByXPath('//fieldset[@id="edit-' . $field_name . '-0"]/legend', $field_name, 'Fieldset and label found'); + $this->assertFieldByXPath('//fieldset[@aria-describedby="edit-' . $field_name . '-0--description"]', NULL, 'ARIA described-by found'); + $this->assertFieldByXPath('//div[@id="edit-' . $field_name . '-0--description"]', NULL, 'ARIA description found'); + + // Assert that Hour and Minute Elements do not appear on Date Only. + $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-hour\"]", NULL, 'Hour element not found on Date Only.'); + $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-minute\"]", NULL, 'Minute element not found on Date Only.'); + $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-end-value-hour\"]", NULL, 'Hour element not found on Date Only.'); + $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-end-value-minute\"]", NULL, 'Minute element not found on Date Only.'); + + // Go to the form display page to assert that increment option does not + // appear on Date Only. + $fieldEditUrl = 'entity_test/structure/entity_test/form-display'; + $this->drupalGet($fieldEditUrl); + + // Click on the widget settings button to open the widget settings form. + $this->drupalPostForm(NULL, [], $field_name . "_settings_edit"); + $xpathIncr = "//select[starts-with(@id, \"edit-fields-$field_name-settings-edit-form-settings-increment\")]"; + $this->assertNoFieldByXPath($xpathIncr, NULL, 'Increment element not found for Date Only.'); + + // Change the field is set to an all day field. + $this->fieldStorage->setSetting('datetime_type', DateRangeItem::DATETIME_TYPE_ALLDAY); + $this->fieldStorage->save(); + + // Change the widget to a datelist widget. + entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') + ->setComponent($field_name, [ + 'type' => 'daterange_datelist', + 'settings' => [ + 'date_order' => 'YMD', + ], + ]) + ->save(); + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); + + // Display creation form. + $this->drupalGet('entity_test/add'); + + // Assert that Hour and Minute Elements do not appear on Date Only. + $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-hour\"]", NULL, 'Hour element not found on Date Only.'); + $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-minute\"]", NULL, 'Minute element not found on Date Only.'); + $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-end-value-hour\"]", NULL, 'Hour element not found on Date Only.'); + $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-end-value-minute\"]", NULL, 'Minute element not found on Date Only.'); + + // Go to the form display page to assert that increment option does not + // appear on Date Only. + $fieldEditUrl = 'entity_test/structure/entity_test/form-display'; + $this->drupalGet($fieldEditUrl); + + // Click on the widget settings button to open the widget settings form. + $this->drupalPostForm(NULL, [], $field_name . "_settings_edit"); + $xpathIncr = "//select[starts-with(@id, \"edit-fields-$field_name-settings-edit-form-settings-increment\")]"; + $this->assertNoFieldByXPath($xpathIncr, NULL, 'Increment element not found for Date Only.'); + + // Change the field to a datetime field. + $this->fieldStorage->setSetting('datetime_type', DateRangeItem::DATETIME_TYPE_DATETIME); + $this->fieldStorage->save(); + + // Change the widget to a datelist widget. + entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') + ->setComponent($field_name, [ + 'type' => 'daterange_datelist', + 'settings' => [ + 'increment' => 1, + 'date_order' => 'YMD', + 'time_type' => '12', + ], + ]) + ->save(); + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); + + // Go to the form display page to assert that increment option does appear + // on Date Time. + $fieldEditUrl = 'entity_test/structure/entity_test/form-display'; + $this->drupalGet($fieldEditUrl); + + // Click on the widget settings button to open the widget settings form. + $this->drupalPostForm(NULL, [], $field_name . "_settings_edit"); + $this->assertFieldByXPath($xpathIncr, NULL, 'Increment element found for Date and time.'); + + // Display creation form. + $this->drupalGet('entity_test/add'); + + foreach (['value', 'end-value'] as $column) { + foreach (['year', 'month', 'day', 'hour', 'minute', 'ampm'] as $element) { + $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-$column-$element\"]", NULL, $element . ' element found.'); + $this->assertOptionSelected("edit-$field_name-0-$column-$element", '', 'No ' . $element . ' selected.'); + } + } + + // Submit a valid date and ensure it is accepted. + $start_date_value = ['year' => 2012, 'month' => 12, 'day' => 31, 'hour' => 5, 'minute' => 15]; + $end_date_value = ['year' => 2013, 'month' => 1, 'day' => 15, 'hour' => 3, 'minute' => 30]; + + $edit = []; + // Add the ampm indicator since we are testing 12 hour time. + $start_date_value['ampm'] = 'am'; + $end_date_value['ampm'] = 'pm'; + foreach ($start_date_value as $part => $value) { + $edit["{$field_name}[0][value][$part]"] = $value; + } + foreach ($end_date_value as $part => $value) { + $edit["{$field_name}[0][end_value][$part]"] = $value; + } + + $this->drupalPostForm(NULL, $edit, t('Save')); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + + $this->assertOptionSelected("edit-$field_name-0-value-year", '2012', 'Correct year selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-month", '12', 'Correct month selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-day", '31', 'Correct day selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-hour", '5', 'Correct hour selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-minute", '15', 'Correct minute selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-ampm", 'am', 'Correct ampm selected.'); + + $this->assertOptionSelected("edit-$field_name-0-end-value-year", '2013', 'Correct year selected.'); + $this->assertOptionSelected("edit-$field_name-0-end-value-month", '1', 'Correct month selected.'); + $this->assertOptionSelected("edit-$field_name-0-end-value-day", '15', 'Correct day selected.'); + $this->assertOptionSelected("edit-$field_name-0-end-value-hour", '3', 'Correct hour selected.'); + $this->assertOptionSelected("edit-$field_name-0-end-value-minute", '30', 'Correct minute selected.'); + $this->assertOptionSelected("edit-$field_name-0-end-value-ampm", 'pm', 'Correct ampm selected.'); + + // Test the widget using increment other than 1 and 24 hour mode. + entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') + ->setComponent($field_name, [ + 'type' => 'daterange_datelist', + 'settings' => [ + 'increment' => 15, + 'date_order' => 'YMD', + 'time_type' => '24', + ], + ]) + ->save(); + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); + + // Display creation form. + $this->drupalGet('entity_test/add'); + + // Other elements are unaffected by the changed settings. + $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-value-hour\"]", NULL, 'Hour element found.'); + $this->assertOptionSelected("edit-$field_name-0-value-hour", '', 'No hour selected.'); + $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-value-ampm\"]", NULL, 'AMPM element not found.'); + $this->assertFieldByXPath("//*[@id=\"edit-$field_name-0-end-value-hour\"]", NULL, 'Hour element found.'); + $this->assertOptionSelected("edit-$field_name-0-end-value-hour", '', 'No hour selected.'); + $this->assertNoFieldByXPath("//*[@id=\"edit-$field_name-0-end-value-ampm\"]", NULL, 'AMPM element not found.'); + + // Submit a valid date and ensure it is accepted. + $start_date_value = ['year' => 2012, 'month' => 12, 'day' => 31, 'hour' => 17, 'minute' => 15]; + $end_date_value = ['year' => 2013, 'month' => 1, 'day' => 15, 'hour' => 3, 'minute' => 30]; + + $edit = []; + foreach ($start_date_value as $part => $value) { + $edit["{$field_name}[0][value][$part]"] = $value; + } + foreach ($end_date_value as $part => $value) { + $edit["{$field_name}[0][end_value][$part]"] = $value; + } + + $this->drupalPostForm(NULL, $edit, t('Save')); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + + $this->assertOptionSelected("edit-$field_name-0-value-year", '2012', 'Correct year selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-month", '12', 'Correct month selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-day", '31', 'Correct day selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-hour", '17', 'Correct hour selected.'); + $this->assertOptionSelected("edit-$field_name-0-value-minute", '15', 'Correct minute selected.'); + + $this->assertOptionSelected("edit-$field_name-0-end-value-year", '2013', 'Correct year selected.'); + $this->assertOptionSelected("edit-$field_name-0-end-value-month", '1', 'Correct month selected.'); + $this->assertOptionSelected("edit-$field_name-0-end-value-day", '15', 'Correct day selected.'); + $this->assertOptionSelected("edit-$field_name-0-end-value-hour", '3', 'Correct hour selected.'); + $this->assertOptionSelected("edit-$field_name-0-end-value-minute", '30', 'Correct minute selected.'); + + // Test the widget for partial completion of fields. + entity_get_form_display($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'default') + ->setComponent($field_name, [ + 'type' => 'daterange_datelist', + 'settings' => [ + 'increment' => 1, + 'date_order' => 'YMD', + 'time_type' => '24', + ], + ]) + ->save(); + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); + + // Test the widget for validation notifications. + foreach ($this->datelistDataProvider() as $data) { + list($start_date_value, $end_date_value, $expected) = $data; + + // Display creation form. + $this->drupalGet('entity_test/add'); + + // Submit a partial date and ensure and error message is provided. + $edit = []; + foreach ($start_date_value as $part => $value) { + $edit["{$field_name}[0][value][$part]"] = $value; + } + foreach ($end_date_value as $part => $value) { + $edit["{$field_name}[0][end_value][$part]"] = $value; + } + + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertResponse(200); + foreach ($expected as $expected_text) { + $this->assertText(t($expected_text)); + } + } + + // Test the widget for complete input with zeros as part of selections. + $this->drupalGet('entity_test/add'); + + $start_date_value = ['year' => 2012, 'month' => 12, 'day' => 31, 'hour' => 0, 'minute' => 0]; + $end_date_value = ['year' => 2013, 'month' => 1, 'day' => 15, 'hour' => 3, 'minute' => 30]; + $edit = []; + foreach ($start_date_value as $part => $value) { + $edit["{$field_name}[0][value][$part]"] = $value; + } + foreach ($end_date_value as $part => $value) { + $edit["{$field_name}[0][end_value][$part]"] = $value; + } + + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertResponse(200); + preg_match('|entity_test/manage/(\d+)|', $this->getUrl(), $match); + $id = $match[1]; + $this->assertText(t('entity_test @id has been created.', ['@id' => $id])); + + // Test the widget to ensure zeros are not deselected on validation. + $this->drupalGet('entity_test/add'); + + $start_date_value = ['year' => 2012, 'month' => 12, 'day' => 31, 'hour' => 0, 'minute' => 0]; + $end_date_value = ['year' => 2013, 'month' => 1, 'day' => 15, 'hour' => 3, 'minute' => 0]; + $edit = []; + foreach ($start_date_value as $part => $value) { + $edit["{$field_name}[0][value][$part]"] = $value; + } + foreach ($end_date_value as $part => $value) { + $edit["{$field_name}[0][end_value][$part]"] = $value; + } + + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertResponse(200); + $this->assertOptionSelected("edit-$field_name-0-value-minute", '0', 'Correct minute selected.'); + $this->assertOptionSelected("edit-$field_name-0-end-value-minute", '0', 'Correct minute selected.'); + } + + /** + * The data provider for testing the validation of the datelist widget. + * + * @return array + * An array of datelist input permutations to test. + */ + protected function datelistDataProvider() { + return [ + // Year only selected, validation error on Month, Day, Hour, Minute. + [ + ['year' => 2012, 'month' => '', 'day' => '', 'hour' => '', 'minute' => ''], + ['year' => 2013, 'month' => '1', 'day' => '15', 'hour' => '3', 'minute' => '30'], [ + 'A value must be selected for month.', + 'A value must be selected for day.', + 'A value must be selected for hour.', + 'A value must be selected for minute.', + ], + ], + // Year and Month selected, validation error on Day, Hour, Minute. + [ + ['year' => 2012, 'month' => '12', 'day' => '', 'hour' => '', 'minute' => ''], + ['year' => 2013, 'month' => '1', 'day' => '15', 'hour' => '3', 'minute' => '30'], [ + 'A value must be selected for day.', + 'A value must be selected for hour.', + 'A value must be selected for minute.', + ], + ], + // Year, Month and Day selected, validation error on Hour, Minute. + [ + ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '', 'minute' => ''], + ['year' => 2013, 'month' => '1', 'day' => '15', 'hour' => '3', 'minute' => '30'], [ + 'A value must be selected for hour.', + 'A value must be selected for minute.', + ], + ], + // Year, Month, Day and Hour selected, validation error on Minute only. + [ + ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '0', 'minute' => ''], + ['year' => 2013, 'month' => '1', 'day' => '15', 'hour' => '3', 'minute' => '30'], [ + 'A value must be selected for minute.', + ], + ], + // Year selected, validation error on Month, Day, Hour, Minute. + [ + ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '0', 'minute' => '0'], + ['year' => 2013, 'month' => '', 'day' => '', 'hour' => '', 'minute' => ''], [ + 'A value must be selected for month.', + 'A value must be selected for day.', + 'A value must be selected for hour.', + 'A value must be selected for minute.', + ], + ], + // Year and Month selected, validation error on Day, Hour, Minute. + [ + ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '0', 'minute' => '0'], + ['year' => 2013, 'month' => '1', 'day' => '', 'hour' => '', 'minute' => ''], [ + 'A value must be selected for day.', + 'A value must be selected for hour.', + 'A value must be selected for minute.', + ], + ], + // Year, Month and Day selected, validation error on Hour, Minute. + [ + ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '0', 'minute' => '0'], + ['year' => 2013, 'month' => '1', 'day' => '15', 'hour' => '', 'minute' => ''], [ + 'A value must be selected for hour.', + 'A value must be selected for minute.', + ], + ], + // Year, Month, Day and Hour selected, validation error on Minute only. + [ + ['year' => 2012, 'month' => '12', 'day' => '31', 'hour' => '0', 'minute' => '0'], + ['year' => 2013, 'month' => '1', 'day' => '15', 'hour' => '3', 'minute' => ''], [ + 'A value must be selected for minute.', + ], + ], + ]; + } + + /** + * Test default value functionality. + */ + public function testDefaultValue() { + // Create a test content type. + $this->drupalCreateContentType(['type' => 'date_content']); + + // Create a field storage with settings to validate. + $field_name = Unicode::strtolower($this->randomMachineName()); + $field_storage = FieldStorageConfig::create([ + 'field_name' => $field_name, + 'entity_type' => 'node', + 'type' => 'daterange', + 'settings' => ['datetime_type' => DateRangeItem::DATETIME_TYPE_DATE], + ]); + $field_storage->save(); + + $field = FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => 'date_content', + ]); + $field->save(); + + // Set now as default_value. + $field_edit = [ + 'default_value_input[default_date_type]' => 'now', + 'default_value_input[default_end_date_type]' => 'now', + ]; + $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); + + // Check that default value is selected in default value form. + $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name); + $this->assertOptionSelected('edit-default-value-input-default-date-type', 'now', 'The default start value is selected in instance settings page'); + $this->assertFieldByName('default_value_input[default_date]', '', 'The relative start default value is empty in instance settings page'); + $this->assertOptionSelected('edit-default-value-input-default-end-date-type', 'now', 'The default end value is selected in instance settings page'); + $this->assertFieldByName('default_value_input[default_end_date]', '', 'The relative end default value is empty in instance settings page'); + + // Check if default_date has been stored successfully. + $config_entity = $this->config('field.field.node.date_content.' . $field_name)->get(); + $this->assertEqual($config_entity['default_value'][0], [ + 'default_date_type' => 'now', + 'default_date' => 'now', + 'default_end_date_type' => 'now', + 'default_end_date' => 'now', + ], 'Default value has been stored successfully'); + + // Clear field cache in order to avoid stale cache values. + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); + + // Create a new node to check that datetime field default value is today. + $new_node = Node::create(['type' => 'date_content']); + $expected_date = new DrupalDateTime('now', DATETIME_STORAGE_TIMEZONE); + $this->assertEqual($new_node->get($field_name)->offsetGet(0)->value, $expected_date->format(DATETIME_DATE_STORAGE_FORMAT)); + $this->assertEqual($new_node->get($field_name)->offsetGet(0)->end_value, $expected_date->format(DATETIME_DATE_STORAGE_FORMAT)); + + // Set an invalid relative default_value to test validation. + $field_edit = [ + 'default_value_input[default_date_type]' => 'relative', + 'default_value_input[default_date]' => 'invalid date', + 'default_value_input[default_end_date_type]' => 'relative', + 'default_value_input[default_end_date]' => '+1 day', + ]; + $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); + $this->assertText('The relative start date value entered is invalid.'); + + $field_edit = [ + 'default_value_input[default_date_type]' => 'relative', + 'default_value_input[default_date]' => '+1 day', + 'default_value_input[default_end_date_type]' => 'relative', + 'default_value_input[default_end_date]' => 'invalid date', + ]; + $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); + $this->assertText('The relative end date value entered is invalid.'); + + // Set a relative default_value. + $field_edit = [ + 'default_value_input[default_date_type]' => 'relative', + 'default_value_input[default_date]' => '+45 days', + 'default_value_input[default_end_date_type]' => 'relative', + 'default_value_input[default_end_date]' => '+90 days', + ]; + $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); + + // Check that default value is selected in default value form. + $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name); + $this->assertOptionSelected('edit-default-value-input-default-date-type', 'relative', 'The default start value is selected in instance settings page'); + $this->assertFieldByName('default_value_input[default_date]', '+45 days', 'The relative default start value is displayed in instance settings page'); + $this->assertOptionSelected('edit-default-value-input-default-end-date-type', 'relative', 'The default end value is selected in instance settings page'); + $this->assertFieldByName('default_value_input[default_end_date]', '+90 days', 'The relative default end value is displayed in instance settings page'); + + // Check if default_date has been stored successfully. + $config_entity = $this->config('field.field.node.date_content.' . $field_name)->get(); + $this->assertEqual($config_entity['default_value'][0], [ + 'default_date_type' => 'relative', + 'default_date' => '+45 days', + 'default_end_date_type' => 'relative', + 'default_end_date' => '+90 days', + ], 'Default value has been stored successfully'); + + // Clear field cache in order to avoid stale cache values. + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); + + // Create a new node to check that datetime field default value is +90 days. + $new_node = Node::create(['type' => 'date_content']); + $expected_start_date = new DrupalDateTime('+45 days', DATETIME_STORAGE_TIMEZONE); + $expected_end_date = new DrupalDateTime('+90 days', DATETIME_STORAGE_TIMEZONE); + $this->assertEqual($new_node->get($field_name)->offsetGet(0)->value, $expected_start_date->format(DATETIME_DATE_STORAGE_FORMAT)); + $this->assertEqual($new_node->get($field_name)->offsetGet(0)->end_value, $expected_end_date->format(DATETIME_DATE_STORAGE_FORMAT)); + + // Remove default value. + $field_edit = [ + 'default_value_input[default_date_type]' => '', + 'default_value_input[default_end_date_type]' => '', + ]; + $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); + + // Check that default value is selected in default value form. + $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name); + $this->assertOptionSelected('edit-default-value-input-default-date-type', '', 'The default start value is selected in instance settings page'); + $this->assertFieldByName('default_value_input[default_date]', '', 'The relative default start value is empty in instance settings page'); + $this->assertOptionSelected('edit-default-value-input-default-end-date-type', '', 'The default end value is selected in instance settings page'); + $this->assertFieldByName('default_value_input[default_end_date]', '', 'The relative default end value is empty in instance settings page'); + + // Check if default_date has been stored successfully. + $config_entity = $this->config('field.field.node.date_content.' . $field_name)->get(); + $this->assertTrue(empty($config_entity['default_value']), 'Empty default value has been stored successfully'); + + // Clear field cache in order to avoid stale cache values. + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); + + // Create a new node to check that datetime field default value is not set. + $new_node = Node::create(['type' => 'date_content']); + $this->assertNull($new_node->get($field_name)->value, 'Default value is not set'); + + // Set now as default_value for start date only. + entity_get_form_display('node', 'date_content', 'default') + ->setComponent($field_name, [ + 'type' => 'datetime_default', + ]) + ->save(); + + $expected_date = new DrupalDateTime('now', DATETIME_STORAGE_TIMEZONE); + + $field_edit = [ + 'default_value_input[default_date_type]' => 'now', + 'default_value_input[default_end_date_type]' => '', + ]; + $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); + + // Make sure only the start value is populated on node add page. + $this->drupalGet('node/add/date_content'); + $this->assertFieldByName("{$field_name}[0][value][date]", $expected_date->format(DATETIME_DATE_STORAGE_FORMAT), 'Start date element populated.'); + $this->assertFieldByName("{$field_name}[0][end_value][date]", '', 'End date element empty.'); + + // Set now as default_value for end date only. + $field_edit = [ + 'default_value_input[default_date_type]' => '', + 'default_value_input[default_end_date_type]' => 'now', + ]; + $this->drupalPostForm('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name, $field_edit, t('Save settings')); + + // Make sure only the start value is populated on node add page. + $this->drupalGet('node/add/date_content'); + $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Start date element empty.'); + $this->assertFieldByName("{$field_name}[0][end_value][date]", $expected_date->format(DATETIME_DATE_STORAGE_FORMAT), 'End date element populated.'); + } + + /** + * Test that invalid values are caught and marked as invalid. + */ + public function testInvalidField() { + // Change the field to a datetime field. + $this->fieldStorage->setSetting('datetime_type', DateRangeItem::DATETIME_TYPE_DATETIME); + $this->fieldStorage->save(); + $field_name = $this->fieldStorage->getName(); + + $this->drupalGet('entity_test/add'); + $this->assertFieldByName("{$field_name}[0][value][date]", '', 'Start date element found.'); + $this->assertFieldByName("{$field_name}[0][value][time]", '', 'Start time element found.'); + $this->assertFieldByName("{$field_name}[0][end_value][date]", '', 'End date element found.'); + $this->assertFieldByName("{$field_name}[0][end_value][time]", '', 'End time element found.'); + + // Submit invalid start dates and ensure they is not accepted. + $date_value = ''; + $edit = [ + "{$field_name}[0][value][date]" => $date_value, + "{$field_name}[0][value][time]" => '12:00:00', + "{$field_name}[0][end_value][date]" => '2012-12-01', + "{$field_name}[0][end_value][time]" => '12:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', 'Empty start date value has been caught.'); + + $date_value = 'aaaa-12-01'; + $edit = [ + "{$field_name}[0][value][date]" => $date_value, + "{$field_name}[0][value][time]" => '00:00:00', + "{$field_name}[0][end_value][date]" => '2012-12-01', + "{$field_name}[0][end_value][time]" => '12:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', new FormattableMarkup('Invalid start year value %date has been caught.', ['%date' => $date_value])); + + $date_value = '2012-75-01'; + $edit = [ + "{$field_name}[0][value][date]" => $date_value, + "{$field_name}[0][value][time]" => '00:00:00', + "{$field_name}[0][end_value][date]" => '2012-12-01', + "{$field_name}[0][end_value][time]" => '12:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', new FormattableMarkup('Invalid start month value %date has been caught.', ['%date' => $date_value])); + + $date_value = '2012-12-99'; + $edit = [ + "{$field_name}[0][value][date]" => $date_value, + "{$field_name}[0][value][time]" => '00:00:00', + "{$field_name}[0][end_value][date]" => '2012-12-01', + "{$field_name}[0][end_value][time]" => '12:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', new FormattableMarkup('Invalid start day value %date has been caught.', ['%date' => $date_value])); + + // Submit invalid start times and ensure they is not accepted. + $time_value = ''; + $edit = [ + "{$field_name}[0][value][date]" => '2012-12-01', + "{$field_name}[0][value][time]" => $time_value, + "{$field_name}[0][end_value][date]" => '2012-12-01', + "{$field_name}[0][end_value][time]" => '12:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', 'Empty start time value has been caught.'); + + $time_value = '49:00:00'; + $edit = [ + "{$field_name}[0][value][date]" => '2012-12-01', + "{$field_name}[0][value][time]" => $time_value, + "{$field_name}[0][end_value][date]" => '2012-12-01', + "{$field_name}[0][end_value][time]" => '12:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', new FormattableMarkup('Invalid start hour value %time has been caught.', ['%time' => $time_value])); + + $time_value = '12:99:00'; + $edit = [ + "{$field_name}[0][value][date]" => '2012-12-01', + "{$field_name}[0][value][time]" => $time_value, + "{$field_name}[0][end_value][date]" => '2012-12-01', + "{$field_name}[0][end_value][time]" => '12:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', new FormattableMarkup('Invalid start minute value %time has been caught.', ['%time' => $time_value])); + + $time_value = '12:15:99'; + $edit = [ + "{$field_name}[0][value][date]" => '2012-12-01', + "{$field_name}[0][value][time]" => $time_value, + "{$field_name}[0][end_value][date]" => '2012-12-01', + "{$field_name}[0][end_value][time]" => '12:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', new FormattableMarkup('Invalid start second value %time has been caught.', ['%time' => $time_value])); + + // Submit invalid end dates and ensure they is not accepted. + $date_value = ''; + $edit = [ + "{$field_name}[0][value][date]" => '2012-12-01', + "{$field_name}[0][value][time]" => '12:00:00', + "{$field_name}[0][end_value][date]" => $date_value, + "{$field_name}[0][end_value][time]" => '12:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', 'Empty end date value has been caught.'); + + $date_value = 'aaaa-12-01'; + $edit = [ + "{$field_name}[0][value][date]" => '2012-12-01', + "{$field_name}[0][value][time]" => '12:00:00', + "{$field_name}[0][end_value][date]" => $date_value, + "{$field_name}[0][end_value][time]" => '00:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', new FormattableMarkup('Invalid end year value %date has been caught.', ['%date' => $date_value])); + + $date_value = '2012-75-01'; + $edit = [ + "{$field_name}[0][value][date]" => '2012-12-01', + "{$field_name}[0][value][time]" => '12:00:00', + "{$field_name}[0][end_value][date]" => $date_value, + "{$field_name}[0][end_value][time]" => '00:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', new FormattableMarkup('Invalid end month value %date has been caught.', ['%date' => $date_value])); + + $date_value = '2012-12-99'; + $edit = [ + "{$field_name}[0][value][date]" => '2012-12-01', + "{$field_name}[0][value][time]" => '12:00:00', + "{$field_name}[0][end_value][date]" => $date_value, + "{$field_name}[0][end_value][time]" => '00:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', new FormattableMarkup('Invalid end day value %date has been caught.', ['%date' => $date_value])); + + // Submit invalid start times and ensure they is not accepted. + $time_value = ''; + $edit = [ + "{$field_name}[0][value][date]" => '2012-12-01', + "{$field_name}[0][value][time]" => '12:00:00', + "{$field_name}[0][end_value][date]" => '2012-12-01', + "{$field_name}[0][end_value][time]" => $time_value, + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', 'Empty end time value has been caught.'); + + $time_value = '49:00:00'; + $edit = [ + "{$field_name}[0][value][date]" => '2012-12-01', + "{$field_name}[0][value][time]" => '12:00:00', + "{$field_name}[0][end_value][date]" => '2012-12-01', + "{$field_name}[0][end_value][time]" => $time_value, + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', new FormattableMarkup('Invalid end hour value %time has been caught.', ['%time' => $time_value])); + + $time_value = '12:99:00'; + $edit = [ + "{$field_name}[0][value][date]" => '2012-12-01', + "{$field_name}[0][value][time]" => '12:00:00', + "{$field_name}[0][end_value][date]" => '2012-12-01', + "{$field_name}[0][end_value][time]" => $time_value, + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', new FormattableMarkup('Invalid end minute value %time has been caught.', ['%time' => $time_value])); + + $time_value = '12:15:99'; + $edit = [ + "{$field_name}[0][value][date]" => '2012-12-01', + "{$field_name}[0][value][time]" => '12:00:00', + "{$field_name}[0][end_value][date]" => '2012-12-01', + "{$field_name}[0][end_value][time]" => $time_value, + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText('date is invalid', new FormattableMarkup('Invalid end second value %time has been caught.', ['%time' => $time_value])); + + $edit = [ + "{$field_name}[0][value][date]" => '2012-12-01', + "{$field_name}[0][value][time]" => '12:00:00', + "{$field_name}[0][end_value][date]" => '2010-12-01', + "{$field_name}[0][end_value][time]" => '12:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText(new FormattableMarkup('The @title end date cannot be before the start date', ['@title' => $field_name]), 'End date before start date has been caught.'); + + $edit = [ + "{$field_name}[0][value][date]" => '2012-12-01', + "{$field_name}[0][value][time]" => '12:00:00', + "{$field_name}[0][end_value][date]" => '2012-12-01', + "{$field_name}[0][end_value][time]" => '11:00:00', + ]; + $this->drupalPostForm(NULL, $edit, t('Save')); + $this->assertText(new FormattableMarkup('The @title end date cannot be before the start date', ['@title' => $field_name]), 'End time before start time has been caught.'); + } + + /** + * Tests that 'Date' field storage setting form is disabled if field has data. + */ + public function testDateStorageSettings() { + // Create a test content type. + $this->drupalCreateContentType(['type' => 'date_content']); + + // Create a field storage with settings to validate. + $field_name = Unicode::strtolower($this->randomMachineName()); + $field_storage = FieldStorageConfig::create([ + 'field_name' => $field_name, + 'entity_type' => 'node', + 'type' => 'daterange', + 'settings' => [ + 'datetime_type' => DateRangeItem::DATETIME_TYPE_DATE, + ], + ]); + $field_storage->save(); + $field = FieldConfig::create([ + 'field_storage' => $field_storage, + 'field_name' => $field_name, + 'bundle' => 'date_content', + ]); + $field->save(); + + entity_get_form_display('node', 'date_content', 'default') + ->setComponent($field_name, [ + 'type' => 'datetime_default', + ]) + ->save(); + $edit = [ + 'title[0][value]' => $this->randomString(), + 'body[0][value]' => $this->randomString(), + $field_name . '[0][value][date]' => '2016-04-01', + $field_name . '[0][end_value][date]' => '2016-04-02', + ]; + $this->drupalPostForm('node/add/date_content', $edit, t('Save')); + $this->drupalGet('admin/structure/types/manage/date_content/fields/node.date_content.' . $field_name . '/storage'); + $result = $this->xpath("//*[@id='edit-settings-datetime-type' and contains(@disabled, 'disabled')]"); + $this->assertEqual(count($result), 1, "Changing datetime setting is disabled."); + $this->assertText('There is data for this field in the database. The field settings can no longer be changed.'); + } + +} diff --git a/core/modules/editor/src/Tests/QuickEditIntegrationLoadingTest.php b/core/modules/editor/src/Tests/QuickEditIntegrationLoadingTest.php index 7141687..a4515d5 100644 --- a/core/modules/editor/src/Tests/QuickEditIntegrationLoadingTest.php +++ b/core/modules/editor/src/Tests/QuickEditIntegrationLoadingTest.php @@ -89,12 +89,12 @@ public function testUsersWithoutPermission() { $response = $this->drupalPost('editor/' . 'node/1/body/en/full', '', [], ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']]); $this->assertResponse(403); if (!$user->hasPermission('access in-place editing')) { - $message = "A fatal error occurred: The 'access in-place editing' permission is required."; - $this->assertIdentical(Json::encode(['message' => $message]), $response); + $message = "The 'access in-place editing' permission is required."; } else { - $this->assertIdentical('{}', $response); + $message = ''; } + $this->assertIdentical(Json::encode(['message' => $message]), $response); } } diff --git a/core/modules/editor/tests/src/Kernel/QuickEditIntegrationTest.php b/core/modules/editor/tests/src/Kernel/QuickEditIntegrationTest.php index 256d271..276f559 100644 --- a/core/modules/editor/tests/src/Kernel/QuickEditIntegrationTest.php +++ b/core/modules/editor/tests/src/Kernel/QuickEditIntegrationTest.php @@ -9,7 +9,7 @@ use Drupal\entity_test\Entity\EntityTest; use Drupal\quickedit\MetadataGenerator; use Drupal\Tests\quickedit\Kernel\QuickEditTestBase; -use Drupal\quickedit_test\MockEditEntityFieldAccessCheck; +use Drupal\quickedit_test\MockQuickEditEntityFieldAccessCheck; use Drupal\editor\EditorController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; @@ -52,7 +52,7 @@ class QuickEditIntegrationTest extends QuickEditTestBase { /** * The access checker object to be used by the metadata generator object. * - * @var \Drupal\quickedit\Access\EditEntityFieldAccessCheckInterface + * @var \Drupal\quickedit\Access\QuickEditEntityFieldAccessCheckInterface */ protected $accessChecker; @@ -165,7 +165,7 @@ public function testEditorSelection() { */ public function testMetadata() { $this->editorManager = $this->container->get('plugin.manager.quickedit.editor'); - $this->accessChecker = new MockEditEntityFieldAccessCheck(); + $this->accessChecker = new MockQuickEditEntityFieldAccessCheck(); $this->editorSelector = $this->container->get('quickedit.editor.selector'); $this->metadataGenerator = new MetadataGenerator($this->accessChecker, $this->editorSelector, $this->editorManager); diff --git a/core/modules/field/src/Plugin/migrate/process/FieldType.php b/core/modules/field/src/Plugin/migrate/process/FieldType.php index 7874f9c..b9af082 100644 --- a/core/modules/field/src/Plugin/migrate/process/FieldType.php +++ b/core/modules/field/src/Plugin/migrate/process/FieldType.php @@ -9,6 +9,7 @@ use Drupal\migrate\Plugin\migrate\process\StaticMap; use Drupal\migrate\Row; use Drupal\migrate_drupal\Plugin\MigrateCckFieldPluginManagerInterface; +use Drupal\migrate_drupal\Plugin\MigrateFieldPluginManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -26,6 +27,13 @@ class FieldType extends StaticMap implements ContainerFactoryPluginInterface { protected $cckPluginManager; /** + * The field plugin manager. + * + * @var \Drupal\migrate_drupal\Plugin\MigrateFieldPluginManagerInterface + */ + protected $fieldPluginManager; + + /** * The migration object. * * @var \Drupal\migrate\Plugin\MigrationInterface @@ -43,12 +51,15 @@ class FieldType extends StaticMap implements ContainerFactoryPluginInterface { * The plugin definition. * @param \Drupal\migrate_drupal\Plugin\MigrateCckFieldPluginManagerInterface $cck_plugin_manager * The cckfield plugin manager. + * @param \Drupal\migrate_drupal\Plugin\MigrateFieldPluginManagerInterface $field_plugin_manager + * The field plugin manager. * @param \Drupal\migrate\Plugin\MigrationInterface $migration * The migration being run. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrateCckFieldPluginManagerInterface $cck_plugin_manager, MigrationInterface $migration = NULL) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrateCckFieldPluginManagerInterface $cck_plugin_manager, MigrateFieldPluginManagerInterface $field_plugin_manager, MigrationInterface $migration = NULL) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->cckPluginManager = $cck_plugin_manager; + $this->fieldPluginManager = $field_plugin_manager; $this->migration = $migration; } @@ -61,6 +72,7 @@ public static function create(ContainerInterface $container, array $configuratio $plugin_id, $plugin_definition, $container->get('plugin.manager.migrate.cckfield'), + $container->get('plugin.manager.migrate.field'), $migration ); } @@ -70,13 +82,18 @@ public static function create(ContainerInterface $container, array $configuratio */ public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) { $field_type = is_array($value) ? $value[0] : $value; - try { - $plugin_id = $this->cckPluginManager->getPluginIdFromFieldType($field_type, [], $this->migration); - return $this->cckPluginManager->createInstance($plugin_id, [], $this->migration)->getFieldType($row); + $plugin_id = $this->fieldPluginManager->getPluginIdFromFieldType($field_type, [], $this->migration); + return $this->fieldPluginManager->createInstance($plugin_id, [], $this->migration)->getFieldType($row); } catch (PluginNotFoundException $e) { - return parent::transform($value, $migrate_executable, $row, $destination_property); + try { + $plugin_id = $this->cckPluginManager->getPluginIdFromFieldType($field_type, [], $this->migration); + return $this->cckPluginManager->createInstance($plugin_id, [], $this->migration)->getFieldType($row); + } + catch (PluginNotFoundException $e) { + return parent::transform($value, $migrate_executable, $row, $destination_property); + } } } diff --git a/core/modules/field/tests/src/Kernel/EntityReference/Views/EntityReferenceRelationshipTest.php b/core/modules/field/tests/src/Kernel/EntityReference/Views/EntityReferenceRelationshipTest.php index 31d6503..2b0be85 100644 --- a/core/modules/field/tests/src/Kernel/EntityReference/Views/EntityReferenceRelationshipTest.php +++ b/core/modules/field/tests/src/Kernel/EntityReference/Views/EntityReferenceRelationshipTest.php @@ -34,7 +34,7 @@ class EntityReferenceRelationshipTest extends ViewsKernelTestBase { 'test_entity_reference_entity_test_mul_view', 'test_entity_reference_reverse_entity_test_mul_view', 'test_entity_reference_group_by_empty_relationships', - ]; + ]; /** * Modules to install. diff --git a/core/modules/file/migration_templates/d6_file.yml b/core/modules/file/migration_templates/d6_file.yml index c25f63f..8371d45 100644 --- a/core/modules/file/migration_templates/d6_file.yml +++ b/core/modules/file/migration_templates/d6_file.yml @@ -7,7 +7,7 @@ migration_tags: source: plugin: d6_file constants: - # source_base_path must be set by the tool configuring this migration. It + # The tool configuring this migration must set source_base_path. It # represents the fully qualified path relative to which URIs in the files # table are specified, and must end with a /. See source_full_path # configuration in this migration's process pipeline as an example. diff --git a/core/modules/file/migration_templates/d7_file.yml b/core/modules/file/migration_templates/d7_file.yml index 7ebf83b..7b35aff 100644 --- a/core/modules/file/migration_templates/d7_file.yml +++ b/core/modules/file/migration_templates/d7_file.yml @@ -7,8 +7,8 @@ migration_tags: source: plugin: d7_file constants: - # source_base_path must be set by the tool configuring this migration. It - # represents the fully qualified path relative to which uris in the files + # The tool configuring this migration must set source_base_path. It + # represents the fully qualified path relative to which URIs in the files # table are specified, and must end with a /. See source_full_path # configuration in this migration's process pipeline as an example. source_base_path: '' @@ -32,8 +32,7 @@ process: - '@source_full_path' - uri filemime: filemime - # filesize is dynamically computed when file entities are saved, so there is - # no point in migrating it. + # No need to migrate filesize, it is computed when file entities are saved. # filesize: filesize status: status # Drupal 7 didn't keep track of the file's creation or update time -- all it diff --git a/core/modules/file/tests/src/Functional/FileFieldTestBase.php b/core/modules/file/tests/src/Functional/FileFieldTestBase.php index 9ed447a..d609687 100644 --- a/core/modules/file/tests/src/Functional/FileFieldTestBase.php +++ b/core/modules/file/tests/src/Functional/FileFieldTestBase.php @@ -259,7 +259,7 @@ public function replaceNodeFile($file, $field_name, $nid, $new_revision = TRUE) /** * Asserts that a file exists physically on disk. * - * Overrides PHPUnit_Framework_Assert::assertFileExists() to also work with + * Overrides PHPUnit\Framework\Assert::assertFileExists() to also work with * file entities. * * @param \Drupal\File\FileInterface|string $file @@ -286,7 +286,7 @@ public function assertFileEntryExists($file, $message = NULL) { /** * Asserts that a file does not exist on disk. * - * Overrides PHPUnit_Framework_Assert::assertFileExists() to also work with + * Overrides PHPUnit\Framework\Assert::assertFileExists() to also work with * file entities. * * @param \Drupal\File\FileInterface|string $file diff --git a/core/modules/forum/src/Form/ContainerForm.php b/core/modules/forum/src/Form/ContainerForm.php index d12d098..0b01124 100644 --- a/core/modules/forum/src/Form/ContainerForm.php +++ b/core/modules/forum/src/Form/ContainerForm.php @@ -20,9 +20,8 @@ class ContainerForm extends ForumForm { * {@inheritdoc} */ public function form(array $form, FormStateInterface $form_state) { - $taxonomy_term = $this->entity; // Build the bulk of the form from the parent forum form. - $form = parent::form($form, $form_state, $taxonomy_term); + $form = parent::form($form, $form_state); // Set the title and description of the name field. $form['name']['#title'] = $this->t('Container name'); diff --git a/core/modules/forum/src/Form/ForumForm.php b/core/modules/forum/src/Form/ForumForm.php index ed0a768..9e5895d 100644 --- a/core/modules/forum/src/Form/ForumForm.php +++ b/core/modules/forum/src/Form/ForumForm.php @@ -29,9 +29,8 @@ class ForumForm extends TermForm { * {@inheritdoc} */ public function form(array $form, FormStateInterface $form_state) { - $taxonomy_term = $this->entity; // Build the bulk of the form from the parent taxonomy term form. - $form = parent::form($form, $form_state, $taxonomy_term); + $form = parent::form($form, $form_state); // Set the title and description of the name field. $form['name']['#title'] = $this->t('Forum name'); @@ -48,7 +47,7 @@ public function form(array $form, FormStateInterface $form_state) { // Our parent field is different to the taxonomy term. $form['parent']['#tree'] = TRUE; - $form['parent'][0] = $this->forumParentSelect($taxonomy_term->id(), $this->t('Parent')); + $form['parent'][0] = $this->forumParentSelect($this->entity->id(), $this->t('Parent')); $form['#theme_wrappers'] = ['form__forum']; $this->forumFormType = $this->t('forum'); diff --git a/core/modules/forum/tests/src/Kernel/Migrate/d6/MigrateForumConfigsTest.php b/core/modules/forum/tests/src/Kernel/Migrate/d6/MigrateForumConfigsTest.php index bc0a9ff..7aa848e 100644 --- a/core/modules/forum/tests/src/Kernel/Migrate/d6/MigrateForumConfigsTest.php +++ b/core/modules/forum/tests/src/Kernel/Migrate/d6/MigrateForumConfigsTest.php @@ -38,9 +38,9 @@ public function testForumSettings() { $this->assertIdentical(1, $config->get('topics.order')); $this->assertIdentical('vocabulary_1_i_0_', $config->get('vocabulary')); // This is 'forum_block_num_0' in D6, but block:active:limit' in D8. - $this->assertIdentical(5, $config->get('block.active.limit')); + $this->assertSame(3, $config->get('block.active.limit')); // This is 'forum_block_num_1' in D6, but 'block:new:limit' in D8. - $this->assertIdentical(5, $config->get('block.new.limit')); + $this->assertSame(4, $config->get('block.new.limit')); $this->assertConfigSchema(\Drupal::service('config.typed'), 'forum.settings', $config->get()); } diff --git a/core/modules/hal/src/LinkManager/RelationLinkManagerInterface.php b/core/modules/hal/src/LinkManager/RelationLinkManagerInterface.php index 9a9c839..18d7863 100644 --- a/core/modules/hal/src/LinkManager/RelationLinkManagerInterface.php +++ b/core/modules/hal/src/LinkManager/RelationLinkManagerInterface.php @@ -21,7 +21,7 @@ * (optional) Optional serializer/normalizer context. * * @return string - * The corresponding URI for the field. + * The corresponding URI (or IANA link relation type) for the field. */ public function getRelationUri($entity_type, $bundle, $field_name, $context = []); @@ -29,7 +29,7 @@ public function getRelationUri($entity_type, $bundle, $field_name, $context = [] * Translates a REST URI into internal IDs. * * @param string $relation_uri - * Relation URI to transform into internal IDs + * Relation URI (or IANA link relation type) to transform into internal IDs. * * @return array * Array with keys 'entity_type', 'bundle' and 'field_name'. diff --git a/core/modules/hal/tests/src/Functional/EntityResource/ContentLanguageSettings/ContentLanguageSettingsHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/ContentLanguageSettings/ContentLanguageSettingsHalJsonAnonTest.php new file mode 100644 index 0000000..e90485d --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/ContentLanguageSettings/ContentLanguageSettingsHalJsonAnonTest.php @@ -0,0 +1,30 @@ +serializer->encode($normalization, static::$format); - // DX: 400 when incorrect entity type bundle is specified. + // DX: 422 when incorrect entity type bundle is specified. $response = $this->request($method, $url, $request_options); - $this->assertResourceErrorResponse(400, 'No entity type(s) specified', $response); + $this->assertResourceErrorResponse(422, 'No entity type(s) specified', $response); unset($normalization['_links']['type']); $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); - // DX: 400 when no entity type bundle is specified. + // DX: 422 when no entity type bundle is specified. $response = $this->request($method, $url, $request_options); - $this->assertResourceErrorResponse(400, 'The type link relation must be specified.', $response); + $this->assertResourceErrorResponse(422, 'The type link relation must be specified.', $response); } } diff --git a/core/modules/hal/tests/src/Functional/EntityResource/RestResourceConfig/RestResourceConfigHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/RestResourceConfig/RestResourceConfigHalJsonAnonTest.php new file mode 100644 index 0000000..021812b --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/RestResourceConfig/RestResourceConfigHalJsonAnonTest.php @@ -0,0 +1,30 @@ + $dimensions['width'], '#height' => $dimensions['height'], '#attributes' => $variables['attributes'], - '#uri' => $style->buildUrl($variables['uri']), '#style_name' => $variables['style_name'], ]; + // If the current image toolkit supports this file type, prepare the URI for + // the derivative image. If not, just use the original image resized to the + // dimensions specified by the style. + if ($style->supportsUri($variables['uri'])) { + $variables['image']['#uri'] = $style->buildUrl($variables['uri']); + } + else { + $variables['image']['#uri'] = $variables['uri']; + // Don't render the image by default, but allow other preprocess functions + // to override that if they need to. + $variables['image']['#access'] = FALSE; + + // Inform the site builders why their image didn't work. + \Drupal::logger('image')->warning('Could not apply @style image style to @uri because the style does not support it.', [ + '@style' => $style->label(), + '@uri' => $variables['uri'], + ]); + } + if (isset($variables['alt']) || array_key_exists('alt', $variables)) { $variables['image']['#alt'] = $variables['alt']; } diff --git a/core/modules/image/src/Entity/ImageStyle.php b/core/modules/image/src/Entity/ImageStyle.php index fb096a7..dd26124 100644 --- a/core/modules/image/src/Entity/ImageStyle.php +++ b/core/modules/image/src/Entity/ImageStyle.php @@ -14,9 +14,11 @@ use Drupal\image\ImageStyleInterface; use Drupal\Component\Utility\Crypt; use Drupal\Component\Utility\UrlHelper; +use Drupal\Component\Utility\Unicode; use Drupal\Core\StreamWrapper\StreamWrapperInterface; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Drupal\Core\Entity\Entity\EntityViewDisplay; + /** * Defines an image style configuration entity. * @@ -274,9 +276,8 @@ public function flush($path = NULL) { * {@inheritdoc} */ public function createDerivative($original_uri, $derivative_uri) { - // If the source file doesn't exist, return FALSE without creating folders. - $image = \Drupal::service('image.factory')->get($original_uri); + $image = $this->getImageFactory()->get($original_uri); if (!$image->isValid()) { return FALSE; } @@ -343,6 +344,18 @@ public function deleteImageEffect(ImageEffectInterface $effect) { /** * {@inheritdoc} */ + public function supportsUri($uri) { + // Only support the URI if its extension is supported by the current image + // toolkit. + return in_array( + Unicode::strtolower(pathinfo($uri, PATHINFO_EXTENSION)), + $this->getImageFactory()->getSupportedExtensions() + ); + } + + /** + * {@inheritdoc} + */ public function getEffect($effect) { return $this->getEffects()->get($effect); } @@ -409,6 +422,16 @@ protected function getImageEffectPluginManager() { } /** + * Returns the image factory. + * + * @return \Drupal\Core\Image\ImageFactory + * The image factory. + */ + protected function getImageFactory() { + return \Drupal::service('image.factory'); + } + + /** * Gets the Drupal private key. * * @return string diff --git a/core/modules/image/src/ImageStyleInterface.php b/core/modules/image/src/ImageStyleInterface.php index 89fd060..7dec361 100644 --- a/core/modules/image/src/ImageStyleInterface.php +++ b/core/modules/image/src/ImageStyleInterface.php @@ -194,4 +194,15 @@ public function addImageEffect(array $configuration); */ public function deleteImageEffect(ImageEffectInterface $effect); + /** + * Determines if this style can be applied to a given image. + * + * @param string $uri + * The URI of the image. + * + * @return bool + * TRUE if the image is supported, FALSE otherwise. + */ + public function supportsUri($uri); + } diff --git a/core/modules/image/tests/src/Kernel/ImageFormatterTest.php b/core/modules/image/tests/src/Kernel/ImageFormatterTest.php index f001743..e994f12 100644 --- a/core/modules/image/tests/src/Kernel/ImageFormatterTest.php +++ b/core/modules/image/tests/src/Kernel/ImageFormatterTest.php @@ -7,6 +7,8 @@ use Drupal\entity_test\Entity\EntityTest; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; +use Drupal\file\Entity\File; +use Drupal\image\Entity\ImageStyle; use Drupal\Tests\field\Kernel\FieldKernelTestBase; /** @@ -99,4 +101,89 @@ public function testImageFormatterCacheTags() { $this->assertEquals($entity->{$this->fieldName}[1]->entity->getCacheTags(), $build[$this->fieldName][1]['#cache']['tags'], 'Second image cache tags is as expected'); } + /** + * Tests ImageFormatter's handling of SVG images. + * + * @requires extension gd + */ + public function testImageFormatterSvg() { + // Install the default image styles. + $this->installConfig(['image']); + + /** @var \Drupal\Core\Render\RendererInterface $renderer */ + $renderer = $this->container->get('renderer'); + + $png = File::create([ + 'uri' => 'public://test-image.png', + ]); + $png->save(); + + // We need to create an actual empty PNG, or the GD toolkit will not + // consider the image valid. + $png_resource = imagecreate(300, 300); + imagefill($png_resource, 0, 0, imagecolorallocate($png_resource, 0, 0, 0)); + imagepng($png_resource, $png->getFileUri()); + + $svg = File::create([ + 'uri' => 'public://test-image.svg', + ]); + $svg->save(); + // We don't have to put any real SVG data in here, because the GD toolkit + // won't be able to load it anyway. + touch($svg->getFileUri()); + + $entity = EntityTest::create([ + 'name' => $this->randomMachineName(), + $this->fieldName => [$png, $svg], + ]); + $entity->save(); + + // Ensure that the display is using the medium image style. + $component = $this->display->getComponent($this->fieldName); + $component['settings']['image_style'] = 'medium'; + $this->display->setComponent($this->fieldName, $component)->save(); + + $build = $this->display->build($entity); + + // The first image is a PNG, so it is supported by the GD image toolkit. + // The image style should be applied with its cache tags, image derivative + // computed with its URI and dimensions. + $this->assertCacheTags($build[$this->fieldName][0], ImageStyle::load('medium')->getCacheTags()); + $renderer->renderRoot($build[$this->fieldName][0]); + $this->assertEquals('medium', $build[$this->fieldName][0]['#image_style']); + // We check that the image URL contains the expected style directory + // structure. + $this->assertTrue(strpos($build[$this->fieldName][0]['#markup'], 'styles/medium/public/test-image.png') !== FALSE); + $this->assertTrue(strpos($build[$this->fieldName][0]['#markup'], 'width="220"') !== FALSE); + $this->assertTrue(strpos($build[$this->fieldName][0]['#markup'], 'height="220"') !== FALSE); + + // The second image is an SVG, which is not supported by the GD toolkit. + // The image style should still be applied with its cache tags, but image + // derivative will not be available so tag will point to the original + // image. + $this->assertCacheTags($build[$this->fieldName][1], ImageStyle::load('medium')->getCacheTags()); + $renderer->renderRoot($build[$this->fieldName][1]); + $this->assertEquals('medium', $build[$this->fieldName][1]['#image_style']); + // We check that the image URL does not contain the style directory + // structure. + $this->assertFalse(strpos($build[$this->fieldName][1]['#markup'], 'styles/medium/public/test-image.svg')); + // Since we did not store original image dimensions, width and height + // HTML attributes will not be present. + $this->assertFalse(strpos($build[$this->fieldName][1]['#markup'], 'width')); + $this->assertFalse(strpos($build[$this->fieldName][1]['#markup'], 'height')); + } + + /** + * Asserts that a renderable array has a set of cache tags. + * + * @param array $renderable + * The renderable array. Must have a #cache[tags] element. + * @param array $cache_tags + * The expected cache tags. + */ + protected function assertCacheTags(array $renderable, array $cache_tags) { + $diff = array_diff($cache_tags, $renderable['#cache']['tags']); + $this->assertEmpty($diff); + } + } diff --git a/core/modules/inline_form_errors/tests/src/FunctionalJavascript/FormErrorHandlerCKEditorTest.php b/core/modules/inline_form_errors/tests/src/FunctionalJavascript/FormErrorHandlerCKEditorTest.php index 3ee24b4..bce4ff8 100644 --- a/core/modules/inline_form_errors/tests/src/FunctionalJavascript/FormErrorHandlerCKEditorTest.php +++ b/core/modules/inline_form_errors/tests/src/FunctionalJavascript/FormErrorHandlerCKEditorTest.php @@ -82,8 +82,6 @@ public function testFragmentLink() { $this->drupalGet('node/add/page'); - $page = $this->getSession()->getPage(); - // Only enter a title in the node add form and leave the body field empty. $edit = ['edit-title-0-value' => 'Test inline form error with CKEditor']; @@ -99,8 +97,8 @@ public function testFragmentLink() { // Check if we can find the error fragment link within the errors summary // message. - $errors_link = $page->find('css', '.messages--error a[href=\#edit-body-0-value]'); - $this->assertTrue($errors_link->isVisible(), 'Error fragment link is visible.'); + $errors_link = $this->assertSession()->waitForElementVisible('css', '.messages--error a[href="#edit-body-0-value"]'); + $this->assertNotEmpty($errors_link, 'Error fragment link is visible.'); $errors_link->click(); diff --git a/core/modules/language/config/schema/language.schema.yml b/core/modules/language/config/schema/language.schema.yml index 5e3f55b..91ce2d1 100644 --- a/core/modules/language/config/schema/language.schema.yml +++ b/core/modules/language/config/schema/language.schema.yml @@ -131,3 +131,11 @@ condition.plugin.language: type: sequence sequence: type: string + +field.widget.settings.language_select: + type: mapping + label: 'Language format settings' + mapping: + include_locked: + type: boolean + label: 'Include locked languages' diff --git a/core/modules/language/language.post_update.php b/core/modules/language/language.post_update.php new file mode 100644 index 0000000..f7f9c29 --- /dev/null +++ b/core/modules/language/language.post_update.php @@ -0,0 +1,28 @@ +get('content'); + $changed = FALSE; + foreach (array_keys($content) as $element) { + if (isset($content[$element]['type']) && $content[$element]['type'] == 'language_select') { + $content[$element]['settings']['include_locked'] = TRUE; + $changed = TRUE; + } + } + if ($changed) { + $display_form->set('content', $content); + $display_form->save(); + } + } +} diff --git a/core/modules/language/src/LanguageNegotiatorInterface.php b/core/modules/language/src/LanguageNegotiatorInterface.php index 71f44b5..5c999c7 100644 --- a/core/modules/language/src/LanguageNegotiatorInterface.php +++ b/core/modules/language/src/LanguageNegotiatorInterface.php @@ -91,7 +91,6 @@ * } * } * } - * ?> * @endcode * * For more information, see diff --git a/core/modules/language/src/Tests/Update/LanguageSelectWidgetUpdateTest.php b/core/modules/language/src/Tests/Update/LanguageSelectWidgetUpdateTest.php new file mode 100644 index 0000000..b055983 --- /dev/null +++ b/core/modules/language/src/Tests/Update/LanguageSelectWidgetUpdateTest.php @@ -0,0 +1,40 @@ +databaseDumpFiles = [ + __DIR__ . '/../../../../system/tests/fixtures/update/drupal-8.filled.standard.php.gz', + ]; + } + + /** + * Tests language_post_update_language_select_widget(). + */ + public function testLanguagePostUpdateLanguageSelectWidget() { + // Tests before the update. + $content_before = EntityFormDisplay::load('node.page.default')->get('content'); + $this->assertEqual([], $content_before['langcode']['settings']); + + // Run the update. + $this->runUpdates(); + + // Tests after the update. + $content_after = EntityFormDisplay::load('node.page.default')->get('content'); + $this->assertEqual(['include_locked' => TRUE], $content_after['langcode']['settings']); + } + +} diff --git a/core/modules/language/tests/src/Kernel/LanguageSelectWidgetTest.php b/core/modules/language/tests/src/Kernel/LanguageSelectWidgetTest.php new file mode 100644 index 0000000..966275c --- /dev/null +++ b/core/modules/language/tests/src/Kernel/LanguageSelectWidgetTest.php @@ -0,0 +1,77 @@ +installEntitySchema('entity_test'); + $this->installEntitySchema('user'); + + $storage = $this->container->get('entity_type.manager')->getStorage('entity_form_display'); + $this->entityFormDisplay = $storage->create([ + 'targetEntityType' => 'entity_test', + 'bundle' => 'entity_test', + 'mode' => 'default', + 'status' => TRUE, + ]); + } + + /** + * Tests the widget with the locked languages. + */ + public function testWithIncludedLockedLanguage() { + $this->entityFormDisplay->setComponent('langcode', [ + 'type' => 'language_select', + ])->save(); + $entity = EntityTest::create(['name' => $this->randomString()]); + $form = $this->container->get('entity.form_builder')->getForm($entity); + $options = array_keys($form['langcode']['widget'][0]['value']['#options']); + $this->assertSame(['en', 'und', 'zxx'], $options); + } + + /** + * Test the widget without the locked languages. + */ + public function testWithoutIncludedLockedLanguage() { + $this->entityFormDisplay->setComponent('langcode', [ + 'type' => 'language_select', + 'settings' => ['include_locked' => FALSE], + ])->save(); + $entity = EntityTest::create(['name' => $this->randomString()]); + $form = $this->container->get('entity.form_builder')->getForm($entity); + $options = array_keys($form['langcode']['widget'][0]['value']['#options']); + $this->assertSame(['en'], $options); + } + +} diff --git a/core/modules/link/src/Plugin/Field/FieldWidget/LinkWidget.php b/core/modules/link/src/Plugin/Field/FieldWidget/LinkWidget.php index e1b1c2a..937491f 100644 --- a/core/modules/link/src/Plugin/Field/FieldWidget/LinkWidget.php +++ b/core/modules/link/src/Plugin/Field/FieldWidget/LinkWidget.php @@ -175,6 +175,7 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen '#element_validate' => [[get_called_class(), 'validateUriElement']], '#maxlength' => 2048, '#required' => $element['#required'], + '#link_type' => $this->getFieldSetting('link_type'), ]; // If the field is configured to support internal links, it cannot use the diff --git a/core/modules/link/src/Plugin/migrate/cckfield/LinkField.php b/core/modules/link/src/Plugin/migrate/cckfield/LinkField.php deleted file mode 100644 index 314e8df..0000000 --- a/core/modules/link/src/Plugin/migrate/cckfield/LinkField.php +++ /dev/null @@ -1,48 +0,0 @@ - 'link', - 'plain' => 'link', - 'absolute' => 'link', - 'title_plain' => 'link', - 'url' => 'link', - 'short' => 'link', - 'label' => 'link', - 'separate' => 'link_separate', - ]; - } - - /** - * {@inheritdoc} - */ - public function processCckFieldValues(MigrationInterface $migration, $field_name, $data) { - $process = [ - 'plugin' => 'd6_cck_link', - 'source' => $field_name, - ]; - $migration->mergeProcessOfProperty($field_name, $process); - } - -} diff --git a/core/modules/link/src/Plugin/migrate/cckfield/d7/LinkField.php b/core/modules/link/src/Plugin/migrate/cckfield/d7/LinkField.php deleted file mode 100644 index 5de568d..0000000 --- a/core/modules/link/src/Plugin/migrate/cckfield/d7/LinkField.php +++ /dev/null @@ -1,48 +0,0 @@ - 'link_default']; - } - - /** - * {@inheritdoc} - */ - public function processFieldInstance(MigrationInterface $migration) { - $process = [ - 'plugin' => 'static_map', - 'source' => 'instance_settings/title', - 'bypass' => TRUE, - 'map' => [ - 'disabled' => DRUPAL_DISABLED, - 'optional' => DRUPAL_OPTIONAL, - 'required' => DRUPAL_REQUIRED, - ], - ]; - $migration->mergeProcessOfProperty('settings/title', $process); - } - -} diff --git a/core/modules/link/src/Plugin/migrate/field/d6/LinkField.php b/core/modules/link/src/Plugin/migrate/field/d6/LinkField.php new file mode 100644 index 0000000..0d03656 --- /dev/null +++ b/core/modules/link/src/Plugin/migrate/field/d6/LinkField.php @@ -0,0 +1,48 @@ + 'link', + 'plain' => 'link', + 'absolute' => 'link', + 'title_plain' => 'link', + 'url' => 'link', + 'short' => 'link', + 'label' => 'link', + 'separate' => 'link_separate', + ]; + } + + /** + * {@inheritdoc} + */ + public function processFieldValues(MigrationInterface $migration, $field_name, $data) { + $process = [ + 'plugin' => 'd6_field_link', + 'source' => $field_name, + ]; + $migration->mergeProcessOfProperty($field_name, $process); + } + +} diff --git a/core/modules/link/src/Plugin/migrate/field/d7/LinkField.php b/core/modules/link/src/Plugin/migrate/field/d7/LinkField.php new file mode 100644 index 0000000..4e78ee5 --- /dev/null +++ b/core/modules/link/src/Plugin/migrate/field/d7/LinkField.php @@ -0,0 +1,48 @@ + 'link_default']; + } + + /** + * {@inheritdoc} + */ + public function processFieldInstance(MigrationInterface $migration) { + $process = [ + 'plugin' => 'static_map', + 'source' => 'instance_settings/title', + 'bypass' => TRUE, + 'map' => [ + 'disabled' => DRUPAL_DISABLED, + 'optional' => DRUPAL_OPTIONAL, + 'required' => DRUPAL_REQUIRED, + ], + ]; + $migration->mergeProcessOfProperty('settings/title', $process); + } + +} diff --git a/core/modules/link/src/Plugin/migrate/process/d6/CckLink.php b/core/modules/link/src/Plugin/migrate/process/d6/CckLink.php index 2c03412..2d1b77a 100644 --- a/core/modules/link/src/Plugin/migrate/process/d6/CckLink.php +++ b/core/modules/link/src/Plugin/migrate/process/d6/CckLink.php @@ -2,85 +2,16 @@ namespace Drupal\link\Plugin\migrate\process\d6; -use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\migrate\Plugin\MigrationInterface; -use Drupal\migrate\MigrateExecutableInterface; -use Drupal\migrate\ProcessPluginBase; -use Drupal\migrate\Row; -use Symfony\Component\DependencyInjection\ContainerInterface; +@trigger_error('CckLink is deprecated in Drupal 8.3.x and will be removed before +Drupal 9.0.x. Use \Drupal\link\Plugin\migrate\process\d6\FieldLink instead.', +E_USER_DEPRECATED); /** * @MigrateProcessPlugin( * id = "d6_cck_link" * ) + * + * @deprecated in Drupal 8.3.x, to be removed before Drupal 9.0.x. Use + * \Drupal\link\Plugin\migrate\process\d6\FieldLink instead. */ -class CckLink extends ProcessPluginBase implements ContainerFactoryPluginInterface { - - /** - * {@inheritdoc} - */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration) { - parent::__construct($configuration, $plugin_id, $plugin_definition); - $this->migration = $migration; - } - - /** - * {@inheritdoc} - */ - public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL) { - return new static( - $configuration, - $plugin_id, - $plugin_definition, - $migration - ); - } - - /** - * Turn a Drupal 6 URI into a Drupal 8-compatible format. - * - * @param string $uri - * The 'url' value from Drupal 6. - * - * @return string - * The Drupal 8-compatible URI. - * - * @see \Drupal\link\Plugin\Field\FieldWidget\LinkWidget::getUserEnteredStringAsUri() - */ - protected function canonicalizeUri($uri) { - // If we already have a scheme, we're fine. - if (empty($uri) || !is_null(parse_url($uri, PHP_URL_SCHEME))) { - return $uri; - } - - // Remove the component of the URL. - if (strpos($uri, '') === 0) { - $uri = substr($uri, strlen('')); - } - - // Add the internal: scheme and ensure a leading slash. - return 'internal:/' . ltrim($uri, '/'); - } - - /** - * {@inheritdoc} - */ - public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) { - $attributes = unserialize($value['attributes']); - // Drupal 6 link attributes might be double serialized. - if (!is_array($attributes)) { - $attributes = unserialize($attributes); - } - - if (!$attributes) { - $attributes = []; - } - - // Massage the values into the correct form for the link. - $route['uri'] = $this->canonicalizeUri($value['url']); - $route['options']['attributes'] = $attributes; - $route['title'] = $value['title']; - return $route; - } - -} +class CckLink extends FieldLink { } diff --git a/core/modules/link/src/Plugin/migrate/process/d6/FieldLink.php b/core/modules/link/src/Plugin/migrate/process/d6/FieldLink.php new file mode 100644 index 0000000..4badcb6 --- /dev/null +++ b/core/modules/link/src/Plugin/migrate/process/d6/FieldLink.php @@ -0,0 +1,86 @@ +migration = $migration; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $migration + ); + } + + /** + * Turn a Drupal 6 URI into a Drupal 8-compatible format. + * + * @param string $uri + * The 'url' value from Drupal 6. + * + * @return string + * The Drupal 8-compatible URI. + * + * @see \Drupal\link\Plugin\Field\FieldWidget\LinkWidget::getUserEnteredStringAsUri() + */ + protected function canonicalizeUri($uri) { + // If we already have a scheme, we're fine. + if (empty($uri) || !is_null(parse_url($uri, PHP_URL_SCHEME))) { + return $uri; + } + + // Remove the component of the URL. + if (strpos($uri, '') === 0) { + $uri = substr($uri, strlen('')); + } + + // Add the internal: scheme and ensure a leading slash. + return 'internal:/' . ltrim($uri, '/'); + } + + /** + * {@inheritdoc} + */ + public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) { + $attributes = unserialize($value['attributes']); + // Drupal 6 link attributes might be double serialized. + if (!is_array($attributes)) { + $attributes = unserialize($attributes); + } + + if (!$attributes) { + $attributes = []; + } + + // Massage the values into the correct form for the link. + $route['uri'] = $this->canonicalizeUri($value['url']); + $route['options']['attributes'] = $attributes; + $route['title'] = $value['title']; + return $route; + } + +} diff --git a/core/modules/link/tests/src/Functional/LinkFieldTest.php b/core/modules/link/tests/src/Functional/LinkFieldTest.php index f4ffdee..104a386 100644 --- a/core/modules/link/tests/src/Functional/LinkFieldTest.php +++ b/core/modules/link/tests/src/Functional/LinkFieldTest.php @@ -610,6 +610,49 @@ public function testLinkSeparateFormatter() { } /** + * Test '#link_type' property exists on 'link_default' widget. + * + * Make sure the 'link_default' widget exposes a '#link_type' property on + * its element. Modules can use it to understand if a text form element is + * a link and also which LinkItemInterface::LINK_* is (EXTERNAL, GENERIC, + * INTERNAL). + */ + public function testLinkTypeOnLinkWidget() { + + $link_type = LinkItemInterface::LINK_EXTERNAL; + $field_name = Unicode::strtolower($this->randomMachineName()); + + // Create a field with settings to validate. + $this->fieldStorage = FieldStorageConfig::create([ + 'field_name' => $field_name, + 'entity_type' => 'entity_test', + 'type' => 'link', + 'cardinality' => 1, + ]); + $this->fieldStorage->save(); + FieldConfig::create([ + 'field_storage' => $this->fieldStorage, + 'label' => 'Read more about this entity', + 'bundle' => 'entity_test', + 'settings' => [ + 'title' => DRUPAL_OPTIONAL, + 'link_type' => $link_type, + ], + ])->save(); + + $this->container->get('entity.manager') + ->getStorage('entity_form_display') + ->load('entity_test.entity_test.default') + ->setComponent($field_name, [ + 'type' => 'link_default', + ]) + ->save(); + + $form = \Drupal::service('entity.form_builder')->getForm(EntityTest::create()); + $this->assertEqual($form[$field_name]['widget'][0]['uri']['#link_type'], $link_type); + } + + /** * Renders a test_entity and returns the output. * * @param int $id diff --git a/core/modules/link/tests/src/Unit/Plugin/migrate/process/d6/CckLinkTest.php b/core/modules/link/tests/src/Unit/Plugin/migrate/process/d6/CckLinkTest.php deleted file mode 100644 index a96287a..0000000 --- a/core/modules/link/tests/src/Unit/Plugin/migrate/process/d6/CckLinkTest.php +++ /dev/null @@ -1,56 +0,0 @@ -getMock('\Drupal\migrate\Plugin\MigrationInterface')); - $transformed = $link_plugin->transform([ - 'url' => $url, - 'title' => '', - 'attributes' => serialize([]), - ], $this->getMock('\Drupal\migrate\MigrateExecutableInterface'), $this->getMockBuilder('\Drupal\migrate\Row')->disableOriginalConstructor()->getMock(), NULL); - $this->assertEquals($expected, $transformed['uri']); - } - - /** - * Data provider for testCanonicalizeUri. - */ - public function canonicalizeUriDataProvider() { - return [ - 'Simple front-page' => [ - '', - 'internal:/', - ], - 'Front page with query' => [ - '?query=1', - 'internal:/?query=1', - ], - 'No leading forward slash' => [ - 'node/10', - 'internal:/node/10', - ], - 'Leading forward slash' => [ - '/node/10', - 'internal:/node/10', - ], - 'Existing scheme' => [ - 'scheme:test', - 'scheme:test', - ], - ]; - } - -} diff --git a/core/modules/link/tests/src/Unit/Plugin/migrate/process/d6/FieldLinkTest.php b/core/modules/link/tests/src/Unit/Plugin/migrate/process/d6/FieldLinkTest.php new file mode 100644 index 0000000..4b4eda2 --- /dev/null +++ b/core/modules/link/tests/src/Unit/Plugin/migrate/process/d6/FieldLinkTest.php @@ -0,0 +1,56 @@ +getMock('\Drupal\migrate\Plugin\MigrationInterface')); + $transformed = $link_plugin->transform([ + 'url' => $url, + 'title' => '', + 'attributes' => serialize([]), + ], $this->getMock('\Drupal\migrate\MigrateExecutableInterface'), $this->getMockBuilder('\Drupal\migrate\Row')->disableOriginalConstructor()->getMock(), NULL); + $this->assertEquals($expected, $transformed['uri']); + } + + /** + * Data provider for testCanonicalizeUri. + */ + public function canonicalizeUriDataProvider() { + return [ + 'Simple front-page' => [ + '', + 'internal:/', + ], + 'Front page with query' => [ + '?query=1', + 'internal:/?query=1', + ], + 'No leading forward slash' => [ + 'node/10', + 'internal:/node/10', + ], + 'Leading forward slash' => [ + '/node/10', + 'internal:/node/10', + ], + 'Existing scheme' => [ + 'scheme:test', + 'scheme:test', + ], + ]; + } + +} diff --git a/core/modules/locale/src/StringDatabaseStorage.php b/core/modules/locale/src/StringDatabaseStorage.php index 57358dc..45bf41d 100644 --- a/core/modules/locale/src/StringDatabaseStorage.php +++ b/core/modules/locale/src/StringDatabaseStorage.php @@ -3,6 +3,7 @@ namespace Drupal\locale; use Drupal\Core\Database\Connection; +use Drupal\Core\Database\Query\Condition; /** * Defines a class to store localized strings in the database. @@ -416,7 +417,7 @@ protected function dbStringSelect(array $conditions, array $options = []) { elseif ($table_alias == 't' && $join === 'leftJoin') { // Conditions for target fields when doing an outer join only make // sense if we add also OR field IS NULL. - $query->condition(db_or() + $query->condition((new Condition('OR')) ->condition($field_alias, (array) $value, 'IN') ->isNull($field_alias) ); @@ -429,7 +430,7 @@ protected function dbStringSelect(array $conditions, array $options = []) { // Process other options, string filter, query limit, etc. if (!empty($options['filters'])) { if (count($options['filters']) > 1) { - $filter = db_or(); + $filter = new Condition('OR'); $query->condition($filter); } else { diff --git a/core/modules/migrate/src/Plugin/migrate/process/FormatDate.php b/core/modules/migrate/src/Plugin/migrate/process/FormatDate.php new file mode 100644 index 0000000..b197f17 --- /dev/null +++ b/core/modules/migrate/src/Plugin/migrate/process/FormatDate.php @@ -0,0 +1,113 @@ +configuration['from_format'])) { + throw new MigrateException('Format date plugin is missing from_format configuration.'); + } + if (empty($this->configuration['to_format'])) { + throw new MigrateException('Format date plugin is missing to_format configuration.'); + } + + $fromFormat = $this->configuration['from_format']; + $toFormat = $this->configuration['to_format']; + $timezone = isset($this->configuration['timezone']) ? $this->configuration['timezone'] : NULL; + $settings = isset($this->configuration['settings']) ? $this->configuration['settings'] : []; + + // Attempts to transform the supplied date using the defined input format. + // DateTimePlus::createFromFormat can throw exceptions, so we need to + // explicitly check for problems. + try { + $transformed = DateTimePlus::createFromFormat($fromFormat, $value, $timezone, $settings)->format($toFormat); + } + catch (\InvalidArgumentException $e) { + throw new MigrateException(sprintf('Format date plugin could not transform "%s" using the format "%s". Error: %s', $value, $fromFormat, $e->getMessage()), $e->getCode(), $e); + } + catch (\UnexpectedValueException $e) { + throw new MigrateException(sprintf('Format date plugin could not transform "%s" using the format "%s". Error: %s', $value, $fromFormat, $e->getMessage()), $e->getCode(), $e); + } + + return $transformed; + } + +} diff --git a/core/modules/migrate/src/Plugin/migrate/process/SkipOnEmpty.php b/core/modules/migrate/src/Plugin/migrate/process/SkipOnEmpty.php index 45626be..c7d3ad5 100644 --- a/core/modules/migrate/src/Plugin/migrate/process/SkipOnEmpty.php +++ b/core/modules/migrate/src/Plugin/migrate/process/SkipOnEmpty.php @@ -21,6 +21,9 @@ * - row: Skips the entire row when an empty value is encountered. * - process: Prevents further processing of the input property when the value * is empty. + * - message: (optional) A message to be logged in the {migrate_message_*} table + * for this row. Messages are only logged for the 'row' skip level. If not + * set, nothing is logged in the message table. * * Examples: * @@ -30,9 +33,11 @@ * plugin: skip_on_empty * method: row * source: field_name + * message: 'Field field_name is missed' * @endcode * - * If field_name is empty, skips the entire row. + * If field_name is empty, skips the entire row and the message 'Field + * field_name is missed' is logged in the message table. * * @code * process: @@ -79,7 +84,8 @@ class SkipOnEmpty extends ProcessPluginBase { */ public function row($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) { if (!$value) { - throw new MigrateSkipRowException(); + $message = !empty($this->configuration['message']) ? $this->configuration['message'] : ''; + throw new MigrateSkipRowException($message); } return $value; } diff --git a/core/modules/migrate/src/Plugin/migrate/process/SkipRowIfNotSet.php b/core/modules/migrate/src/Plugin/migrate/process/SkipRowIfNotSet.php index ab7552e..fa9b3f6 100644 --- a/core/modules/migrate/src/Plugin/migrate/process/SkipRowIfNotSet.php +++ b/core/modules/migrate/src/Plugin/migrate/process/SkipRowIfNotSet.php @@ -16,6 +16,8 @@ * * Available configuration keys: * - index: The source property to check for. + * - message: (optional) A message to be logged in the {migrate_message_*} table + * for this row. If not set, nothing is logged in the message table. * * Example: * @@ -26,10 +28,12 @@ * plugin: skip_row_if_not_set * index: contact * source: data + * message: "Missed the 'data' key" * @endcode * * This will return $data['contact'] if it exists. Otherwise, the row will be - * skipped. + * skipped and the message "Missed the 'data' key" will be logged in the + * message table. * * @see \Drupal\migrate\Plugin\MigrateProcessInterface * @@ -45,7 +49,8 @@ class SkipRowIfNotSet extends ProcessPluginBase { */ public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) { if (!isset($value[$this->configuration['index']])) { - throw new MigrateSkipRowException(); + $message = !empty($this->configuration['message']) ? $this->configuration['message'] : ''; + throw new MigrateSkipRowException($message); } return $value[$this->configuration['index']]; } diff --git a/core/modules/migrate/src/Plugin/migrate/process/StaticMap.php b/core/modules/migrate/src/Plugin/migrate/process/StaticMap.php index d3c93ed..1d68f35 100644 --- a/core/modules/migrate/src/Plugin/migrate/process/StaticMap.php +++ b/core/modules/migrate/src/Plugin/migrate/process/StaticMap.php @@ -10,9 +10,98 @@ use Drupal\migrate\MigrateSkipRowException; /** - * This plugin changes the current value based on a static lookup map. + * Changes the source value based on a static lookup map. * - * @link https://www.drupal.org/node/2143521 Online handbook documentation for static_map process plugin @endlink + * Maps the input value to another value using an associative array specified in + * the configuration. + * + * Available configuration keys: + * - source: The input value - either a scalar or an array. + * - map: An array (of 1 or more dimensions) that identifies the mapping between + * source values and destination values. + * - bypass: (optional) Whether the plugin should proceed when the source is not + * found in the map array. Defaults to FALSE. + * - TRUE: Return the unmodified input value, or another default value, if one + * is specified. + * - FALSE: Throw a MigrateSkipRowException. + * - default_value: (optional) The value to return if the source is not found in + * the map array. + * + * Examples: + * + * @code + * process: + * bar: + * plugin: static_map + * source: foo + * map: + * from: to + * this: that + * @endcode + * + * If the value of the source property foo was "from" then the value of the + * destination property bar will be "to". Similarly "this" becomes "that". + * static_map can do a lot more than this: it supports a list of source + * properties. This is super useful in module-delta to machine name conversions. + * + * @code + * process: + * id: + * plugin: static_map + * source: + * - module + * - delta + * map: + * filter: + * 0: filter_html_escape + * 1: filter_autop + * 2: filter_url + * 3: filter_htmlcorrector + * 4: filter_html_escape + * php: + * 0: php_code + * @endcode + * + * If the value of the source properties module and delta are "filter" and "2" + * respectively, then the returned value will be "filter_url". By default, if a + * value is not found in the map, an exception is thrown. + * + * When static_map is used to just rename a few things and leave the others, a + * "bypass: true" option can be added. In this case, the source value is used + * unchanged, e.g.: + * + * @code + * process: + * bar: + * plugin: static_map + * source: foo + * map: + * from: to + * this: that + * bypass: TRUE + * @endcode + * + * If the value of the source property "foo" is "from" then the returned value + * will be "to", but if the value of "foo" is "another" (a value that is not in + * the map) then the source value is used unchanged so the returned value will + * be "from" because "bypass" is set to TRUE. + * + * @code + * process: + * bar: + * plugin: static_map + * source: foo + * map: + * from: to + * this: that + * default_value: bar + * @endcode + * + * If the value of the source property "foo" is "yet_another" (a value that is + * not in the map) then the default_value is used so the returned value will + * be "bar". + * + * @see \Drupal\migrate\Plugin\MigrateProcessInterface * * @MigrateProcessPlugin( * id = "static_map" diff --git a/core/modules/migrate/src/Plugin/migrate/source/EmbeddedDataSource.php b/core/modules/migrate/src/Plugin/migrate/source/EmbeddedDataSource.php index 05be56d..0bb35f3 100644 --- a/core/modules/migrate/src/Plugin/migrate/source/EmbeddedDataSource.php +++ b/core/modules/migrate/src/Plugin/migrate/source/EmbeddedDataSource.php @@ -1,10 +1,41 @@ [ 'name' => 'changed', diff --git a/core/modules/migrate/tests/src/Kernel/MigrateSourceTestBase.php b/core/modules/migrate/tests/src/Kernel/MigrateSourceTestBase.php index 8a99cad..00f07a9 100644 --- a/core/modules/migrate/tests/src/Kernel/MigrateSourceTestBase.php +++ b/core/modules/migrate/tests/src/Kernel/MigrateSourceTestBase.php @@ -160,8 +160,8 @@ public function testSource(array $source_data, array $expected_data, $expected_c // If an expected count was given, assert it only if the plugin is // countable. if (is_numeric($expected_count)) { - $this->assertInstanceOf('\Iterator', $plugin); - $this->assertSame($expected_count, iterator_count($plugin)); + $this->assertInstanceOf('\Countable', $plugin); + $this->assertCount($expected_count, $plugin); } $i = 0; @@ -185,6 +185,11 @@ public function testSource(array $source_data, array $expected_data, $expected_c } } } + // False positives occur if the foreach is not entered. So, confirm the + // foreach loop was entered if the expected count is greater than 0. + if ($expected_count > 0) { + $this->assertGreaterThan(0, $i); + } } } diff --git a/core/modules/migrate/tests/src/Unit/Event/EventBaseTest.php b/core/modules/migrate/tests/src/Unit/Event/EventBaseTest.php index 6a52ed6..b712d31 100644 --- a/core/modules/migrate/tests/src/Unit/Event/EventBaseTest.php +++ b/core/modules/migrate/tests/src/Unit/Event/EventBaseTest.php @@ -3,12 +3,13 @@ namespace Drupal\Tests\migrate\Unit\Event; use Drupal\migrate\Event\EventBase; +use Drupal\Tests\UnitTestCase; /** * @coversDefaultClass \Drupal\migrate\Event\EventBase * @group migrate */ -class EventBaseTest extends \PHPUnit_Framework_TestCase { +class EventBaseTest extends UnitTestCase { /** * Test getMigration method. diff --git a/core/modules/migrate/tests/src/Unit/Event/MigrateImportEventTest.php b/core/modules/migrate/tests/src/Unit/Event/MigrateImportEventTest.php index 480fe85..d281433 100644 --- a/core/modules/migrate/tests/src/Unit/Event/MigrateImportEventTest.php +++ b/core/modules/migrate/tests/src/Unit/Event/MigrateImportEventTest.php @@ -3,12 +3,13 @@ namespace Drupal\Tests\migrate\Unit\Event; use Drupal\migrate\Event\MigrateImportEvent; +use Drupal\Tests\UnitTestCase; /** * @coversDefaultClass \Drupal\migrate\Event\MigrateImportEvent * @group migrate */ -class MigrateImportEventTest extends \PHPUnit_Framework_TestCase { +class MigrateImportEventTest extends UnitTestCase { /** * Test getMigration method. diff --git a/core/modules/migrate/tests/src/Unit/process/FormatDateTest.php b/core/modules/migrate/tests/src/Unit/process/FormatDateTest.php new file mode 100644 index 0000000..c1e0c63 --- /dev/null +++ b/core/modules/migrate/tests/src/Unit/process/FormatDateTest.php @@ -0,0 +1,124 @@ + '', + 'to_format' => 'Y-m-d', + ]; + + $this->setExpectedException(MigrateException::class, 'Format date plugin is missing from_format configuration.'); + $this->plugin = new FormatDate($configuration, 'test_format_date', []); + $this->plugin->transform('01/05/1955', $this->migrateExecutable, $this->row, 'field_date'); + } + + /** + * Tests that missing configuration will throw an exception. + */ + public function testMigrateExceptionMissingToFormat() { + $configuration = [ + 'from_format' => 'm/d/Y', + 'to_format' => '', + ]; + + $this->setExpectedException(MigrateException::class, 'Format date plugin is missing to_format configuration.'); + $this->plugin = new FormatDate($configuration, 'test_format_date', []); + $this->plugin->transform('01/05/1955', $this->migrateExecutable, $this->row, 'field_date'); + } + + /** + * Tests that date format mismatches will throw an exception. + */ + public function testMigrateExceptionBadFormat() { + $configuration = [ + 'from_format' => 'm/d/Y', + 'to_format' => 'Y-m-d', + ]; + + $this->setExpectedException(MigrateException::class, 'Format date plugin could not transform "January 5, 1955" using the format "m/d/Y". Error: The date cannot be created from a format.'); + $this->plugin = new FormatDate($configuration, 'test_format_date', []); + $this->plugin->transform('January 5, 1955', $this->migrateExecutable, $this->row, 'field_date'); + } + + /** + * Tests transformation. + * + * @covers ::transform + * + * @dataProvider datesDataProvider + * + * @param $configuration + * The configuration of the migration process plugin. + * @param $value + * The source value for the migration process plugin. + * @param $expected + * The expected value of the migration process plugin. + */ + public function testTransform($configuration, $value, $expected) { + $this->plugin = new FormatDate($configuration, 'test_format_date', []); + $actual = $this->plugin->transform($value, $this->migrateExecutable, $this->row, 'field_date'); + + $this->assertEquals($expected, $actual); + } + + /** + * Data provider of test dates. + * + * @return array + * Array of date formats and actual/expected values. + */ + public function datesDataProvider() { + return [ + 'datetime_date' => [ + 'configuration' => [ + 'from_format' => 'm/d/Y', + 'to_format' => 'Y-m-d', + ], + 'value' => '01/05/1955', + 'expected' => '1955-01-05', + ], + 'datetime_datetime' => [ + 'configuration' => [ + 'from_format' => 'm/d/Y H:i:s', + 'to_format' => 'Y-m-d\TH:i:s', + ], + 'value' => '01/05/1955 10:43:22', + 'expected' => '1955-01-05T10:43:22', + ], + 'empty_values' => [ + 'configuration' => [ + 'from_format' => 'm/d/Y', + 'to_format' => 'Y-m-d', + ], + 'value' => '', + 'expected' => '', + ], + 'timezone' => [ + 'configuration' => [ + 'from_format' => 'Y-m-d\TH:i:sO', + 'to_format' => 'Y-m-d\TH:i:s', + 'timezone' => 'America/Managua', + ], + 'value' => '2004-12-19T10:19:42-0600', + 'expected' => '2004-12-19T10:19:42', + ], + ]; + } + +} diff --git a/core/modules/migrate/tests/src/Unit/process/SkipOnEmptyTest.php b/core/modules/migrate/tests/src/Unit/process/SkipOnEmptyTest.php index 66b27cc..08fbef2 100644 --- a/core/modules/migrate/tests/src/Unit/process/SkipOnEmptyTest.php +++ b/core/modules/migrate/tests/src/Unit/process/SkipOnEmptyTest.php @@ -1,6 +1,7 @@ assertSame($value, ' '); } + /** + * Tests that a skip row exception without a message is raised. + * + * @covers ::row + */ + public function testRowSkipWithoutMessage() { + $configuration = [ + 'method' => 'row', + ]; + $process = new SkipOnEmpty($configuration, 'skip_on_empty', []); + $this->setExpectedException(MigrateSkipRowException::class); + $process->transform('', $this->migrateExecutable, $this->row, 'destinationproperty'); + } + + /** + * Tests that a skip row exception with a message is raised. + * + * @covers ::row + */ + public function testRowSkipWithMessage() { + $configuration = [ + 'method' => 'row', + 'message' => 'The value is empty', + ]; + $process = new SkipOnEmpty($configuration, 'skip_on_empty', []); + $this->setExpectedException(MigrateSkipRowException::class, 'The value is empty'); + $process->transform('', $this->migrateExecutable, $this->row, 'destinationproperty'); + } + } diff --git a/core/modules/migrate/tests/src/Unit/process/SkipRowIfNotSetTest.php b/core/modules/migrate/tests/src/Unit/process/SkipRowIfNotSetTest.php new file mode 100644 index 0000000..26e5b3f --- /dev/null +++ b/core/modules/migrate/tests/src/Unit/process/SkipRowIfNotSetTest.php @@ -0,0 +1,45 @@ + 'some_key', + ]; + $process = new SkipRowIfNotSet($configuration, 'skip_row_if_not_set', []); + $this->setExpectedException(MigrateSkipRowException::class); + $process->transform('', $this->migrateExecutable, $this->row, 'destinationproperty'); + } + + /** + * Tests that a skip row exception with a message is raised. + * + * @covers ::transform + */ + public function testRowSkipWithMessage() { + $configuration = [ + 'index' => 'some_key', + 'message' => "The 'some_key' key is not set", + ]; + $process = new SkipRowIfNotSet($configuration, 'skip_row_if_not_set', []); + $this->setExpectedException(MigrateSkipRowException::class, "The 'some_key' key is not set"); + $process->transform('', $this->migrateExecutable, $this->row, 'destinationproperty'); + } + +} diff --git a/core/modules/migrate_drupal/migrate_drupal.services.yml b/core/modules/migrate_drupal/migrate_drupal.services.yml index 71a0b27..23b3492 100644 --- a/core/modules/migrate_drupal/migrate_drupal.services.yml +++ b/core/modules/migrate_drupal/migrate_drupal.services.yml @@ -1,4 +1,12 @@ services: + plugin.manager.migrate.field: + class: Drupal\migrate_drupal\Plugin\MigrateFieldPluginManager + arguments: + - field + - '@container.namespaces' + - '@cache.discovery' + - '@module_handler' + - '\Drupal\migrate_drupal\Annotation\MigrateField' plugin.manager.migrate.cckfield: class: Drupal\migrate_drupal\Plugin\MigrateCckFieldPluginManager arguments: @@ -7,3 +15,4 @@ services: - '@cache.discovery' - '@module_handler' - '\Drupal\migrate_drupal\Annotation\MigrateCckField' + deprecated: The "%service_id%" service is deprecated. You should use the 'plugin.manager.migrate.field' service instead. See https://www.drupal.org/node/2751897 diff --git a/core/modules/migrate_drupal/src/Annotation/MigrateCckField.php b/core/modules/migrate_drupal/src/Annotation/MigrateCckField.php index 5e5a500..73949e1 100644 --- a/core/modules/migrate_drupal/src/Annotation/MigrateCckField.php +++ b/core/modules/migrate_drupal/src/Annotation/MigrateCckField.php @@ -2,53 +2,20 @@ namespace Drupal\migrate_drupal\Annotation; -use Drupal\Component\Annotation\Plugin; +@trigger_error('MigrateCckField is deprecated in Drupal 8.3.x and will be +removed before Drupal 9.0.x. Use \Drupal\migrate_drupal\Annotation\MigrateField +instead.', E_USER_DEPRECATED); /** - * Defines a cckfield plugin annotation object. + * Deprecated: Defines a cckfield plugin annotation object. * - * cckfield plugins are variously responsible for handling the migration of - * CCK fields from Drupal 6 to Drupal 8, and Field API fields from Drupal 7 - * to Drupal 8. They are allowed to alter CCK-related migrations when migrations - * are being generated, and can compute destination field types for individual - * fields during the actual migration process. + * @deprecated in Drupal 8.3.x, to be removed before Drupal 9.0.x. Use + * \Drupal\migrate_drupal\Annotation\MigrateField instead. * * Plugin Namespace: Plugin\migrate\cckfield * * @Annotation */ -class MigrateCckField extends Plugin { - - /** - * @inheritdoc - */ - public function __construct($values) { - parent::__construct($values); - // Provide default value for core property, in case it's missing. - if (empty($this->definition['core'])) { - $this->definition['core'] = [6]; - } - } - - /** - * The plugin ID. - * - * @var string - */ - public $id; - - /** - * Map of D6 and D7 field types to D8 field type plugin IDs. - * - * @var string[] - */ - public $type_map = []; - - /** - * The Drupal core version(s) this plugin applies to. - * - * @var int[] - */ - public $core = []; +class MigrateCckField extends MigrateField { } diff --git a/core/modules/migrate_drupal/src/Annotation/MigrateField.php b/core/modules/migrate_drupal/src/Annotation/MigrateField.php new file mode 100644 index 0000000..ad78bc9 --- /dev/null +++ b/core/modules/migrate_drupal/src/Annotation/MigrateField.php @@ -0,0 +1,54 @@ +definition['core'])) { + $this->definition['core'] = [6]; + } + } + + /** + * The plugin ID. + * + * @var string + */ + public $id; + + /** + * Map of D6 and D7 field types to D8 field type plugin IDs. + * + * @var string[] + */ + public $type_map = []; + + /** + * The Drupal core version(s) this plugin applies to. + * + * @var int[] + */ + public $core = []; + +} diff --git a/core/modules/migrate_drupal/src/Plugin/MigrateCckFieldInterface.php b/core/modules/migrate_drupal/src/Plugin/MigrateCckFieldInterface.php index b30b2b8..c64738f 100644 --- a/core/modules/migrate_drupal/src/Plugin/MigrateCckFieldInterface.php +++ b/core/modules/migrate_drupal/src/Plugin/MigrateCckFieldInterface.php @@ -2,64 +2,19 @@ namespace Drupal\migrate_drupal\Plugin; -use Drupal\Component\Plugin\PluginInspectionInterface; +@trigger_error('MigrateCckFieldInterface is deprecated in Drupal 8.3.x and will +be removed before Drupal 9.0.x. Use \Drupal\migrate_drupal\Annotation\MigrateField +instead.', E_USER_DEPRECATED); + use Drupal\migrate\Plugin\MigrationInterface; -use Drupal\migrate\Row; /** * Provides an interface for all CCK field type plugins. + * + * @deprecated in Drupal 8.3.x, to be removed before Drupal 9.0.x. Use + * \Drupal\migrate_drupal\Annotation\MigrateField instead. */ -interface MigrateCckFieldInterface extends PluginInspectionInterface { - - /** - * Apply any custom processing to the field migration. - * - * @param \Drupal\migrate\Plugin\MigrationInterface $migration - * The migration entity. - */ - public function processField(MigrationInterface $migration); - - /** - * Apply any custom processing to the field instance migration. - * - * @param \Drupal\migrate\Plugin\MigrationInterface $migration - * The migration entity. - */ - public function processFieldInstance(MigrationInterface $migration); - - /** - * Apply any custom processing to the field widget migration. - * - * @param \Drupal\migrate\Plugin\MigrationInterface $migration - * The migration entity. - */ - public function processFieldWidget(MigrationInterface $migration); - - /** - * Apply any custom processing to the field formatter migration. - * - * @param \Drupal\migrate\Plugin\MigrationInterface $migration - * The migration entity. - */ - public function processFieldFormatter(MigrationInterface $migration); - - /** - * Get a map between D6 formatters and D8 formatters for this field type. - * - * This is used by static::processFieldFormatter() in the base class. - * - * @return array - * The keys are D6 formatters and the values are D8 formatters. - */ - public function getFieldFormatterMap(); - - /** - * Get a map between D6 and D8 widgets for this field type. - * - * @return array - * The keys are D6 field widget types and the values D8 widgets. - */ - public function getFieldWidgetMap(); +interface MigrateCckFieldInterface extends MigrateFieldInterface { /** * Apply any custom processing to the cck bundle migrations. @@ -73,15 +28,4 @@ public function getFieldWidgetMap(); */ public function processCckFieldValues(MigrationInterface $migration, $field_name, $data); - /** - * Computes the destination type of a migrated field. - * - * @param \Drupal\migrate\Row $row - * The field being migrated. - * - * @return string - * The destination field type. - */ - public function getFieldType(Row $row); - } diff --git a/core/modules/migrate_drupal/src/Plugin/MigrateCckFieldPluginManager.php b/core/modules/migrate_drupal/src/Plugin/MigrateCckFieldPluginManager.php index 56c7b5b..9ade1e5 100644 --- a/core/modules/migrate_drupal/src/Plugin/MigrateCckFieldPluginManager.php +++ b/core/modules/migrate_drupal/src/Plugin/MigrateCckFieldPluginManager.php @@ -2,54 +2,16 @@ namespace Drupal\migrate_drupal\Plugin; -use Drupal\Component\Plugin\Exception\PluginNotFoundException; -use Drupal\migrate\Plugin\MigratePluginManager; -use Drupal\migrate\Plugin\MigrationInterface; +@trigger_error('MigrateCckFieldPluginManager is deprecated in Drupal 8.3.x and will +be removed before Drupal 9.0.x. Use \Drupal\migrate_drupal\Annotation\MigrateFieldPluginManager +instead.', E_USER_DEPRECATED); /** - * Plugin manager for migrate cckfield plugins. + * Deprecated: Plugin manager for migrate field plugins. * - * @see \Drupal\migrate_drupal\Plugin\MigrateCckFieldInterface - * @see \Drupal\migrate\Annotation\MigrateCckField - * @see plugin_api + * @deprecated in Drupal 8.3.x, to be removed before Drupal 9.0.x. Use + * \Drupal\migrate_drupal\Plugin\MigrateFieldPluginManager instead. * * @ingroup migration */ -class MigrateCckFieldPluginManager extends MigratePluginManager implements MigrateCckFieldPluginManagerInterface { - - /** - * The default version of core to use for cck field plugins. - * - * These plugins were initially only built and used for Drupal 6 fields. - * Having been extended for Drupal 7 with a "core" annotation, we fall back to - * Drupal 6 where none exists. - */ - const DEFAULT_CORE_VERSION = 6; - - /** - * {@inheritdoc} - */ - public function getPluginIdFromFieldType($field_type, array $configuration = [], MigrationInterface $migration = NULL) { - $core = static::DEFAULT_CORE_VERSION; - if (!empty($configuration['core'])) { - $core = $configuration['core']; - } - elseif (!empty($migration->getPluginDefinition()['migration_tags'])) { - foreach ($migration->getPluginDefinition()['migration_tags'] as $tag) { - if ($tag == 'Drupal 7') { - $core = 7; - } - } - } - - foreach ($this->getDefinitions() as $plugin_id => $definition) { - if (in_array($core, $definition['core'])) { - if (array_key_exists($field_type, $definition['type_map']) || $field_type === $plugin_id) { - return $plugin_id; - } - } - } - throw new PluginNotFoundException($field_type); - } - -} +class MigrateCckFieldPluginManager extends MigrateFieldPluginManager implements MigrateCckFieldPluginManagerInterface { } diff --git a/core/modules/migrate_drupal/src/Plugin/MigrateCckFieldPluginManagerInterface.php b/core/modules/migrate_drupal/src/Plugin/MigrateCckFieldPluginManagerInterface.php index a5371b9..3d88d77 100644 --- a/core/modules/migrate_drupal/src/Plugin/MigrateCckFieldPluginManagerInterface.php +++ b/core/modules/migrate_drupal/src/Plugin/MigrateCckFieldPluginManagerInterface.php @@ -2,27 +2,14 @@ namespace Drupal\migrate_drupal\Plugin; -use Drupal\migrate\Plugin\MigratePluginManagerInterface; -use Drupal\migrate\Plugin\MigrationInterface; - -interface MigrateCckFieldPluginManagerInterface extends MigratePluginManagerInterface { - - /** - * Get the plugin ID from the field type. - * - * @param string $field_type - * The field type being migrated. - * @param array $configuration - * (optional) An array of configuration relevant to the plugin instance. - * @param \Drupal\migrate\Plugin\MigrationInterface|null $migration - * (optional) The current migration instance. - * - * @return string - * The ID of the plugin for the field_type if available. - * - * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException - * If the plugin cannot be determined, such as if the field type is invalid. - */ - public function getPluginIdFromFieldType($field_type, array $configuration = [], MigrationInterface $migration = NULL); - -} +@trigger_error('MigrateCckFieldPluginManagerInterface is deprecated in Drupal 8.3.x +and will be removed before Drupal 9.0.x. Use \Drupal\migrate_drupal\Annotation\MigrateFieldPluginManagerInterface +instead.', E_USER_DEPRECATED); + +/** + * Provides an interface for cck field plugin manager. + * + * @deprecated in Drupal 8.3.x, to be removed before Drupal 9.0.x. Use + * \Drupal\migrate_drupal\Plugin\MigrateFieldPluginManagerInterface instead. + */ +interface MigrateCckFieldPluginManagerInterface extends MigrateFieldPluginManagerInterface { } diff --git a/core/modules/migrate_drupal/src/Plugin/MigrateFieldInterface.php b/core/modules/migrate_drupal/src/Plugin/MigrateFieldInterface.php new file mode 100644 index 0000000..c8cecad --- /dev/null +++ b/core/modules/migrate_drupal/src/Plugin/MigrateFieldInterface.php @@ -0,0 +1,87 @@ +getPluginDefinition()['migration_tags'])) { + foreach ($migration->getPluginDefinition()['migration_tags'] as $tag) { + if ($tag == 'Drupal 7') { + $core = 7; + } + } + } + + $definitions = $this->getDefinitions(); + foreach ($definitions as $plugin_id => $definition) { + if (in_array($core, $definition['core'])) { + if (array_key_exists($field_type, $definition['type_map']) || $field_type === $plugin_id) { + return $plugin_id; + } + } + } + throw new PluginNotFoundException($field_type); + } + +} diff --git a/core/modules/migrate_drupal/src/Plugin/MigrateFieldPluginManagerInterface.php b/core/modules/migrate_drupal/src/Plugin/MigrateFieldPluginManagerInterface.php new file mode 100644 index 0000000..7219c2b --- /dev/null +++ b/core/modules/migrate_drupal/src/Plugin/MigrateFieldPluginManagerInterface.php @@ -0,0 +1,28 @@ +cckPluginManager = $cck_manager; - } +class CckMigration extends FieldMigration { /** * {@inheritdoc} */ - public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { - return new static( - $configuration, - $plugin_id, - $plugin_definition, - $container->get('plugin.manager.migrate.cckfield'), - $container->get('plugin.manager.migration'), - $container->get('plugin.manager.migrate.source'), - $container->get('plugin.manager.migrate.process'), - $container->get('plugin.manager.migrate.destination'), - $container->get('plugin.manager.migrate.id_map') - ); - } - - /** - * {@inheritdoc} - */ - public function getProcess() { - if (!$this->init) { - $this->init = TRUE; - $source_plugin = $this->migrationPluginManager->createInstance($this->pluginId)->getSourcePlugin(); - if ($source_plugin instanceof RequirementsInterface) { - try { - $source_plugin->checkRequirements(); - } - catch (RequirementsException $e) { - // Kill the rest of the method. - $source_plugin = []; - } - } - foreach ($source_plugin as $row) { - $field_type = $row->getSourceProperty('type'); - try { - $plugin_id = $this->cckPluginManager->getPluginIdFromFieldType($field_type, [], $this); - } - catch (PluginNotFoundException $ex) { - continue; - } - - if (!isset($this->processedFieldTypes[$field_type])) { - $this->processedFieldTypes[$field_type] = TRUE; - // Allow the cckfield plugin to alter the migration as necessary so - // that it knows how to handle fields of this type. - if (!isset($this->cckPluginCache[$field_type])) { - $this->cckPluginCache[$field_type] = $this->cckPluginManager->createInstance($plugin_id, [], $this); - } - call_user_func([$this->cckPluginCache[$field_type], $this->pluginDefinition['cck_plugin_method']], $this); - } - } - } - return parent::getProcess(); - } + const PLUGIN_METHOD = 'cck_plugin_method'; } diff --git a/core/modules/migrate_drupal/src/Plugin/migrate/FieldMigration.php b/core/modules/migrate_drupal/src/Plugin/migrate/FieldMigration.php new file mode 100644 index 0000000..c7aaa2d --- /dev/null +++ b/core/modules/migrate_drupal/src/Plugin/migrate/FieldMigration.php @@ -0,0 +1,171 @@ +cckPluginManager = $cck_manager; + $this->fieldPluginManager = $field_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('plugin.manager.migrate.cckfield'), + $container->get('plugin.manager.migrate.field'), + $container->get('plugin.manager.migration'), + $container->get('plugin.manager.migrate.source'), + $container->get('plugin.manager.migrate.process'), + $container->get('plugin.manager.migrate.destination'), + $container->get('plugin.manager.migrate.id_map') + ); + } + + /** + * {@inheritdoc} + */ + public function getProcess() { + if (!$this->init) { + $this->init = TRUE; + $source_plugin = $this->migrationPluginManager->createInstance($this->pluginId)->getSourcePlugin(); + if ($source_plugin instanceof RequirementsInterface) { + try { + $source_plugin->checkRequirements(); + } + catch (RequirementsException $e) { + // Kill the rest of the method. + $source_plugin = []; + } + } + foreach ($source_plugin as $row) { + $field_type = $row->getSourceProperty('type'); + + try { + $plugin_id = $this->cckPluginManager->getPluginIdFromFieldType($field_type, [], $this); + $Manager = $this->cckPluginManager; + } + catch (PluginNotFoundException $ex) { + try { + $plugin_id = $this->fieldPluginManager->getPluginIdFromFieldType($field_type, [], $this); + $Manager = $this->fieldPluginManager; + } + catch (PluginNotFoundException $ex) { + continue; + } + } + + if (!isset($this->processedFieldTypes[$field_type]) && $Manager->hasDefinition($plugin_id)) { + $this->processedFieldTypes[$field_type] = TRUE; + // Allow the field plugin to alter the migration as necessary so that + // it knows how to handle fields of this type. + if (!isset($this->fieldPluginCache[$field_type])) { + $this->fieldPluginCache[$field_type] = $Manager->createInstance($plugin_id, [], $this); + } + } + $method = $this->pluginDefinition[static::PLUGIN_METHOD]; + call_user_func([$this->fieldPluginCache[$field_type], $method], $this); + } + } + return parent::getProcess(); + } + +} diff --git a/core/modules/migrate_drupal/src/Plugin/migrate/cckfield/CckFieldPluginBase.php b/core/modules/migrate_drupal/src/Plugin/migrate/cckfield/CckFieldPluginBase.php index d942bce..79a65a7 100644 --- a/core/modules/migrate_drupal/src/Plugin/migrate/cckfield/CckFieldPluginBase.php +++ b/core/modules/migrate_drupal/src/Plugin/migrate/cckfield/CckFieldPluginBase.php @@ -2,82 +2,49 @@ namespace Drupal\migrate_drupal\Plugin\migrate\cckfield; -use Drupal\Core\Plugin\PluginBase; +@trigger_error('CckFieldPluginBase is deprecated in Drupal 8.3.x and will be +be removed before Drupal 9.0.x. Use \Drupal\migrate_drupal\Plugin\migrate\field\FieldPluginBase +instead.', E_USER_DEPRECATED); + use Drupal\migrate\Plugin\MigrationInterface; -use Drupal\migrate\Row; -use Drupal\migrate_drupal\Plugin\MigrateCckFieldInterface; +use Drupal\migrate_drupal\Plugin\migrate\field\FieldPluginBase; /** - * The base class for all cck field plugins. + * The base class for all field plugins. * - * @see \Drupal\migrate\Plugin\MigratePluginManager - * @see \Drupal\migrate_drupal\Annotation\MigrateCckField - * @see \Drupal\migrate_drupal\Plugin\MigrateCckFieldInterface - * @see plugin_api + * @deprecated in Drupal 8.3.x, to be removed before Drupal 9.0.x. Use + * \Drupal\migrate_drupal\Plugin\migrate\field\FieldPluginBase instead. * * @ingroup migration */ -abstract class CckFieldPluginBase extends PluginBase implements MigrateCckFieldInterface { - - /** - * {@inheritdoc} - */ - public function processField(MigrationInterface $migration) { - $process[0]['map'][$this->pluginId][$this->pluginId] = $this->pluginId; - $migration->mergeProcessOfProperty('type', $process); - } +abstract class CckFieldPluginBase extends FieldPluginBase { /** - * {@inheritdoc} + * Apply any custom processing to the field bundle migrations. + * + * @param \Drupal\migrate\Plugin\MigrationInterface $migration + * The migration entity. + * @param string $field_name + * The field name we're processing the value for. + * @param array $data + * The array of field data from FieldValues::fieldData(). */ - public function processFieldInstance(MigrationInterface $migration) { - // Nothing to do by default with field instances. + public function processFieldValues(MigrationInterface $migration, $field_name, $data) { + // Provide a bridge to the old method declared on the interface and now an + // abstract method in this class. + return $this->processCckFieldValues($migration, $field_name, $data); } /** - * {@inheritdoc} + * Apply any custom processing to the field bundle migrations. + * + * @param \Drupal\migrate\Plugin\MigrationInterface $migration + * The migration entity. + * @param string $field_name + * The field name we're processing the value for. + * @param array $data + * The array of field data from FieldValues::fieldData(). */ - public function processFieldWidget(MigrationInterface $migration) { - $process = []; - foreach ($this->getFieldWidgetMap() as $source_widget => $destination_widget) { - $process['type']['map'][$source_widget] = $destination_widget; - } - $migration->mergeProcessOfProperty('options/type', $process); - } - - /** - * {@inheritdoc} - */ - public function getFieldWidgetMap() { - // By default, use the plugin ID for the widget types. - return [ - $this->pluginId => $this->pluginId . '_default', - ]; - } - - /** - * {@inheritdoc} - */ - public function processFieldFormatter(MigrationInterface $migration) { - $process = []; - foreach ($this->getFieldFormatterMap() as $source_format => $destination_format) { - $process[0]['map'][$this->pluginId][$source_format] = $destination_format; - } - $migration->mergeProcessOfProperty('options/type', $process); - } - - /** - * {@inheritdoc} - */ - public function getFieldType(Row $row) { - $field_type = $row->getSourceProperty('type'); - - if (isset($this->pluginDefinition['type_map'][$field_type])) { - return $this->pluginDefinition['type_map'][$field_type]; - } - else { - return $field_type; - } - } + abstract public function processCckFieldValues(MigrationInterface $migration, $field_name, $data); } diff --git a/core/modules/migrate_drupal/src/Plugin/migrate/field/FieldPluginBase.php b/core/modules/migrate_drupal/src/Plugin/migrate/field/FieldPluginBase.php new file mode 100644 index 0000000..852c6ca --- /dev/null +++ b/core/modules/migrate_drupal/src/Plugin/migrate/field/FieldPluginBase.php @@ -0,0 +1,83 @@ +pluginId][$this->pluginId] = $this->pluginId; + $migration->mergeProcessOfProperty('type', $process); + } + + /** + * {@inheritdoc} + */ + public function processFieldInstance(MigrationInterface $migration) { + // Nothing to do by default with field instances. + } + + /** + * {@inheritdoc} + */ + public function processFieldWidget(MigrationInterface $migration) { + $process = []; + foreach ($this->getFieldWidgetMap() as $source_widget => $destination_widget) { + $process['type']['map'][$source_widget] = $destination_widget; + } + $migration->mergeProcessOfProperty('options/type', $process); + } + + /** + * {@inheritdoc} + */ + public function getFieldWidgetMap() { + // By default, use the plugin ID for the widget types. + return [ + $this->pluginId => $this->pluginId . '_default', + ]; + } + + /** + * {@inheritdoc} + */ + public function processFieldFormatter(MigrationInterface $migration) { + $process = []; + foreach ($this->getFieldFormatterMap() as $source_format => $destination_format) { + $process[0]['map'][$this->pluginId][$source_format] = $destination_format; + } + $migration->mergeProcessOfProperty('options/type', $process); + } + + /** + * {@inheritdoc} + */ + public function getFieldType(Row $row) { + $field_type = $row->getSourceProperty('type'); + + if (isset($this->pluginDefinition['type_map'][$field_type])) { + return $this->pluginDefinition['type_map'][$field_type]; + } + else { + return $field_type; + } + } + +} diff --git a/core/modules/migrate_drupal/tests/fixtures/drupal6.php b/core/modules/migrate_drupal/tests/fixtures/drupal6.php index 71bccd4..73431ef 100644 --- a/core/modules/migrate_drupal/tests/fixtures/drupal6.php +++ b/core/modules/migrate_drupal/tests/fixtures/drupal6.php @@ -43,6 +43,75 @@ 'mysql_character_set' => 'utf8', )); +$connection->schema()->createTable('accesslog', array( + 'fields' => array( + 'aid' => array( + 'type' => 'serial', + 'not null' => TRUE, + 'size' => 'normal', + ), + 'sid' => array( + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => '64', + 'default' => '', + ), + 'title' => array( + 'type' => 'varchar', + 'not null' => FALSE, + 'length' => '255', + ), + 'path' => array( + 'type' => 'varchar', + 'not null' => FALSE, + 'length' => '255', + ), + 'url' => array( + 'type' => 'text', + 'not null' => FALSE, + 'size' => 'normal', + ), + 'hostname' => array( + 'type' => 'varchar', + 'not null' => FALSE, + 'length' => '128', + ), + 'uid' => array( + 'type' => 'int', + 'not null' => FALSE, + 'size' => 'normal', + 'default' => '0', + 'unsigned' => TRUE, + ), + 'timer' => array( + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => '0', + 'unsigned' => TRUE, + ), + 'timestamp' => array( + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => '0', + 'unsigned' => TRUE, + ), + ), + 'primary key' => array( + 'aid', + ), + 'indexes' => array( + 'accesslog_timestamp' => array( + 'timestamp', + ), + 'uid' => array( + 'uid', + ), + ), + 'mysql_character_set' => 'utf8', +)); + $connection->schema()->createTable('actions', array( 'fields' => array( 'aid' => array( @@ -391,7 +460,7 @@ 'image' => 'http://b.thumbs.redditmedia.com/harEHsUUZVajabtC.png', 'etag' => '"213cc1365b96c310e92053c5551f0504"', 'modified' => '0', - 'block' => '5', + 'block' => '7', )) ->execute(); @@ -641,7 +710,7 @@ 'delta' => '0', 'theme' => 'garland', 'status' => '1', - 'weight' => '0', + 'weight' => '-10', 'region' => 'left', 'custom' => '0', 'throttle' => '0', @@ -656,7 +725,7 @@ 'delta' => '1', 'theme' => 'garland', 'status' => '1', - 'weight' => '0', + 'weight' => '-11', 'region' => 'left', 'custom' => '0', 'throttle' => '0', @@ -746,7 +815,7 @@ 'delta' => '2', 'theme' => 'garland', 'status' => '1', - 'weight' => '-9', + 'weight' => '-11', 'region' => 'right', 'custom' => '0', 'throttle' => '0', @@ -761,7 +830,7 @@ 'delta' => '3', 'theme' => 'garland', 'status' => '1', - 'weight' => '-6', + 'weight' => '-10', 'region' => 'right', 'custom' => '0', 'throttle' => '0', @@ -835,9 +904,9 @@ 'module' => 'aggregator', 'delta' => 'feed-5', 'theme' => 'garland', - 'status' => '0', + 'status' => '1', 'weight' => '-2', - 'region' => '', + 'region' => 'right', 'custom' => '0', 'throttle' => '0', 'visibility' => '0', @@ -925,9 +994,9 @@ 'module' => 'book', 'delta' => '0', 'theme' => 'garland', - 'status' => '0', + 'status' => '1', 'weight' => '-4', - 'region' => '', + 'region' => 'right', 'custom' => '0', 'throttle' => '0', 'visibility' => '0', @@ -950,6 +1019,51 @@ 'title' => '', 'cache' => '-1', )) +->values(array( + 'bid' => '22', + 'module' => 'forum', + 'delta' => '0', + 'theme' => 'garland', + 'status' => '1', + 'weight' => '-8', + 'region' => 'left', + 'custom' => '0', + 'throttle' => '0', + 'visibility' => '0', + 'pages' => '', + 'title' => '', + 'cache' => '1', +)) +->values(array( + 'bid' => '23', + 'module' => 'forum', + 'delta' => '1', + 'theme' => 'garland', + 'status' => '1', + 'weight' => '-9', + 'region' => 'left', + 'custom' => '0', + 'throttle' => '0', + 'visibility' => '0', + 'pages' => '', + 'title' => '', + 'cache' => '1', +)) +->values(array( + 'bid' => '24', + 'module' => 'statistics', + 'delta' => '0', + 'theme' => 'garland', + 'status' => '1', + 'weight' => '0', + 'region' => 'right', + 'custom' => '0', + 'throttle' => '0', + 'visibility' => '0', + 'pages' => '', + 'title' => '', + 'cache' => '-1', +)) ->execute(); $connection->schema()->createTable('blocks_roles', array( @@ -7992,6 +8106,44 @@ 'mysql_character_set' => 'utf8', )); +$connection->schema()->createTable('forum', array( + 'fields' => array( + 'nid' => array( + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => '0', + 'unsigned' => TRUE, + ), + 'vid' => array( + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => '0', + 'unsigned' => TRUE, + ), + 'tid' => array( + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => '0', + 'unsigned' => TRUE, + ), + ), + 'primary key' => array( + 'vid', + ), + 'indexes' => array( + 'nid' => array( + 'nid', + ), + 'tid' => array( + 'tid', + ), + ), + 'mysql_character_set' => 'utf8', +)); + $connection->schema()->createTable('history', array( 'fields' => array( 'uid' => array( @@ -9148,6 +9300,38 @@ 'objectindex' => '139', 'format' => '0', )) +->values(array( + 'lid' => '1664', + 'objectid' => 'forum', + 'type' => 'type', + 'property' => 'name', + 'objectindex' => '0', + 'format' => '0', +)) +->values(array( + 'lid' => '1665', + 'objectid' => 'forum', + 'type' => 'type', + 'property' => 'title', + 'objectindex' => '0', + 'format' => '0', +)) +->values(array( + 'lid' => '1666', + 'objectid' => 'forum', + 'type' => 'type', + 'property' => 'body', + 'objectindex' => '0', + 'format' => '0', +)) +->values(array( + 'lid' => '1667', + 'objectid' => 'forum', + 'type' => 'type', + 'property' => 'description', + 'objectindex' => '0', + 'format' => '0', +)) ->execute(); $connection->schema()->createTable('i18n_variable', array( @@ -9194,6 +9378,21 @@ 'value' => 'a:2:{i:0;i:1;i:1;i:2;}', )) ->values(array( + 'name' => 'statistics_count_content_views', + 'language' => 'en', + 'value' => 's:1:"1";', +)) +->values(array( + 'name' => 'statistics_enable_access_log', + 'language' => 'en', + 'value' => 's:1:"0";', +)) +->values(array( + 'name' => 'statistics_flush_accesslog_timer', + 'language' => 'en', + 'value' => 's:6:"259200";', +)) +->values(array( 'name' => 'anonymous', 'language' => 'fr', 'value' => 's:8:"fr Guest";', @@ -21529,6 +21728,41 @@ 'source' => 'Delete all translations', 'version' => '6.38-dev', )) +->values(array( + 'lid' => '1664', + 'location' => 'type:forum:name', + 'textgroup' => 'nodetype', + 'source' => 'Forum topic', + 'version' => '1', +)) +->values(array( + 'lid' => '1665', + 'location' => 'type:forum:title', + 'textgroup' => 'nodetype', + 'source' => 'Subject', + 'version' => '1', +)) +->values(array( + 'lid' => '1666', + 'location' => 'type:forum:body', + 'textgroup' => 'nodetype', + 'source' => 'Body', + 'version' => '1', +)) +->values(array( + 'lid' => '1667', + 'location' => 'type:forum:description', + 'textgroup' => 'nodetype', + 'source' => 'A forum topic is the initial post to a new discussion thread within a forum.', + 'version' => '1', +)) +->values(array( + 'lid' => '1668', + 'location' => 'modules/block/block.js', + 'textgroup' => 'default', + 'source' => 'The changes to these blocks will not be saved until the Save blocks button is clicked.', + 'version' => 'none', +)) ->execute(); $connection->schema()->createTable('locales_target', array( @@ -32562,6 +32796,384 @@ 'p9' => '0', 'updated' => '0', )) +->values(array( + 'menu_name' => 'navigation', + 'mlid' => '441', + 'plid' => '0', + 'link_path' => 'forum', + 'router_path' => 'forum', + 'link_title' => 'Forums', + 'options' => 'a:0:{}', + 'module' => 'system', + 'hidden' => '1', + 'external' => '0', + 'has_children' => '0', + 'expanded' => '0', + 'weight' => '0', + 'depth' => '1', + 'customized' => '0', + 'p1' => '441', + 'p2' => '0', + 'p3' => '0', + 'p4' => '0', + 'p5' => '0', + 'p6' => '0', + 'p7' => '0', + 'p8' => '0', + 'p9' => '0', + 'updated' => '0', +)) +->values(array( + 'menu_name' => 'navigation', + 'mlid' => '442', + 'plid' => '165', + 'link_path' => 'admin/reports/settings', + 'router_path' => 'admin/reports/settings', + 'link_title' => 'Access log settings', + 'options' => 'a:1:{s:10:"attributes";a:1:{s:5:"title";s:50:"Control details about what and how your site logs.";}}', + 'module' => 'system', + 'hidden' => '0', + 'external' => '0', + 'has_children' => '0', + 'expanded' => '0', + 'weight' => '3', + 'depth' => '3', + 'customized' => '0', + 'p1' => '144', + 'p2' => '165', + 'p3' => '442', + 'p4' => '0', + 'p5' => '0', + 'p6' => '0', + 'p7' => '0', + 'p8' => '0', + 'p9' => '0', + 'updated' => '0', +)) +->values(array( + 'menu_name' => 'navigation', + 'mlid' => '443', + 'plid' => '158', + 'link_path' => 'node/add/forum', + 'router_path' => 'node/add/forum', + 'link_title' => 'Forum topic', + 'options' => 'a:1:{s:10:"attributes";a:1:{s:5:"title";s:85:"A forum topic is the initial post to a new discussion thread within a forum.";}}', + 'module' => 'system', + 'hidden' => '0', + 'external' => '0', + 'has_children' => '0', + 'expanded' => '0', + 'weight' => '0', + 'depth' => '2', + 'customized' => '0', + 'p1' => '158', + 'p2' => '443', + 'p3' => '0', + 'p4' => '0', + 'p5' => '0', + 'p6' => '0', + 'p7' => '0', + 'p8' => '0', + 'p9' => '0', + 'updated' => '0', +)) +->values(array( + 'menu_name' => 'navigation', + 'mlid' => '444', + 'plid' => '157', + 'link_path' => 'admin/content/forum', + 'router_path' => 'admin/content/forum', + 'link_title' => 'Forums', + 'options' => 'a:1:{s:10:"attributes";a:1:{s:5:"title";s:61:"Control forums and their hierarchy and change forum settings.";}}', + 'module' => 'system', + 'hidden' => '0', + 'external' => '0', + 'has_children' => '0', + 'expanded' => '0', + 'weight' => '0', + 'depth' => '3', + 'customized' => '0', + 'p1' => '144', + 'p2' => '157', + 'p3' => '444', + 'p4' => '0', + 'p5' => '0', + 'p6' => '0', + 'p7' => '0', + 'p8' => '0', + 'p9' => '0', + 'updated' => '0', +)) +->values(array( + 'menu_name' => 'navigation', + 'mlid' => '445', + 'plid' => '165', + 'link_path' => 'admin/reports/hits', + 'router_path' => 'admin/reports/hits', + 'link_title' => 'Recent hits', + 'options' => 'a:1:{s:10:"attributes";a:1:{s:5:"title";s:43:"View pages that have recently been visited.";}}', + 'module' => 'system', + 'hidden' => '0', + 'external' => '0', + 'has_children' => '0', + 'expanded' => '0', + 'weight' => '0', + 'depth' => '3', + 'customized' => '0', + 'p1' => '144', + 'p2' => '165', + 'p3' => '445', + 'p4' => '0', + 'p5' => '0', + 'p6' => '0', + 'p7' => '0', + 'p8' => '0', + 'p9' => '0', + 'updated' => '0', +)) +->values(array( + 'menu_name' => 'navigation', + 'mlid' => '446', + 'plid' => '165', + 'link_path' => 'admin/reports/pages', + 'router_path' => 'admin/reports/pages', + 'link_title' => 'Top pages', + 'options' => 'a:1:{s:10:"attributes";a:1:{s:5:"title";s:41:"View pages that have been hit frequently.";}}', + 'module' => 'system', + 'hidden' => '0', + 'external' => '0', + 'has_children' => '0', + 'expanded' => '0', + 'weight' => '1', + 'depth' => '3', + 'customized' => '0', + 'p1' => '144', + 'p2' => '165', + 'p3' => '446', + 'p4' => '0', + 'p5' => '0', + 'p6' => '0', + 'p7' => '0', + 'p8' => '0', + 'p9' => '0', + 'updated' => '0', +)) +->values(array( + 'menu_name' => 'navigation', + 'mlid' => '447', + 'plid' => '165', + 'link_path' => 'admin/reports/referrers', + 'router_path' => 'admin/reports/referrers', + 'link_title' => 'Top referrers', + 'options' => 'a:1:{s:10:"attributes";a:1:{s:5:"title";s:19:"View top referrers.";}}', + 'module' => 'system', + 'hidden' => '0', + 'external' => '0', + 'has_children' => '0', + 'expanded' => '0', + 'weight' => '0', + 'depth' => '3', + 'customized' => '0', + 'p1' => '144', + 'p2' => '165', + 'p3' => '447', + 'p4' => '0', + 'p5' => '0', + 'p6' => '0', + 'p7' => '0', + 'p8' => '0', + 'p9' => '0', + 'updated' => '0', +)) +->values(array( + 'menu_name' => 'navigation', + 'mlid' => '448', + 'plid' => '165', + 'link_path' => 'admin/reports/visitors', + 'router_path' => 'admin/reports/visitors', + 'link_title' => 'Top visitors', + 'options' => 'a:1:{s:10:"attributes";a:1:{s:5:"title";s:34:"View visitors that hit many pages.";}}', + 'module' => 'system', + 'hidden' => '0', + 'external' => '0', + 'has_children' => '0', + 'expanded' => '0', + 'weight' => '2', + 'depth' => '3', + 'customized' => '0', + 'p1' => '144', + 'p2' => '165', + 'p3' => '448', + 'p4' => '0', + 'p5' => '0', + 'p6' => '0', + 'p7' => '0', + 'p8' => '0', + 'p9' => '0', + 'updated' => '0', +)) +->values(array( + 'menu_name' => 'navigation', + 'mlid' => '449', + 'plid' => '165', + 'link_path' => 'admin/reports/access/%', + 'router_path' => 'admin/reports/access/%', + 'link_title' => 'Details', + 'options' => 'a:1:{s:10:"attributes";a:1:{s:5:"title";s:16:"View access log.";}}', + 'module' => 'system', + 'hidden' => '-1', + 'external' => '0', + 'has_children' => '0', + 'expanded' => '0', + 'weight' => '0', + 'depth' => '3', + 'customized' => '0', + 'p1' => '144', + 'p2' => '165', + 'p3' => '449', + 'p4' => '0', + 'p5' => '0', + 'p6' => '0', + 'p7' => '0', + 'p8' => '0', + 'p9' => '0', + 'updated' => '0', +)) +->values(array( + 'menu_name' => 'navigation', + 'mlid' => '450', + 'plid' => '157', + 'link_path' => 'admin/content/node-type/forum', + 'router_path' => 'admin/content/node-type/forum', + 'link_title' => 'Forum topic', + 'options' => 'a:0:{}', + 'module' => 'system', + 'hidden' => '-1', + 'external' => '0', + 'has_children' => '0', + 'expanded' => '0', + 'weight' => '0', + 'depth' => '3', + 'customized' => '0', + 'p1' => '144', + 'p2' => '157', + 'p3' => '450', + 'p4' => '0', + 'p5' => '0', + 'p6' => '0', + 'p7' => '0', + 'p8' => '0', + 'p9' => '0', + 'updated' => '0', +)) +->values(array( + 'menu_name' => 'navigation', + 'mlid' => '451', + 'plid' => '444', + 'link_path' => 'admin/content/forum/edit/%', + 'router_path' => 'admin/content/forum/edit/%', + 'link_title' => '', + 'options' => 'a:0:{}', + 'module' => 'system', + 'hidden' => '-1', + 'external' => '0', + 'has_children' => '0', + 'expanded' => '0', + 'weight' => '0', + 'depth' => '4', + 'customized' => '0', + 'p1' => '144', + 'p2' => '157', + 'p3' => '444', + 'p4' => '451', + 'p5' => '0', + 'p6' => '0', + 'p7' => '0', + 'p8' => '0', + 'p9' => '0', + 'updated' => '0', +)) +->values(array( + 'menu_name' => 'navigation', + 'mlid' => '452', + 'plid' => '0', + 'link_path' => 'admin/content/node-type/forum/delete', + 'router_path' => 'admin/content/node-type/forum/delete', + 'link_title' => 'Delete', + 'options' => 'a:0:{}', + 'module' => 'system', + 'hidden' => '-1', + 'external' => '0', + 'has_children' => '0', + 'expanded' => '0', + 'weight' => '0', + 'depth' => '1', + 'customized' => '0', + 'p1' => '452', + 'p2' => '0', + 'p3' => '0', + 'p4' => '0', + 'p5' => '0', + 'p6' => '0', + 'p7' => '0', + 'p8' => '0', + 'p9' => '0', + 'updated' => '0', +)) +->values(array( + 'menu_name' => 'navigation', + 'mlid' => '453', + 'plid' => '444', + 'link_path' => 'admin/content/forum/edit/container/%', + 'router_path' => 'admin/content/forum/edit/container/%', + 'link_title' => 'Edit container', + 'options' => 'a:0:{}', + 'module' => 'system', + 'hidden' => '-1', + 'external' => '0', + 'has_children' => '0', + 'expanded' => '0', + 'weight' => '0', + 'depth' => '4', + 'customized' => '0', + 'p1' => '144', + 'p2' => '157', + 'p3' => '444', + 'p4' => '453', + 'p5' => '0', + 'p6' => '0', + 'p7' => '0', + 'p8' => '0', + 'p9' => '0', + 'updated' => '0', +)) +->values(array( + 'menu_name' => 'navigation', + 'mlid' => '454', + 'plid' => '444', + 'link_path' => 'admin/content/forum/edit/forum/%', + 'router_path' => 'admin/content/forum/edit/forum/%', + 'link_title' => 'Edit forum', + 'options' => 'a:0:{}', + 'module' => 'system', + 'hidden' => '-1', + 'external' => '0', + 'has_children' => '0', + 'expanded' => '0', + 'weight' => '0', + 'depth' => '4', + 'customized' => '0', + 'p1' => '144', + 'p2' => '157', + 'p3' => '444', + 'p4' => '454', + 'p5' => '0', + 'p6' => '0', + 'p7' => '0', + 'p8' => '0', + 'p9' => '0', + 'updated' => '0', +)) ->execute(); $connection->schema()->createTable('menu_router', array( @@ -34471,6 +35083,182 @@ 'file' => 'modules/comment/comment.admin.inc', )) ->values(array( + 'path' => 'admin/content/forum', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"administer forums";}', + 'page_callback' => 'drupal_get_form', + 'page_arguments' => 'a:1:{i:0;s:14:"forum_overview";}', + 'fit' => '7', + 'number_parts' => '3', + 'tab_parent' => '', + 'tab_root' => 'admin/content/forum', + 'title' => 'Forums', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '6', + 'block_callback' => '', + 'description' => 'Control forums and their hierarchy and change forum settings.', + 'position' => '', + 'weight' => '0', + 'file' => 'modules/forum/forum.admin.inc', +)) +->values(array( + 'path' => 'admin/content/forum/add/container', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"administer forums";}', + 'page_callback' => 'forum_form_main', + 'page_arguments' => 'a:1:{i:0;s:9:"container";}', + 'fit' => '31', + 'number_parts' => '5', + 'tab_parent' => 'admin/content/forum', + 'tab_root' => 'admin/content/forum', + 'title' => 'Add container', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '128', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '0', + 'file' => 'modules/forum/forum.admin.inc', +)) +->values(array( + 'path' => 'admin/content/forum/add/forum', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"administer forums";}', + 'page_callback' => 'forum_form_main', + 'page_arguments' => 'a:1:{i:0;s:5:"forum";}', + 'fit' => '31', + 'number_parts' => '5', + 'tab_parent' => 'admin/content/forum', + 'tab_root' => 'admin/content/forum', + 'title' => 'Add forum', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '128', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '0', + 'file' => 'modules/forum/forum.admin.inc', +)) +->values(array( + 'path' => 'admin/content/forum/edit/%', + 'load_functions' => 'a:1:{i:4;s:15:"forum_term_load";}', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"administer forums";}', + 'page_callback' => 'forum_form_main', + 'page_arguments' => 'a:0:{}', + 'fit' => '30', + 'number_parts' => '5', + 'tab_parent' => '', + 'tab_root' => 'admin/content/forum/edit/%', + 'title' => '', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '4', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '0', + 'file' => 'modules/forum/forum.admin.inc', +)) +->values(array( + 'path' => 'admin/content/forum/edit/container/%', + 'load_functions' => 'a:1:{i:5;s:15:"forum_term_load";}', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"administer forums";}', + 'page_callback' => 'forum_form_main', + 'page_arguments' => 'a:2:{i:0;s:9:"container";i:1;i:5;}', + 'fit' => '62', + 'number_parts' => '6', + 'tab_parent' => '', + 'tab_root' => 'admin/content/forum/edit/container/%', + 'title' => 'Edit container', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '4', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '0', + 'file' => 'modules/forum/forum.admin.inc', +)) +->values(array( + 'path' => 'admin/content/forum/edit/forum/%', + 'load_functions' => 'a:1:{i:5;s:15:"forum_term_load";}', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"administer forums";}', + 'page_callback' => 'forum_form_main', + 'page_arguments' => 'a:2:{i:0;s:5:"forum";i:1;i:5;}', + 'fit' => '62', + 'number_parts' => '6', + 'tab_parent' => '', + 'tab_root' => 'admin/content/forum/edit/forum/%', + 'title' => 'Edit forum', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '4', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '0', + 'file' => 'modules/forum/forum.admin.inc', +)) +->values(array( + 'path' => 'admin/content/forum/list', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"administer forums";}', + 'page_callback' => 'drupal_get_form', + 'page_arguments' => 'a:1:{i:0;s:14:"forum_overview";}', + 'fit' => '15', + 'number_parts' => '4', + 'tab_parent' => 'admin/content/forum', + 'tab_root' => 'admin/content/forum', + 'title' => 'List', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '136', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '-10', + 'file' => 'modules/forum/forum.admin.inc', +)) +->values(array( + 'path' => 'admin/content/forum/settings', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"administer forums";}', + 'page_callback' => 'drupal_get_form', + 'page_arguments' => 'a:1:{i:0;s:20:"forum_admin_settings";}', + 'fit' => '15', + 'number_parts' => '4', + 'tab_parent' => 'admin/content/forum', + 'tab_root' => 'admin/content/forum', + 'title' => 'Settings', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '128', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '5', + 'file' => 'modules/forum/forum.admin.inc', +)) +->values(array( 'path' => 'admin/content/node', 'load_functions' => '', 'to_arg_functions' => '', @@ -35065,6 +35853,182 @@ 'file' => 'sites/all/modules/cck/includes/content.admin.inc', )) ->values(array( + 'path' => 'admin/content/node-type/forum', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:24:"administer content types";}', + 'page_callback' => 'drupal_get_form', + 'page_arguments' => 'a:2:{i:0;s:14:"node_type_form";i:1;O:8:"stdClass":15:{s:4:"name";s:11:"Forum topic";s:6:"module";s:5:"forum";s:11:"description";s:85:"A forum topic is the initial post to a new discussion thread within a forum.";s:11:"title_label";s:7:"Subject";s:4:"type";s:5:"forum";s:9:"has_title";b:1;s:8:"has_body";b:1;s:10:"body_label";s:4:"Body";s:4:"help";s:0:"";s:14:"min_word_count";i:0;s:6:"custom";b:0;s:8:"modified";b:0;s:6:"locked";b:1;s:9:"orig_type";s:5:"forum";s:6:"is_new";b:1;}}', + 'fit' => '15', + 'number_parts' => '4', + 'tab_parent' => '', + 'tab_root' => 'admin/content/node-type/forum', + 'title' => 'Forum topic', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '4', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '0', + 'file' => 'modules/node/content_types.inc', +)) +->values(array( + 'path' => 'admin/content/node-type/forum/delete', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:24:"administer content types";}', + 'page_callback' => 'drupal_get_form', + 'page_arguments' => 'a:2:{i:0;s:24:"node_type_delete_confirm";i:1;O:8:"stdClass":15:{s:4:"name";s:11:"Forum topic";s:6:"module";s:5:"forum";s:11:"description";s:85:"A forum topic is the initial post to a new discussion thread within a forum.";s:11:"title_label";s:7:"Subject";s:4:"type";s:5:"forum";s:9:"has_title";b:1;s:8:"has_body";b:1;s:10:"body_label";s:4:"Body";s:4:"help";s:0:"";s:14:"min_word_count";i:0;s:6:"custom";b:0;s:8:"modified";b:0;s:6:"locked";b:1;s:9:"orig_type";s:5:"forum";s:6:"is_new";b:1;}}', + 'fit' => '31', + 'number_parts' => '5', + 'tab_parent' => '', + 'tab_root' => 'admin/content/node-type/forum/delete', + 'title' => 'Delete', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '4', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '0', + 'file' => 'modules/node/content_types.inc', +)) +->values(array( + 'path' => 'admin/content/node-type/forum/display', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:24:"administer content types";}', + 'page_callback' => 'drupal_get_form', + 'page_arguments' => 'a:2:{i:0;s:29:"content_display_overview_form";i:1;s:5:"forum";}', + 'fit' => '31', + 'number_parts' => '5', + 'tab_parent' => 'admin/content/node-type/forum', + 'tab_root' => 'admin/content/node-type/forum', + 'title' => 'Display fields', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '128', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '2', + 'file' => 'sites/all/modules/cck/includes/content.admin.inc', +)) +->values(array( + 'path' => 'admin/content/node-type/forum/display/basic', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:24:"administer content types";}', + 'page_callback' => 'drupal_get_form', + 'page_arguments' => 'a:3:{i:0;s:29:"content_display_overview_form";i:1;s:5:"forum";i:2;s:5:"basic";}', + 'fit' => '63', + 'number_parts' => '6', + 'tab_parent' => 'admin/content/node-type/forum/display', + 'tab_root' => 'admin/content/node-type/forum', + 'title' => 'Basic', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '136', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '0', + 'file' => 'sites/all/modules/cck/includes/content.admin.inc', +)) +->values(array( + 'path' => 'admin/content/node-type/forum/display/print', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:24:"administer content types";}', + 'page_callback' => 'drupal_get_form', + 'page_arguments' => 'a:3:{i:0;s:29:"content_display_overview_form";i:1;s:5:"forum";i:2;s:5:"print";}', + 'fit' => '63', + 'number_parts' => '6', + 'tab_parent' => 'admin/content/node-type/forum/display', + 'tab_root' => 'admin/content/node-type/forum', + 'title' => 'Print', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '128', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '1', + 'file' => 'sites/all/modules/cck/includes/content.admin.inc', +)) +->values(array( + 'path' => 'admin/content/node-type/forum/display/rss', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:24:"administer content types";}', + 'page_callback' => 'drupal_get_form', + 'page_arguments' => 'a:3:{i:0;s:29:"content_display_overview_form";i:1;s:5:"forum";i:2;s:3:"rss";}', + 'fit' => '63', + 'number_parts' => '6', + 'tab_parent' => 'admin/content/node-type/forum/display', + 'tab_root' => 'admin/content/node-type/forum', + 'title' => 'RSS', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '128', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '1', + 'file' => 'sites/all/modules/cck/includes/content.admin.inc', +)) +->values(array( + 'path' => 'admin/content/node-type/forum/edit', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:24:"administer content types";}', + 'page_callback' => 'drupal_get_form', + 'page_arguments' => 'a:2:{i:0;s:14:"node_type_form";i:1;O:8:"stdClass":15:{s:4:"name";s:11:"Forum topic";s:6:"module";s:5:"forum";s:11:"description";s:85:"A forum topic is the initial post to a new discussion thread within a forum.";s:11:"title_label";s:7:"Subject";s:4:"type";s:5:"forum";s:9:"has_title";b:1;s:8:"has_body";b:1;s:10:"body_label";s:4:"Body";s:4:"help";s:0:"";s:14:"min_word_count";i:0;s:6:"custom";b:0;s:8:"modified";b:0;s:6:"locked";b:1;s:9:"orig_type";s:5:"forum";s:6:"is_new";b:1;}}', + 'fit' => '31', + 'number_parts' => '5', + 'tab_parent' => 'admin/content/node-type/forum', + 'tab_root' => 'admin/content/node-type/forum', + 'title' => 'Edit', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '136', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '0', + 'file' => 'modules/node/content_types.inc', +)) +->values(array( + 'path' => 'admin/content/node-type/forum/fields', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:24:"administer content types";}', + 'page_callback' => 'drupal_get_form', + 'page_arguments' => 'a:2:{i:0;s:27:"content_field_overview_form";i:1;s:5:"forum";}', + 'fit' => '31', + 'number_parts' => '5', + 'tab_parent' => 'admin/content/node-type/forum', + 'tab_root' => 'admin/content/node-type/forum', + 'title' => 'Manage fields', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '128', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '1', + 'file' => 'sites/all/modules/cck/includes/content.admin.inc', +)) +->values(array( 'path' => 'admin/content/node-type/sponsor', 'load_functions' => '', 'to_arg_functions' => '', @@ -37485,6 +38449,116 @@ 'file' => 'modules/system/system.admin.inc', )) ->values(array( + 'path' => 'admin/reports/access/%', + 'load_functions' => 'a:1:{i:3;N;}', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"access statistics";}', + 'page_callback' => 'statistics_access_log', + 'page_arguments' => 'a:1:{i:0;i:3;}', + 'fit' => '14', + 'number_parts' => '4', + 'tab_parent' => '', + 'tab_root' => 'admin/reports/access/%', + 'title' => 'Details', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '4', + 'block_callback' => '', + 'description' => 'View access log.', + 'position' => '', + 'weight' => '0', + 'file' => 'modules/statistics/statistics.admin.inc', +)) +->values(array( + 'path' => 'admin/reports/hits', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"access statistics";}', + 'page_callback' => 'statistics_recent_hits', + 'page_arguments' => 'a:0:{}', + 'fit' => '7', + 'number_parts' => '3', + 'tab_parent' => '', + 'tab_root' => 'admin/reports/hits', + 'title' => 'Recent hits', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '6', + 'block_callback' => '', + 'description' => 'View pages that have recently been visited.', + 'position' => '', + 'weight' => '0', + 'file' => 'modules/statistics/statistics.admin.inc', +)) +->values(array( + 'path' => 'admin/reports/pages', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"access statistics";}', + 'page_callback' => 'statistics_top_pages', + 'page_arguments' => 'a:0:{}', + 'fit' => '7', + 'number_parts' => '3', + 'tab_parent' => '', + 'tab_root' => 'admin/reports/pages', + 'title' => 'Top pages', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '6', + 'block_callback' => '', + 'description' => 'View pages that have been hit frequently.', + 'position' => '', + 'weight' => '1', + 'file' => 'modules/statistics/statistics.admin.inc', +)) +->values(array( + 'path' => 'admin/reports/referrers', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"access statistics";}', + 'page_callback' => 'statistics_top_referrers', + 'page_arguments' => 'a:0:{}', + 'fit' => '7', + 'number_parts' => '3', + 'tab_parent' => '', + 'tab_root' => 'admin/reports/referrers', + 'title' => 'Top referrers', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '6', + 'block_callback' => '', + 'description' => 'View top referrers.', + 'position' => '', + 'weight' => '0', + 'file' => 'modules/statistics/statistics.admin.inc', +)) +->values(array( + 'path' => 'admin/reports/settings', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:29:"administer site configuration";}', + 'page_callback' => 'drupal_get_form', + 'page_arguments' => 'a:1:{i:0;s:34:"statistics_access_logging_settings";}', + 'fit' => '7', + 'number_parts' => '3', + 'tab_parent' => '', + 'tab_root' => 'admin/reports/settings', + 'title' => 'Access log settings', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '6', + 'block_callback' => '', + 'description' => 'Control details about what and how your site logs.', + 'position' => '', + 'weight' => '3', + 'file' => 'modules/statistics/statistics.admin.inc', +)) +->values(array( 'path' => 'admin/reports/status', 'load_functions' => '', 'to_arg_functions' => '', @@ -37573,6 +38647,28 @@ 'file' => 'modules/system/system.admin.inc', )) ->values(array( + 'path' => 'admin/reports/visitors', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"access statistics";}', + 'page_callback' => 'statistics_top_visitors', + 'page_arguments' => 'a:0:{}', + 'fit' => '7', + 'number_parts' => '3', + 'tab_parent' => '', + 'tab_root' => 'admin/reports/visitors', + 'title' => 'Top visitors', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '6', + 'block_callback' => '', + 'description' => 'View visitors that hit many pages.', + 'position' => '', + 'weight' => '2', + 'file' => 'modules/statistics/statistics.admin.inc', +)) +->values(array( 'path' => 'admin/settings', 'load_functions' => '', 'to_arg_functions' => '', @@ -39971,6 +41067,28 @@ 'file' => 'modules/filter/filter.pages.inc', )) ->values(array( + 'path' => 'forum', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:14:"access content";}', + 'page_callback' => 'forum_page', + 'page_arguments' => 'a:0:{}', + 'fit' => '1', + 'number_parts' => '1', + 'tab_parent' => '', + 'tab_root' => 'forum', + 'title' => 'Forums', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '20', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '0', + 'file' => 'modules/forum/forum.pages.inc', +)) +->values(array( 'path' => 'i18n/node/autocomplete', 'load_functions' => '', 'to_arg_functions' => '', @@ -40279,6 +41397,28 @@ 'file' => '', )) ->values(array( + 'path' => 'node/%/track', + 'load_functions' => 'a:1:{i:1;s:9:"node_load";}', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"access statistics";}', + 'page_callback' => 'statistics_node_tracker', + 'page_arguments' => 'a:0:{}', + 'fit' => '5', + 'number_parts' => '3', + 'tab_parent' => 'node/%', + 'tab_root' => 'node/%', + 'title' => 'Track', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '128', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '2', + 'file' => 'modules/statistics/statistics.pages.inc', +)) +->values(array( 'path' => 'node/%/translate', 'load_functions' => 'a:1:{i:1;s:9:"node_load";}', 'to_arg_functions' => '', @@ -40411,6 +41551,28 @@ 'file' => 'modules/node/node.pages.inc', )) ->values(array( + 'path' => 'node/add/forum', + 'load_functions' => '', + 'to_arg_functions' => '', + 'access_callback' => 'node_access', + 'access_arguments' => 'a:2:{i:0;s:6:"create";i:1;s:5:"forum";}', + 'page_callback' => 'i18ncontent_node_add', + 'page_arguments' => 'a:1:{i:0;i:2;}', + 'fit' => '7', + 'number_parts' => '3', + 'tab_parent' => '', + 'tab_root' => 'node/add/forum', + 'title' => 'Forum topic', + 'title_callback' => 'i18nstrings_title_callback', + 'title_arguments' => 'a:2:{i:0;s:24:"nodetype:type:forum:name";i:1;s:11:"Forum topic";}', + 'type' => '6', + 'block_callback' => '', + 'description' => 'A forum topic is the initial post to a new discussion thread within a forum.', + 'position' => '', + 'weight' => '0', + 'file' => 'modules/node/node.pages.inc', +)) +->values(array( 'path' => 'node/add/sponsor', 'load_functions' => '', 'to_arg_functions' => '', @@ -40917,6 +42079,28 @@ 'file' => 'modules/user/user.pages.inc', )) ->values(array( + 'path' => 'user/%/track/navigation', + 'load_functions' => 'a:1:{i:1;s:9:"user_load";}', + 'to_arg_functions' => '', + 'access_callback' => 'user_access', + 'access_arguments' => 'a:1:{i:0;s:17:"access statistics";}', + 'page_callback' => 'statistics_user_tracker', + 'page_arguments' => 'a:0:{}', + 'fit' => '11', + 'number_parts' => '4', + 'tab_parent' => 'user/%', + 'tab_root' => 'user/%', + 'title' => 'Track page visits', + 'title_callback' => 't', + 'title_arguments' => '', + 'type' => '128', + 'block_callback' => '', + 'description' => '', + 'position' => '', + 'weight' => '2', + 'file' => 'modules/statistics/statistics.pages.inc', +)) +->values(array( 'path' => 'user/%/view', 'load_functions' => 'a:1:{i:1;s:9:"user_load";}', 'to_arg_functions' => '', @@ -42036,6 +43220,22 @@ 'orig_type' => 'event', )) ->values(array( + 'type' => 'forum', + 'name' => 'Forum topic', + 'module' => 'forum', + 'description' => 'A forum topic is the initial post to a new discussion thread within a forum.', + 'help' => '', + 'has_title' => '1', + 'title_label' => 'Subject', + 'has_body' => '1', + 'body_label' => 'Body', + 'min_word_count' => '0', + 'custom' => '0', + 'modified' => '0', + 'locked' => '1', + 'orig_type' => 'forum', +)) +->values(array( 'type' => 'page', 'name' => 'Page', 'module' => 'node', @@ -42855,7 +44055,7 @@ 'bootstrap' => '0', 'schema_version' => '6001', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:10:"Aggregator";s:11:"description";s:57:"Aggregates syndicated content (RSS, RDF, and Atom feeds).";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:10:"Aggregator";s:11:"description";s:57:"Aggregates syndicated content (RSS, RDF, and Atom feeds).";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/block/block.module', @@ -42867,7 +44067,7 @@ 'bootstrap' => '0', 'schema_version' => '0', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:5:"Block";s:11:"description";s:62:"Controls the boxes that are displayed around the main content.";s:7:"package";s:15:"Core - required";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:5:"Block";s:11:"description";s:62:"Controls the boxes that are displayed around the main content.";s:7:"package";s:15:"Core - required";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/blog/blog.module', @@ -42879,7 +44079,7 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:4:"Blog";s:11:"description";s:69:"Enables keeping easily and regularly updated user web pages or blogs.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:4:"Blog";s:11:"description";s:69:"Enables keeping easily and regularly updated user web pages or blogs.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/blogapi/blogapi.module', @@ -42891,7 +44091,7 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:8:"Blog API";s:11:"description";s:79:"Allows users to post content using applications that support XML-RPC blog APIs.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:8:"Blog API";s:11:"description";s:79:"Allows users to post content using applications that support XML-RPC blog APIs.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/book/book.module', @@ -42903,7 +44103,7 @@ 'bootstrap' => '0', 'schema_version' => '6000', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:4:"Book";s:11:"description";s:63:"Allows users to structure site pages in a hierarchy or outline.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:4:"Book";s:11:"description";s:63:"Allows users to structure site pages in a hierarchy or outline.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/color/color.module', @@ -42915,7 +44115,7 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:5:"Color";s:11:"description";s:61:"Allows the user to change the color scheme of certain themes.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:5:"Color";s:11:"description";s:61:"Allows the user to change the color scheme of certain themes.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/comment/comment.module', @@ -42927,7 +44127,7 @@ 'bootstrap' => '0', 'schema_version' => '6005', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:7:"Comment";s:11:"description";s:57:"Allows users to comment on and discuss published content.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:7:"Comment";s:11:"description";s:57:"Allows users to comment on and discuss published content.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/contact/contact.module', @@ -42939,7 +44139,7 @@ 'bootstrap' => '0', 'schema_version' => '6001', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:7:"Contact";s:11:"description";s:61:"Enables the use of both personal and site-wide contact forms.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:7:"Contact";s:11:"description";s:61:"Enables the use of both personal and site-wide contact forms.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/dblog/dblog.module', @@ -42951,7 +44151,7 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:16:"Database logging";s:11:"description";s:47:"Logs and records system events to the database.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:16:"Database logging";s:11:"description";s:47:"Logs and records system events to the database.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/filter/filter.module', @@ -42963,19 +44163,19 @@ 'bootstrap' => '0', 'schema_version' => '0', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:6:"Filter";s:11:"description";s:60:"Handles the filtering of content in preparation for display.";s:7:"package";s:15:"Core - required";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:6:"Filter";s:11:"description";s:60:"Handles the filtering of content in preparation for display.";s:7:"package";s:15:"Core - required";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/forum/forum.module', 'name' => 'forum', 'type' => 'module', 'owner' => '', - 'status' => '0', + 'status' => '1', 'throttle' => '0', 'bootstrap' => '0', - 'schema_version' => '-1', - 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:5:"Forum";s:11:"description";s:50:"Enables threaded discussions about general topics.";s:12:"dependencies";a:2:{i:0;s:8:"taxonomy";i:1;s:7:"comment";}s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'schema_version' => '6000', + 'weight' => '1', + 'info' => 'a:8:{s:4:"name";s:5:"Forum";s:11:"description";s:50:"Enables threaded discussions about general topics.";s:12:"dependencies";a:2:{i:0;s:8:"taxonomy";i:1;s:7:"comment";}s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/help/help.module', @@ -42987,7 +44187,7 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:4:"Help";s:11:"description";s:35:"Manages the display of online help.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:4:"Help";s:11:"description";s:35:"Manages the display of online help.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/locale/locale.module', @@ -42999,7 +44199,7 @@ 'bootstrap' => '0', 'schema_version' => '6007', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:6:"Locale";s:11:"description";s:119:"Adds language handling functionality and enables the translation of the user interface to languages other than English.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:6:"Locale";s:11:"description";s:119:"Adds language handling functionality and enables the translation of the user interface to languages other than English.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/menu/menu.module', @@ -43011,7 +44211,7 @@ 'bootstrap' => '0', 'schema_version' => '6000', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:4:"Menu";s:11:"description";s:60:"Allows administrators to customize the site navigation menu.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:4:"Menu";s:11:"description";s:60:"Allows administrators to customize the site navigation menu.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/node/node.module', @@ -43023,7 +44223,7 @@ 'bootstrap' => '0', 'schema_version' => '0', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:4:"Node";s:11:"description";s:66:"Allows content to be submitted to the site and displayed on pages.";s:7:"package";s:15:"Core - required";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:4:"Node";s:11:"description";s:66:"Allows content to be submitted to the site and displayed on pages.";s:7:"package";s:15:"Core - required";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/openid/openid.module', @@ -43035,7 +44235,7 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:6:"OpenID";s:11:"description";s:48:"Allows users to log into your site using OpenID.";s:7:"version";s:8:"6.38-dev";s:7:"package";s:15:"Core - optional";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:6:"OpenID";s:11:"description";s:48:"Allows users to log into your site using OpenID.";s:7:"version";s:4:"6.38";s:7:"package";s:15:"Core - optional";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/path/path.module', @@ -43047,7 +44247,7 @@ 'bootstrap' => '0', 'schema_version' => '0', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:4:"Path";s:11:"description";s:28:"Allows users to rename URLs.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:4:"Path";s:11:"description";s:28:"Allows users to rename URLs.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/php/php.module', @@ -43059,7 +44259,7 @@ 'bootstrap' => '0', 'schema_version' => '0', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:10:"PHP filter";s:11:"description";s:50:"Allows embedded PHP code/snippets to be evaluated.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:10:"PHP filter";s:11:"description";s:50:"Allows embedded PHP code/snippets to be evaluated.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/ping/ping.module', @@ -43071,7 +44271,7 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:4:"Ping";s:11:"description";s:51:"Alerts other sites when your site has been updated.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:4:"Ping";s:11:"description";s:51:"Alerts other sites when your site has been updated.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/poll/poll.module', @@ -43083,7 +44283,7 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:4:"Poll";s:11:"description";s:95:"Allows your site to capture votes on different topics in the form of multiple choice questions.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:4:"Poll";s:11:"description";s:95:"Allows your site to capture votes on different topics in the form of multiple choice questions.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/profile/profile.module', @@ -43095,7 +44295,7 @@ 'bootstrap' => '0', 'schema_version' => '6001', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:7:"Profile";s:11:"description";s:36:"Supports configurable user profiles.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:7:"Profile";s:11:"description";s:36:"Supports configurable user profiles.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/search/search.module', @@ -43107,19 +44307,19 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:6:"Search";s:11:"description";s:36:"Enables site-wide keyword searching.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:6:"Search";s:11:"description";s:36:"Enables site-wide keyword searching.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/statistics/statistics.module', 'name' => 'statistics', 'type' => 'module', 'owner' => '', - 'status' => '0', + 'status' => '1', 'throttle' => '0', - 'bootstrap' => '0', - 'schema_version' => '-1', + 'bootstrap' => '1', + 'schema_version' => '6000', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:10:"Statistics";s:11:"description";s:37:"Logs access statistics for your site.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:10:"Statistics";s:11:"description";s:37:"Logs access statistics for your site.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/syslog/syslog.module', @@ -43131,7 +44331,7 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:6:"Syslog";s:11:"description";s:41:"Logs and records system events to syslog.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:6:"Syslog";s:11:"description";s:41:"Logs and records system events to syslog.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/system/system.module', @@ -43143,7 +44343,7 @@ 'bootstrap' => '0', 'schema_version' => '6055', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:6:"System";s:11:"description";s:54:"Handles general site configuration for administrators.";s:7:"package";s:15:"Core - required";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:6:"System";s:11:"description";s:54:"Handles general site configuration for administrators.";s:7:"package";s:15:"Core - required";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/taxonomy/taxonomy.module', @@ -43155,7 +44355,7 @@ 'bootstrap' => '0', 'schema_version' => '6001', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:8:"Taxonomy";s:11:"description";s:38:"Enables the categorization of content.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:8:"Taxonomy";s:11:"description";s:38:"Enables the categorization of content.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/throttle/throttle.module', @@ -43167,7 +44367,7 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:8:"Throttle";s:11:"description";s:66:"Handles the auto-throttling mechanism, to control site congestion.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:8:"Throttle";s:11:"description";s:66:"Handles the auto-throttling mechanism, to control site congestion.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/tracker/tracker.module', @@ -43179,7 +44379,7 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:7:"Tracker";s:11:"description";s:43:"Enables tracking of recent posts for users.";s:12:"dependencies";a:1:{i:0;s:7:"comment";}s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:7:"Tracker";s:11:"description";s:43:"Enables tracking of recent posts for users.";s:12:"dependencies";a:1:{i:0;s:7:"comment";}s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/translation/translation.module', @@ -43191,7 +44391,7 @@ 'bootstrap' => '0', 'schema_version' => '0', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:19:"Content translation";s:11:"description";s:57:"Allows content to be translated into different languages.";s:12:"dependencies";a:1:{i:0;s:6:"locale";}s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:19:"Content translation";s:11:"description";s:57:"Allows content to be translated into different languages.";s:12:"dependencies";a:1:{i:0;s:6:"locale";}s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/trigger/trigger.module', @@ -43203,7 +44403,7 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:7:"Trigger";s:11:"description";s:90:"Enables actions to be fired on certain system events, such as when new content is created.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:7:"Trigger";s:11:"description";s:90:"Enables actions to be fired on certain system events, such as when new content is created.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/update/update.module', @@ -43215,7 +44415,7 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:13:"Update status";s:11:"description";s:88:"Checks the status of available updates for Drupal and your installed modules and themes.";s:7:"version";s:8:"6.38-dev";s:7:"package";s:15:"Core - optional";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:13:"Update status";s:11:"description";s:88:"Checks the status of available updates for Drupal and your installed modules and themes.";s:7:"version";s:4:"6.38";s:7:"package";s:15:"Core - optional";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/upload/upload.module', @@ -43227,7 +44427,7 @@ 'bootstrap' => '0', 'schema_version' => '6000', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:6:"Upload";s:11:"description";s:51:"Allows users to upload and attach files to content.";s:7:"package";s:15:"Core - optional";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:6:"Upload";s:11:"description";s:51:"Allows users to upload and attach files to content.";s:7:"package";s:15:"Core - optional";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'modules/user/user.module', @@ -43239,7 +44439,7 @@ 'bootstrap' => '0', 'schema_version' => '0', 'weight' => '0', - 'info' => 'a:8:{s:4:"name";s:4:"User";s:11:"description";s:47:"Manages the user registration and login system.";s:7:"package";s:15:"Core - required";s:7:"version";s:8:"6.38-dev";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:8:{s:4:"name";s:4:"User";s:11:"description";s:47:"Manages the user registration and login system.";s:7:"package";s:15:"Core - required";s:7:"version";s:4:"6.38";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'sites/all/modules/cck/content.module', @@ -43266,6 +44466,18 @@ 'info' => 'a:10:{s:4:"name";s:12:"Content Copy";s:11:"description";s:51:"Enables ability to import/export field definitions.";s:12:"dependencies";a:1:{i:0;s:7:"content";}s:7:"package";s:3:"CCK";s:4:"core";s:3:"6.x";s:7:"version";s:7:"6.x-2.9";s:7:"project";s:3:"cck";s:9:"datestamp";s:10:"1294407979";s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( + 'filename' => 'sites/all/modules/cck/modules/content_multigroup/content_multigroup.module', + 'name' => 'content_multigroup', + 'type' => 'module', + 'owner' => '', + 'status' => '0', + 'throttle' => '0', + 'bootstrap' => '0', + 'schema_version' => '-1', + 'weight' => '0', + 'info' => 'a:10:{s:4:"name";s:18:"Content Multigroup";s:11:"description";s:81:"Combine multiple CCK fields into repeating field collections that work in unison.";s:12:"dependencies";a:2:{i:0;s:7:"content";i:1;s:10:"fieldgroup";}s:7:"package";s:3:"CCK";s:4:"core";s:3:"6.x";s:7:"version";s:20:"6.x-3.0-alpha4+0-dev";s:7:"project";s:3:"cck";s:9:"datestamp";s:10:"1435195093";s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', +)) +->values(array( 'filename' => 'sites/all/modules/cck/modules/content_permissions/content_permissions.module', 'name' => 'content_permissions', 'type' => 'module', @@ -43350,6 +44562,18 @@ 'info' => 'a:10:{s:4:"name";s:14:"User Reference";s:11:"description";s:56:"Defines a field type for referencing a user from a node.";s:12:"dependencies";a:3:{i:0;s:7:"content";i:1;s:4:"text";i:2;s:13:"optionwidgets";}s:7:"package";s:3:"CCK";s:4:"core";s:3:"6.x";s:7:"version";s:7:"6.x-2.9";s:7:"project";s:3:"cck";s:9:"datestamp";s:10:"1294407979";s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( + 'filename' => 'sites/all/modules/cck/tests/content_test.module', + 'name' => 'content_test', + 'type' => 'module', + 'owner' => '', + 'status' => '0', + 'throttle' => '0', + 'bootstrap' => '0', + 'schema_version' => '-1', + 'weight' => '0', + 'info' => 'a:12:{s:4:"name";s:12:"Content Test";s:11:"description";s:20:"Test module for CCK.";s:7:"package";s:3:"CCK";s:4:"core";s:3:"6.x";s:12:"dependencies";a:1:{i:0;s:6:"schema";}s:6:"hidden";b:1;s:5:"files";a:1:{i:0;s:19:"content_test.module";}s:7:"version";s:20:"6.x-3.0-alpha4+0-dev";s:7:"project";s:3:"cck";s:9:"datestamp";s:10:"1435195093";s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', +)) +->values(array( 'filename' => 'sites/all/modules/date/date/date.module', 'name' => 'date', 'type' => 'module', @@ -43770,6 +44994,18 @@ 'info' => 'a:10:{s:4:"name";s:10:"ImageField";s:11:"description";s:28:"Defines an image field type.";s:4:"core";s:3:"6.x";s:12:"dependencies";a:2:{i:0;s:7:"content";i:1;s:9:"filefield";}s:7:"package";s:3:"CCK";s:7:"version";s:8:"6.x-3.11";s:7:"project";s:10:"imagefield";s:9:"datestamp";s:10:"1365969012";s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( + 'filename' => 'sites/all/modules/jquery_ui/jquery_ui.module', + 'name' => 'jquery_ui', + 'type' => 'module', + 'owner' => '', + 'status' => '0', + 'throttle' => '0', + 'bootstrap' => '0', + 'schema_version' => '-1', + 'weight' => '0', + 'info' => 'a:8:{s:4:"name";s:9:"jQuery UI";s:11:"description";s:55:"Provides the jQuery UI plug-in to other Drupal modules.";s:7:"package";s:14:"User interface";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:7:"version";N;s:3:"php";s:5:"4.3.5";}', +)) +->values(array( 'filename' => 'sites/all/modules/link/link.module', 'name' => 'link', 'type' => 'module', @@ -43815,7 +45051,7 @@ 'bootstrap' => '0', 'schema_version' => '-1', 'weight' => '0', - 'info' => 'a:7:{s:4:"name";s:12:"Variable API";s:11:"description";s:12:"Variable API";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:7:"version";N;s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:9:{s:4:"name";s:12:"Variable API";s:11:"description";s:12:"Variable API";s:4:"core";s:3:"6.x";s:7:"version";s:14:"6.x-1.0-alpha1";s:7:"project";s:8:"variable";s:9:"datestamp";s:10:"1414059742";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'sites/all/modules/variable/variable_admin/variable_admin.module', @@ -43827,7 +45063,7 @@ 'bootstrap' => '0', 'schema_version' => '0', 'weight' => '0', - 'info' => 'a:7:{s:4:"name";s:14:"Variable admin";s:11:"description";s:23:"Variable API - Admin UI";s:4:"core";s:3:"6.x";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:7:"version";N;s:3:"php";s:5:"4.3.5";}', + 'info' => 'a:9:{s:4:"name";s:14:"Variable admin";s:11:"description";s:23:"Variable API - Admin UI";s:4:"core";s:3:"6.x";s:7:"version";s:14:"6.x-1.0-alpha1";s:7:"project";s:8:"variable";s:9:"datestamp";s:10:"1414059742";s:12:"dependencies";a:0:{}s:10:"dependents";a:0:{}s:3:"php";s:5:"4.3.5";}', )) ->values(array( 'filename' => 'sites/all/modules/views/tests/views_test.module', @@ -44613,8 +45849,8 @@ 'signature' => '', 'signature_format' => '0', 'created' => '0', - 'access' => '1468384823', - 'login' => '1468384420', + 'access' => '1467889223', + 'login' => '1467886039', 'status' => '1', 'timezone' => NULL, 'language' => '', @@ -44903,7 +46139,7 @@ )) ->values(array( 'name' => 'book_block_mode', - 'value' => 's:9:"all pages";', + 'value' => 's:10:"book pages";', )) ->values(array( 'name' => 'book_child_type', @@ -45651,11 +46887,11 @@ )) ->values(array( 'name' => 'forum_block_num_0', - 'value' => 's:1:"5";', + 'value' => 's:1:"3";', )) ->values(array( 'name' => 'forum_block_num_1', - 'value' => 's:1:"5";', + 'value' => 's:1:"4";', )) ->values(array( 'name' => 'forum_hot_topic', @@ -45678,6 +46914,10 @@ 'value' => 'a:2:{i:0;i:1;i:1;i:2;}', )) ->values(array( + 'name' => 'i18n_lock_node_article', + 'value' => 'i:1;', +)) +->values(array( 'name' => 'image_jpeg_quality', 'value' => 'i:75;', )) @@ -45687,7 +46927,7 @@ )) ->values(array( 'name' => 'javascript_parsed', - 'value' => 'a:21:{i:0;s:14:"misc/jquery.js";i:1;s:14:"misc/drupal.js";i:2;s:19:"misc/tableheader.js";i:3;s:16:"misc/collapse.js";i:4;s:16:"misc/textarea.js";i:5;s:20:"modules/user/user.js";i:6;s:17:"misc/tabledrag.js";i:7;s:26:"modules/profile/profile.js";i:8;s:12:"misc/form.js";i:9;s:19:"misc/tableselect.js";i:10;s:20:"misc/autocomplete.js";s:10:"refresh:ga";s:7:"waiting";s:10:"refresh:ab";s:7:"waiting";s:10:"refresh:ca";s:7:"waiting";s:10:"refresh:fi";s:7:"waiting";s:10:"refresh:es";s:7:"waiting";i:11;s:16:"misc/progress.js";i:12;s:13:"misc/batch.js";s:10:"refresh:nl";s:7:"waiting";s:10:"refresh:de";s:7:"waiting";s:10:"refresh:pl";s:7:"waiting";}', + 'value' => 'a:9:{i:0;s:14:"misc/jquery.js";i:1;s:14:"misc/drupal.js";i:2;s:19:"misc/tableheader.js";i:3;s:16:"misc/collapse.js";s:10:"refresh:fr";s:7:"waiting";s:10:"refresh:zu";s:7:"waiting";i:4;s:17:"misc/tabledrag.js";i:5;s:22:"modules/block/block.js";i:6;s:16:"misc/textarea.js";}', )) ->values(array( 'name' => 'language_content_type_article', @@ -45698,10 +46938,6 @@ 'value' => 's:1:"2";', )) ->values(array( - 'name' => 'i18n_lock_node_article', - 'value' => 'i:1;', -)) -->values(array( 'name' => 'language_count', 'value' => 'i:11;', )) @@ -45875,27 +47111,27 @@ )) ->values(array( 'name' => 'statistics_block_top_all_num', - 'value' => 'i:0;', + 'value' => 's:1:"8";', )) ->values(array( 'name' => 'statistics_block_top_day_num', - 'value' => 'i:0;', + 'value' => 's:1:"7";', )) ->values(array( 'name' => 'statistics_block_top_last_num', - 'value' => 'i:0;', + 'value' => 's:1:"9";', )) ->values(array( 'name' => 'statistics_count_content_views', - 'value' => 'i:0;', + 'value' => 's:1:"1";', )) ->values(array( 'name' => 'statistics_enable_access_log', - 'value' => 'i:0;', + 'value' => 's:1:"0";', )) ->values(array( 'name' => 'statistics_flush_accesslog_timer', - 'value' => 'i:259200;', + 'value' => 's:6:"259200";', )) ->values(array( 'name' => 'syslog_facility', @@ -46280,6 +47516,10 @@ 'type' => 'article', )) ->values(array( + 'vid' => '1', + 'type' => 'forum', +)) +->values(array( 'vid' => '4', 'type' => 'page', )) diff --git a/core/modules/migrate_drupal/tests/fixtures/drupal7.php b/core/modules/migrate_drupal/tests/fixtures/drupal7.php index 8c96dae..1831171 100644 --- a/core/modules/migrate_drupal/tests/fixtures/drupal7.php +++ b/core/modules/migrate_drupal/tests/fixtures/drupal7.php @@ -42953,6 +42953,15 @@ 'module' => 'taxonomy', 'weight' => '0', )) +->values(array( + 'vid' => '4', + 'name' => 'vocabulary name much longer than thirty two characters', + 'machine_name' => 'vocabulary_name_much_longer_than_thirty_two_characters', + 'description' => 'description of vocabulary name much longer than thirty two characters', + 'hierarchy' => '1', + 'module' => 'taxonomy', + 'weight' => '0', +)) ->execute(); $connection->schema()->createTable('tracker_node', array( diff --git a/core/modules/migrate_drupal/tests/modules/migrate_field_plugin_manager_test/migrate_field_plugin_manager_test.info.yml b/core/modules/migrate_drupal/tests/modules/migrate_field_plugin_manager_test/migrate_field_plugin_manager_test.info.yml new file mode 100644 index 0000000..db67a4f --- /dev/null +++ b/core/modules/migrate_drupal/tests/modules/migrate_field_plugin_manager_test/migrate_field_plugin_manager_test.info.yml @@ -0,0 +1,6 @@ +name: 'Migrate field plugin manager test' +type: module +description: 'Example module demonstrating the field plugin manager in the Migrate API.' +package: Testing +version: VERSION +core: 8.x diff --git a/core/modules/migrate_drupal/tests/modules/migrate_field_plugin_manager_test/migrate_field_plugin_manager_test.module b/core/modules/migrate_drupal/tests/modules/migrate_field_plugin_manager_test/migrate_field_plugin_manager_test.module new file mode 100644 index 0000000..9f9da0d --- /dev/null +++ b/core/modules/migrate_drupal/tests/modules/migrate_field_plugin_manager_test/migrate_field_plugin_manager_test.module @@ -0,0 +1,14 @@ +mergeProcessOfProperty($field_name, [ + 'class' => __CLASS__, + ]); + } + +} diff --git a/core/modules/migrate_drupal/tests/modules/migrate_field_plugin_manager_test/src/Plugin/migrate/field/D6FileField.php b/core/modules/migrate_drupal/tests/modules/migrate_field_plugin_manager_test/src/Plugin/migrate/field/D6FileField.php new file mode 100644 index 0000000..08ca88c --- /dev/null +++ b/core/modules/migrate_drupal/tests/modules/migrate_field_plugin_manager_test/src/Plugin/migrate/field/D6FileField.php @@ -0,0 +1,31 @@ +container + ->get('plugin.manager.migration') + ->getDefinition('d6_node:story'); + + $this->assertSame(FileField::class, $migration['process']['field_test_filefield']['class']); + } + +} diff --git a/core/modules/migrate_drupal/tests/src/Kernel/MigrateFieldPluginManagerTest.php b/core/modules/migrate_drupal/tests/src/Kernel/MigrateFieldPluginManagerTest.php new file mode 100644 index 0000000..7c3c5a4 --- /dev/null +++ b/core/modules/migrate_drupal/tests/src/Kernel/MigrateFieldPluginManagerTest.php @@ -0,0 +1,51 @@ +container->get('plugin.manager.migrate.field'); + + try { + // If this test passes, getPluginIdFromFieldType will raise a + // PluginNotFoundException and we'll never reach fail(). + $plugin_manager->getPluginIdFromFieldType('filefield', ['core' => 7]); + $this->fail('Expected Drupal\Component\Plugin\Exception\PluginNotFoundException.'); + } + catch (PluginNotFoundException $e) { + $this->assertIdentical($e->getMessage(), "Plugin ID 'filefield' was not found."); + } + + $this->assertIdentical('d6_file', $plugin_manager->getPluginIdFromFieldType('file', ['core' => 6])); + + // Test fallback when no core version is specified. + $this->assertIdentical('d6_no_core_version_specified', $plugin_manager->getPluginIdFromFieldType('d6_no_core_version_specified', ['core' => 6])); + + try { + // If this test passes, getPluginIdFromFieldType will raise a + // PluginNotFoundException and we'll never reach fail(). + $plugin_manager->getPluginIdFromFieldType('d6_no_core_version_specified', ['core' => 7]); + $this->fail('Expected Drupal\Component\Plugin\Exception\PluginNotFoundException.'); + } + catch (PluginNotFoundException $e) { + $this->assertIdentical($e->getMessage(), "Plugin ID 'd6_no_core_version_specified' was not found."); + } + } + +} diff --git a/core/modules/migrate_drupal/tests/src/Kernel/Plugin/migrate/source/VariableMultiRowTest.php b/core/modules/migrate_drupal/tests/src/Kernel/Plugin/migrate/source/VariableMultiRowTest.php new file mode 100644 index 0000000..932af99 --- /dev/null +++ b/core/modules/migrate_drupal/tests/src/Kernel/Plugin/migrate/source/VariableMultiRowTest.php @@ -0,0 +1,57 @@ + 'foo', 'value' => 'i:1;'], + ['name' => 'bar', 'value' => 'b:0;'], + ]; + + // The expected results. + $tests[0]['expected_data'] = [ + [ + 'name' => 'foo', + 'value' => 1, + ], + [ + 'name' => 'bar', + 'value' => FALSE, + ], + ]; + + // The expected count. + $tests[0]['expected_count'] = NULL; + + // The source plugin configuration. + $tests[0]['configuration']['variables'] = [ + 'foo', + 'bar', + ]; + + return $tests; + } + +} diff --git a/core/modules/migrate_drupal/tests/src/Kernel/Plugin/migrate/source/VariableTest.php b/core/modules/migrate_drupal/tests/src/Kernel/Plugin/migrate/source/VariableTest.php new file mode 100644 index 0000000..b68c4b4 --- /dev/null +++ b/core/modules/migrate_drupal/tests/src/Kernel/Plugin/migrate/source/VariableTest.php @@ -0,0 +1,54 @@ + 'foo', 'value' => 'i:1;'], + ['name' => 'bar', 'value' => 'b:0;'], + ]; + + // The expected results. + $tests[0]['expected_data'] = [ + [ + 'id' => 'foo', + 'foo' => 1, + 'bar' => FALSE, + ], + ]; + + // The expected count. + $tests[0]['expected_count'] = NULL; + + // The source plugin configuration. + $tests[0]['configuration']['variables'] = [ + 'foo', + 'bar', + ]; + + return $tests; + } + +} diff --git a/core/modules/migrate_drupal/tests/src/Kernel/Plugin/migrate/source/d6/i18nVariableTest.php b/core/modules/migrate_drupal/tests/src/Kernel/Plugin/migrate/source/d6/i18nVariableTest.php new file mode 100644 index 0000000..8c3e604 --- /dev/null +++ b/core/modules/migrate_drupal/tests/src/Kernel/Plugin/migrate/source/d6/i18nVariableTest.php @@ -0,0 +1,77 @@ + 'site_slogan', + 'language' => 'fr', + 'value' => 's:19:"Migrate est génial";', + ], + [ + 'name' => 'site_name', + 'language' => 'fr', + 'value' => 's:11:"nom de site";', + ], + [ + 'name' => 'site_slogan', + 'language' => 'mi', + 'value' => 's:19:"Ko whakamataku heke";', + ], + [ + 'name' => 'site_name', + 'language' => 'mi', + 'value' => 's:9:"ingoa_pae";', + ], + ]; + + // The expected results. + $tests[0]['expected_data'] = [ + [ + 'language' => 'fr', + 'site_slogan' => 'Migrate est génial', + 'site_name' => 'nom de site', + ], + [ + 'language' => 'mi', + 'site_slogan' => 'Ko whakamataku heke', + 'site_name' => 'ingoa_pae', + ], + ]; + + // The expected count. + $tests[0]['expected_count'] = NULL; + + // The migration configuration. + $tests[0]['configuration']['variables'] = [ + 'site_slogan', + 'site_name', + ]; + + return $tests; + } + +} diff --git a/core/modules/migrate_drupal/tests/src/Kernel/dependencies/MigrateDependenciesTest.php b/core/modules/migrate_drupal/tests/src/Kernel/dependencies/MigrateDependenciesTest.php index ba0dc5a..1a30489 100644 --- a/core/modules/migrate_drupal/tests/src/Kernel/dependencies/MigrateDependenciesTest.php +++ b/core/modules/migrate_drupal/tests/src/Kernel/dependencies/MigrateDependenciesTest.php @@ -33,17 +33,18 @@ public function testMigrateDependenciesOrder() { 'd6_node:company', 'd6_node:employee', 'd6_node:event', + 'd6_node:forum', 'd6_node:page', + 'd6_user', + 'd6_node_type', + 'd6_node_settings', + 'd6_filter_format', 'd6_node:sponsor', 'd6_node:story', 'd6_node:test_event', 'd6_node:test_page', 'd6_node:test_planet', 'd6_node:test_story', - 'd6_node_type', - 'd6_node_settings', - 'd6_filter_format', - 'd6_user', 'd6_comment_type', 'd6_comment_entity_display', 'd6_comment_entity_form_display', diff --git a/core/modules/migrate_drupal/tests/src/Unit/source/VariableMultiRowSourceWithHighwaterTest.php b/core/modules/migrate_drupal/tests/src/Unit/source/VariableMultiRowSourceWithHighwaterTest.php deleted file mode 100644 index f97b7a7..0000000 --- a/core/modules/migrate_drupal/tests/src/Unit/source/VariableMultiRowSourceWithHighwaterTest.php +++ /dev/null @@ -1,20 +0,0 @@ -migrationConfiguration['highWaterProperty']['field'] = 'test'; - parent::setup(); - } - -} diff --git a/core/modules/migrate_drupal/tests/src/Unit/source/VariableMultiRowTest.php b/core/modules/migrate_drupal/tests/src/Unit/source/VariableMultiRowTest.php deleted file mode 100644 index 1ba95ba..0000000 --- a/core/modules/migrate_drupal/tests/src/Unit/source/VariableMultiRowTest.php +++ /dev/null @@ -1,12 +0,0 @@ - 'simpletest', 'destination_module' => 'simpletest', ], - 'd6_statistics_settings' => [ + 'statistics_settings' => [ 'source_module' => 'statistics', 'destination_module' => 'statistics', ], diff --git a/core/modules/migrate_drupal_ui/src/Tests/MigrateUpgradeTestBase.php b/core/modules/migrate_drupal_ui/src/Tests/MigrateUpgradeTestBase.php index 85a9e79..b635f2a 100644 --- a/core/modules/migrate_drupal_ui/src/Tests/MigrateUpgradeTestBase.php +++ b/core/modules/migrate_drupal_ui/src/Tests/MigrateUpgradeTestBase.php @@ -30,7 +30,16 @@ * * @var array */ - public static $modules = ['language', 'content_translation', 'migrate_drupal_ui', 'telephone']; + public static $modules = [ + 'language', + 'content_translation', + 'migrate_drupal_ui', + 'telephone', + 'aggregator', + 'book', + 'forum', + 'statistics', + ]; /** * {@inheritdoc} @@ -143,8 +152,9 @@ public function testMigrateUpgrade() { $this->resetAll(); $expected_counts = $this->getEntityCounts(); - foreach (array_keys(\Drupal::entityTypeManager()->getDefinitions()) as $entity_type) { - $real_count = count(\Drupal::entityTypeManager()->getStorage($entity_type)->loadMultiple()); + foreach (array_keys(\Drupal::entityTypeManager() + ->getDefinitions()) as $entity_type) { + $real_count = \Drupal::entityQuery($entity_type)->count()->execute(); $expected_count = isset($expected_counts[$entity_type]) ? $expected_counts[$entity_type] : 0; $this->assertEqual($expected_count, $real_count, "Found $real_count $entity_type entities, expected $expected_count."); } diff --git a/core/modules/migrate_drupal_ui/src/Tests/d6/MigrateUpgrade6Test.php b/core/modules/migrate_drupal_ui/src/Tests/d6/MigrateUpgrade6Test.php index 404f21b..29479c4 100644 --- a/core/modules/migrate_drupal_ui/src/Tests/d6/MigrateUpgrade6Test.php +++ b/core/modules/migrate_drupal_ui/src/Tests/d6/MigrateUpgrade6Test.php @@ -34,42 +34,44 @@ protected function getSourceBasePath() { */ protected function getEntityCounts() { return [ - 'block' => 30, + 'aggregator_item' => 1, + 'aggregator_feed' => 1, + 'block' => 35, 'block_content' => 2, 'block_content_type' => 1, 'comment' => 3, - 'comment_type' => 2, + 'comment_type' => 3, 'contact_form' => 5, 'configurable_language' => 5, 'editor' => 2, - 'field_config' => 63, - 'field_storage_config' => 43, + 'field_config' => 71, + 'field_storage_config' => 46, 'file' => 7, 'filter_format' => 7, 'image_style' => 5, 'language_content_settings' => 2, 'migration' => 105, 'node' => 11, - 'node_type' => 11, - 'rdf_mapping' => 5, + 'node_type' => 13, + 'rdf_mapping' => 7, 'search_page' => 2, 'shortcut' => 2, 'shortcut_set' => 1, 'action' => 22, 'menu' => 8, 'taxonomy_term' => 6, - 'taxonomy_vocabulary' => 5, + 'taxonomy_vocabulary' => 6, 'tour' => 4, 'user' => 7, 'user_role' => 6, 'menu_link_content' => 4, - 'view' => 12, + 'view' => 14, 'date_format' => 11, - 'entity_form_display' => 15, + 'entity_form_display' => 19, 'entity_form_mode' => 1, - 'entity_view_display' => 32, - 'entity_view_mode' => 12, - 'base_field_override' => 33, + 'entity_view_display' => 41, + 'entity_view_mode' => 14, + 'base_field_override' => 38, ]; } diff --git a/core/modules/migrate_drupal_ui/src/Tests/d7/MigrateUpgrade7Test.php b/core/modules/migrate_drupal_ui/src/Tests/d7/MigrateUpgrade7Test.php index 86f1ca9..94a42e2 100644 --- a/core/modules/migrate_drupal_ui/src/Tests/d7/MigrateUpgrade7Test.php +++ b/core/modules/migrate_drupal_ui/src/Tests/d7/MigrateUpgrade7Test.php @@ -34,17 +34,19 @@ protected function getSourceBasePath() { */ protected function getEntityCounts() { return [ + 'aggregator_item' => 10, + 'aggregator_feed' => 1, 'block' => 25, 'block_content' => 1, 'block_content_type' => 1, 'comment' => 1, - 'comment_type' => 7, + 'comment_type' => 8, // Module 'language' comes with 'en', 'und', 'zxx'. Migration adds 'is'. 'configurable_language' => 4, 'contact_form' => 3, 'editor' => 2, - 'field_config' => 49, - 'field_storage_config' => 37, + 'field_config' => 52, + 'field_storage_config' => 39, 'file' => 2, 'filter_format' => 7, 'image_style' => 6, @@ -52,25 +54,25 @@ protected function getEntityCounts() { 'migration' => 73, 'node' => 3, 'node_type' => 6, - 'rdf_mapping' => 5, + 'rdf_mapping' => 7, 'search_page' => 2, 'shortcut' => 6, 'shortcut_set' => 2, 'action' => 16, 'menu' => 6, 'taxonomy_term' => 18, - 'taxonomy_vocabulary' => 3, + 'taxonomy_vocabulary' => 4, 'tour' => 4, 'user' => 4, 'user_role' => 3, 'menu_link_content' => 7, - 'view' => 12, + 'view' => 14, 'date_format' => 11, - 'entity_form_display' => 16, + 'entity_form_display' => 18, 'entity_form_mode' => 1, - 'entity_view_display' => 24, - 'entity_view_mode' => 11, - 'base_field_override' => 7, + 'entity_view_display' => 29, + 'entity_view_mode' => 14, + 'base_field_override' => 9, ]; } diff --git a/core/modules/node/node.permissions.yml b/core/modules/node/node.permissions.yml index 753ed40..289227c 100644 --- a/core/modules/node/node.permissions.yml +++ b/core/modules/node/node.permissions.yml @@ -18,12 +18,13 @@ view own unpublished content: title: 'View own unpublished content' view all revisions: title: 'View all revisions' + description: 'To view a revision, you also need permission to view the content item.' revert all revisions: title: 'Revert all revisions' - description: 'Role requires permission view revisions and edit rights for nodes in question or administer nodes.' + description: 'To revert a revision, you also need permission to edit the content item.' delete all revisions: title: 'Delete all revisions' - description: 'Role requires permission to view revisions and delete rights for nodes in question or administer nodes.' + description: 'To delete a revision, you also need permission to delete the content item.' permission_callbacks: - \Drupal\node\NodePermissions::nodeTypePermissions diff --git a/core/modules/node/src/Entity/Node.php b/core/modules/node/src/Entity/Node.php index 4f8efe3..c11a188 100644 --- a/core/modules/node/src/Entity/Node.php +++ b/core/modules/node/src/Entity/Node.php @@ -61,6 +61,11 @@ * "published" = "status", * "uid" = "uid", * }, + * revision_metadata_keys = { + * "revision_user" = "revision_uid", + * "revision_created" = "revision_timestamp", + * "revision_log_message" = "revision_log" + * }, * bundle_entity_type = "node_type", * field_ui_base_route = "entity.node_type.edit_form", * common_reference_target = TRUE, @@ -453,14 +458,12 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { $fields['revision_timestamp'] = BaseFieldDefinition::create('created') ->setLabel(t('Revision timestamp')) ->setDescription(t('The time that the current revision was created.')) - ->setQueryable(FALSE) ->setRevisionable(TRUE); $fields['revision_uid'] = BaseFieldDefinition::create('entity_reference') ->setLabel(t('Revision user ID')) ->setDescription(t('The user ID of the author of the current revision.')) ->setSetting('target_type', 'user') - ->setQueryable(FALSE) ->setRevisionable(TRUE); $fields['revision_log'] = BaseFieldDefinition::create('string_long') diff --git a/core/modules/node/src/NodePermissions.php b/core/modules/node/src/NodePermissions.php index 5754d71..1996360 100644 --- a/core/modules/node/src/NodePermissions.php +++ b/core/modules/node/src/NodePermissions.php @@ -62,14 +62,15 @@ protected function buildPermissions(NodeType $type) { ], "view $type_id revisions" => [ 'title' => $this->t('%type_name: View revisions', $type_params), + 'description' => t('To view a revision, you also need permission to view the content item.'), ], "revert $type_id revisions" => [ 'title' => $this->t('%type_name: Revert revisions', $type_params), - 'description' => t('Role requires permission view revisions and edit rights for nodes in question, or administer nodes.'), + 'description' => t('To revert a revision, you also need permission to edit the content item.'), ], "delete $type_id revisions" => [ 'title' => $this->t('%type_name: Delete revisions', $type_params), - 'description' => $this->t('Role requires permission to view revisions and delete rights for nodes in question, or administer nodes.'), + 'description' => $this->t('To delete a revision, you also need permission to delete the content item.'), ], ]; } diff --git a/core/modules/node/src/Plugin/migrate/D6NodeDeriver.php b/core/modules/node/src/Plugin/migrate/D6NodeDeriver.php index 6d9b67a..536303e 100644 --- a/core/modules/node/src/Plugin/migrate/D6NodeDeriver.php +++ b/core/modules/node/src/Plugin/migrate/D6NodeDeriver.php @@ -9,6 +9,7 @@ use Drupal\migrate\Exception\RequirementsException; use Drupal\migrate\Plugin\MigrationDeriverTrait; use Drupal\migrate_drupal\Plugin\MigrateCckFieldPluginManagerInterface; +use Drupal\migrate_drupal\Plugin\MigrateFieldPluginManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -39,6 +40,20 @@ class D6NodeDeriver extends DeriverBase implements ContainerDeriverInterface { protected $cckPluginManager; /** + * Already-instantiated field plugins, keyed by ID. + * + * @var \Drupal\migrate_drupal\Plugin\MigrateFieldInterface[] + */ + protected $fieldPluginCache; + + /** + * The field plugin manager. + * + * @var \Drupal\migrate_drupal\Plugin\MigrateFieldPluginManagerInterface + */ + protected $fieldPluginManager; + + /** * Whether or not to include translations. * * @var bool @@ -52,12 +67,15 @@ class D6NodeDeriver extends DeriverBase implements ContainerDeriverInterface { * The base plugin ID for the plugin ID. * @param \Drupal\migrate_drupal\Plugin\MigrateCckFieldPluginManagerInterface $cck_manager * The CCK plugin manager. + * @param \Drupal\migrate_drupal\Plugin\MigrateFieldPluginManagerInterface $field_manager + * The field plugin manager. * @param bool $translations * Whether or not to include translations. */ - public function __construct($base_plugin_id, MigrateCckFieldPluginManagerInterface $cck_manager, $translations) { + public function __construct($base_plugin_id, MigrateCckFieldPluginManagerInterface $cck_manager, MigrateFieldPluginManagerInterface $field_manager, $translations) { $this->basePluginId = $base_plugin_id; $this->cckPluginManager = $cck_manager; + $this->fieldPluginManager = $field_manager; $this->includeTranslations = $translations; } @@ -69,6 +87,7 @@ public static function create(ContainerInterface $container, $base_plugin_id) { return new static( $base_plugin_id, $container->get('plugin.manager.migrate.cckfield'), + $container->get('plugin.manager.migrate.field'), $container->get('module_handler')->moduleExists('content_translation') ); } @@ -100,7 +119,7 @@ public function getDerivativeDefinitions($base_plugin_definition) { return $this->derivatives; } - // Read all CCK field instance definitions in the source database. + // Read all field instance definitions in the source database. $fields = []; try { $source_plugin = static::getSourcePlugin('d6_field_instance'); @@ -112,7 +131,7 @@ public function getDerivativeDefinitions($base_plugin_definition) { } catch (RequirementsException $e) { // If checkRequirements() failed then the content module did not exist and - // we do not have any CCK fields. Therefore, $fields will be empty and + // we do not have any fields. Therefore, $fields will be empty and // below we'll create a migration just for the node properties. } @@ -135,20 +154,31 @@ public function getDerivativeDefinitions($base_plugin_definition) { $values['migration_dependencies']['required'][] = 'd6_node:' . $node_type; } + /** @var \Drupal\migrate\Plugin\Migration $migration */ $migration = \Drupal::service('plugin.manager.migration')->createStubMigration($values); if (isset($fields[$node_type])) { foreach ($fields[$node_type] as $field_name => $info) { $field_type = $info['type']; try { - $plugin_id = $this->cckPluginManager->getPluginIdFromFieldType($field_type, ['core' => 6], $migration); - if (!isset($this->cckPluginCache[$field_type])) { - $this->cckPluginCache[$field_type] = $this->cckPluginManager->createInstance($plugin_id, ['core' => 6], $migration); + $plugin_id = $this->fieldPluginManager->getPluginIdFromFieldType($field_type, ['core' => 6], $migration); + if (!isset($this->fieldPluginCache[$field_type])) { + $this->fieldPluginCache[$field_type] = $this->fieldPluginManager->createInstance($plugin_id, ['core' => 6], $migration); } - $this->cckPluginCache[$field_type] - ->processCckFieldValues($migration, $field_name, $info); + $this->fieldPluginCache[$field_type] + ->processFieldValues($migration, $field_name, $info); } catch (PluginNotFoundException $ex) { - $migration->setProcessOfProperty($field_name, $field_name); + try { + $plugin_id = $this->cckPluginManager->getPluginIdFromFieldType($field_type, ['core' => 6], $migration); + if (!isset($this->cckPluginCache[$field_type])) { + $this->cckPluginCache[$field_type] = $this->cckPluginManager->createInstance($plugin_id, ['core' => 6], $migration); + } + $this->cckPluginCache[$field_type] + ->processCckFieldValues($migration, $field_name, $info); + } + catch (PluginNotFoundException $ex) { + $migration->setProcessOfProperty($field_name, $field_name); + } } } } diff --git a/core/modules/node/src/Plugin/migrate/D7NodeDeriver.php b/core/modules/node/src/Plugin/migrate/D7NodeDeriver.php index a35c376..da01c70 100644 --- a/core/modules/node/src/Plugin/migrate/D7NodeDeriver.php +++ b/core/modules/node/src/Plugin/migrate/D7NodeDeriver.php @@ -9,6 +9,7 @@ use Drupal\migrate\Exception\RequirementsException; use Drupal\migrate\Plugin\MigrationDeriverTrait; use Drupal\migrate_drupal\Plugin\MigrateCckFieldPluginManagerInterface; +use Drupal\migrate_drupal\Plugin\MigrateFieldPluginManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -39,6 +40,20 @@ class D7NodeDeriver extends DeriverBase implements ContainerDeriverInterface { protected $cckPluginManager; /** + * Already-instantiated field plugins, keyed by ID. + * + * @var \Drupal\migrate_drupal\Plugin\MigrateFieldInterface[] + */ + protected $fieldPluginCache; + + /** + * The field plugin manager. + * + * @var \Drupal\migrate_drupal\Plugin\MigrateFieldPluginManagerInterface + */ + protected $fieldPluginManager; + + /** * Whether or not to include translations. * * @var bool @@ -52,12 +67,15 @@ class D7NodeDeriver extends DeriverBase implements ContainerDeriverInterface { * The base plugin ID for the plugin ID. * @param \Drupal\migrate_drupal\Plugin\MigrateCckFieldPluginManagerInterface $cck_manager * The CCK plugin manager. + * @param \Drupal\migrate_drupal\Plugin\MigrateFieldPluginManagerInterface $field_manager + * The field plugin manager. * @param bool $translations * Whether or not to include translations. */ - public function __construct($base_plugin_id, MigrateCckFieldPluginManagerInterface $cck_manager, $translations) { + public function __construct($base_plugin_id, MigrateCckFieldPluginManagerInterface $cck_manager, MigrateFieldPluginManagerInterface $field_manager, $translations) { $this->basePluginId = $base_plugin_id; $this->cckPluginManager = $cck_manager; + $this->fieldPluginManager = $field_manager; $this->includeTranslations = $translations; } @@ -69,6 +87,7 @@ public static function create(ContainerInterface $container, $base_plugin_id) { return new static( $base_plugin_id, $container->get('plugin.manager.migrate.cckfield'), + $container->get('plugin.manager.migrate.field'), $container->get('module_handler')->moduleExists('content_translation') ); } @@ -134,15 +153,25 @@ public function getDerivativeDefinitions($base_plugin_definition) { foreach ($fields[$node_type] as $field_name => $info) { $field_type = $info['type']; try { - $plugin_id = $this->cckPluginManager->getPluginIdFromFieldType($field_type, ['core' => 7], $migration); - if (!isset($this->cckPluginCache[$field_type])) { - $this->cckPluginCache[$field_type] = $this->cckPluginManager->createInstance($plugin_id, ['core' => 7], $migration); + $plugin_id = $this->fieldPluginManager->getPluginIdFromFieldType($field_type, ['core' => 7], $migration); + if (!isset($this->fieldPluginCache[$field_type])) { + $this->fieldPluginCache[$field_type] = $this->fieldPluginManager->createInstance($plugin_id, ['core' => 7], $migration); } - $this->cckPluginCache[$field_type] - ->processCckFieldValues($migration, $field_name, $info); + $this->fieldPluginCache[$field_type] + ->processFieldValues($migration, $field_name, $info); } catch (PluginNotFoundException $ex) { - $migration->setProcessOfProperty($field_name, $field_name); + try { + $plugin_id = $this->cckPluginManager->getPluginIdFromFieldType($field_type, ['core' => 7], $migration); + if (!isset($this->cckPluginCache[$field_type])) { + $this->cckPluginCache[$field_type] = $this->cckPluginManager->createInstance($plugin_id, ['core' => 7], $migration); + } + $this->cckPluginCache[$field_type] + ->processCckFieldValues($migration, $field_name, $info); + } + catch (PluginNotFoundException $ex) { + $migration->setProcessOfProperty($field_name, $field_name); + } } } } diff --git a/core/modules/node/src/Plugin/views/argument/UidRevision.php b/core/modules/node/src/Plugin/views/argument/UidRevision.php index 9e2fb81..0f989a7 100644 --- a/core/modules/node/src/Plugin/views/argument/UidRevision.php +++ b/core/modules/node/src/Plugin/views/argument/UidRevision.php @@ -15,7 +15,7 @@ class UidRevision extends Uid { public function query($group_by = FALSE) { $this->ensureMyTable(); $placeholder = $this->placeholder(); - $this->query->addWhereExpression(0, "$this->tableAlias.revision_uid = $placeholder OR ((SELECT COUNT(DISTINCT vid) FROM {node_revision} nr WHERE nfr.revision_uid = $placeholder AND nr.nid = $this->tableAlias.nid) > 0)", [$placeholder => $this->argument]); + $this->query->addWhereExpression(0, "$this->tableAlias.uid = $placeholder OR ((SELECT COUNT(DISTINCT vid) FROM {node_revision} nr WHERE nr.revision_uid = $placeholder AND nr.nid = $this->tableAlias.nid) > 0)", [$placeholder => $this->argument]); } } diff --git a/core/modules/node/src/Plugin/views/filter/Access.php b/core/modules/node/src/Plugin/views/filter/Access.php index 73844e1..10ae922 100644 --- a/core/modules/node/src/Plugin/views/filter/Access.php +++ b/core/modules/node/src/Plugin/views/filter/Access.php @@ -2,6 +2,7 @@ namespace Drupal\node\Plugin\views\filter; +use Drupal\Core\Database\Query\Condition; use Drupal\Core\Form\FormStateInterface; use Drupal\views\Plugin\views\filter\FilterPluginBase; @@ -27,10 +28,10 @@ public function query() { $account = $this->view->getUser(); if (!$account->hasPermission('bypass node access')) { $table = $this->ensureMyTable(); - $grants = db_or(); + $grants = new Condition('OR'); foreach (node_access_grants('view', $account) as $realm => $gids) { foreach ($gids as $gid) { - $grants->condition(db_and() + $grants->condition((new Condition('AND')) ->condition($table . '.gid', $gid) ->condition($table . '.realm', $realm) ); diff --git a/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_argument_node_uid_revision.yml b/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_argument_node_uid_revision.yml new file mode 100644 index 0000000..00ee678 --- /dev/null +++ b/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_argument_node_uid_revision.yml @@ -0,0 +1,106 @@ +langcode: en +status: true +dependencies: + module: + - node + - user +id: test_argument_node_uid_revision +label: test_argument_node_uid_revision +module: views +description: '' +tag: default +base_table: node_field_data +base_field: nid +core: 8.0-dev +display: + default: + display_options: + access: + type: perm + cache: + type: tag + exposed_form: + type: basic + fields: + nid: + id: nid + table: node_field_data + field: nid + plugin_id: field + entity_type: node + entity_field: nid + filter_groups: + groups: + 1: AND + operator: AND + filters: { } + sorts: + nid: + id: nid + table: node_field_data + field: nid + order: ASC + plugin_id: standard + relationship: none + entity_type: node + entity_field: nid + pager: + type: full + query: + type: views_query + style: + type: default + row: + type: fields + display_extenders: { } + arguments: + uid_revision: + id: uid_revision + table: node_field_data + field: uid_revision + relationship: none + group_type: group + admin_label: '' + default_action: empty + exception: + value: all + title_enable: false + title: All + title_enable: false + title: '' + default_argument_type: fixed + default_argument_options: + argument: '' + default_argument_skip_url: false + summary_options: + base_path: '' + count: true + items_per_page: 25 + override: false + summary: + sort_order: asc + number_of_records: 0 + format: default_summary + specify_validation: false + validate: + type: none + fail: 'not found' + validate_options: { } + break_phrase: false + not: false + entity_type: node + plugin_id: node_uid_revision + display_plugin: default + display_title: Master + id: default + position: 0 + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } diff --git a/core/modules/node/tests/src/Kernel/Views/ArgumentUidRevisionTest.php b/core/modules/node/tests/src/Kernel/Views/ArgumentUidRevisionTest.php new file mode 100644 index 0000000..b216f40 --- /dev/null +++ b/core/modules/node/tests/src/Kernel/Views/ArgumentUidRevisionTest.php @@ -0,0 +1,93 @@ +installEntitySchema('node'); + $this->installSchema('node', ['node_access']); + $this->installEntitySchema('user'); + $this->installConfig(['node', 'field']); + + ViewTestData::createTestViews(get_class($this), ['node_test_views']); + } + + /** + * Tests the node_uid_revision argument. + */ + public function testArgument() { + $expected_result = []; + + $author = $this->createUser(); + $no_author = $this->createUser(); + + // Create one node, with the author as the node author. + $node1 = Node::create([ + 'type' => 'default', + 'title' => $this->randomMachineName(), + ]); + $node1->setOwner($author); + $node1->save(); + $expected_result[] = ['nid' => $node1->id()]; + + // Create one node of which an additional revision author will be the + // author. + $node2 = Node::create([ + 'type' => 'default', + 'title' => $this->randomMachineName(), + ]); + $node2->setRevisionAuthorId($no_author->id()); + $node2->save(); + $expected_result[] = ['nid' => $node2->id()]; + + // Force to add a new revision. + $node2->setNewRevision(TRUE); + $node2->setRevisionAuthorId($author->id()); + $node2->save(); + + // Create one node on which the author has neither authorship of revisions + // or the main node. + $node3 = Node::create([ + 'type' => 'default', + 'title' => $this->randomMachineName(), + ]); + $node3->setOwner($no_author); + $node3->save(); + + $view = Views::getView('test_argument_node_uid_revision'); + $view->initHandlers(); + $view->setArguments(['uid_revision' => $author->id()]); + + $this->executeView($view); + $this->assertIdenticalResultset($view, $expected_result, ['nid' => 'nid']); + } + +} diff --git a/core/modules/outside_in/outside_in.module b/core/modules/outside_in/outside_in.module index a7fab7b..9a0e629 100644 --- a/core/modules/outside_in/outside_in.module +++ b/core/modules/outside_in/outside_in.module @@ -34,12 +34,21 @@ function outside_in_help($route_name, RouteMatchInterface $route_match) { */ function outside_in_contextual_links_view_alter(&$element, $items) { if (isset($element['#links']['outside-inblock-configure'])) { + // Place outside_in link first. + $outside_in_link = $element['#links']['outside-inblock-configure']; + unset($element['#links']['outside-inblock-configure']); + $element['#links'] = ['outside-inblock-configure' => $outside_in_link] + $element['#links']; + $element['#links']['outside-inblock-configure']['attributes'] = [ 'class' => ['use-ajax'], 'data-dialog-type' => 'dialog', 'data-dialog-renderer' => 'offcanvas', 'data-outside-in-edit' => TRUE, ]; + // If this is content block change title to avoid duplicate "Quick Edit". + if (isset($element['#links']['block-contentblock-edit'])) { + $element['#links']['outside-inblock-configure']['title'] = t('Quick edit settings'); + } $element['#attached']['library'][] = 'outside_in/drupal.off_canvas'; } diff --git a/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInBlockFormTest.php b/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInBlockFormTest.php index c62436c..a5dc740 100644 --- a/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInBlockFormTest.php +++ b/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInBlockFormTest.php @@ -2,6 +2,8 @@ namespace Drupal\Tests\outside_in\FunctionalJavascript; +use Drupal\block_content\Entity\BlockContent; +use Drupal\block_content\Entity\BlockContentType; use Drupal\user\Entity\Role; /** @@ -26,6 +28,7 @@ class OutsideInBlockFormTest extends OutsideInJavascriptTestBase { 'outside_in', 'quickedit', 'search', + 'block_content', // Add test module to override CSS pointer-events properties because they // cause test failures. 'outside_in_test_css', @@ -36,6 +39,10 @@ class OutsideInBlockFormTest extends OutsideInJavascriptTestBase { */ protected function setUp() { parent::setUp(); + + $this->createBlockContentType('basic', TRUE); + $block_content = $this->createBlockContent('Custom Block', 'basic', TRUE); + // @todo Ensure that this test class works against bartik and stark: // https://www.drupal.org/node/2784881. $this->enableTheme('bartik'); @@ -49,6 +56,7 @@ protected function setUp() { ]); $this->drupalLogin($user); + $this->placeBlock('block_content:' . $block_content->uuid(), ['id' => 'custom']); $this->placeBlock('system_powered_by_block', ['id' => 'powered']); $this->placeBlock('system_branding_block', ['id' => 'branding']); $this->placeBlock('search_form_block', ['id' => 'search']); @@ -62,8 +70,13 @@ protected function setUp() { public function testBlocks($block_id, $new_page_text, $element_selector, $label_selector, $button_text, $toolbar_item) { $web_assert = $this->assertSession(); $page = $this->getSession()->getPage(); - $block_selector = '#' . $block_id; + $block_selector = '#block-' . $block_id; $this->drupalGet('user'); + + $link = $page->find('css', "$block_selector .contextual-links li a"); + $this->assertEquals('Quick edit', $link->getText(), "'Quick edit' is the first contextual link for the block."); + $this->assertContains("/admin/structure/block/manage/$block_id/offcanvas?destination=user/2", $link->getAttribute('href')); + if (isset($toolbar_item)) { // Check that you can open a toolbar tray and it will be closed after // entering edit mode. @@ -83,13 +96,13 @@ public function testBlocks($block_id, $new_page_text, $element_selector, $label_ $this->openBlockForm($block_selector); switch ($block_id) { - case 'block-powered': + case 'powered': // Fill out form, save the form. $page->fillField('settings[label]', $new_page_text); $page->checkField('settings[label_display]'); break; - case 'block-branding': + case 'branding': // Fill out form, save the form. $page->fillField('settings[site_information][site_name]', $new_page_text); break; @@ -137,7 +150,7 @@ public function testBlocks($block_id, $new_page_text, $element_selector, $label_ public function providerTestBlocks() { $blocks = [ 'block-powered' => [ - 'id' => 'block-powered', + 'id' => 'powered', 'new_page_text' => 'Can you imagine anyone showing the label on this block?', 'element_selector' => '.content a', 'label_selector' => 'h2', @@ -145,7 +158,7 @@ public function providerTestBlocks() { 'toolbar_item' => '#toolbar-item-user', ], 'block-branding' => [ - 'id' => 'block-branding', + 'id' => 'branding', 'new_page_text' => 'The site that will live a very short life.', 'element_selector' => 'a[rel="home"]:nth-child(2)', 'label_selector' => '.site-branding__name a', @@ -153,7 +166,7 @@ public function providerTestBlocks() { 'toolbar_item' => '#toolbar-item-administration', ], 'block-search' => [ - 'id' => 'block-search', + 'id' => 'search', 'new_page_text' => NULL, 'element_selector' => '#edit-submit', 'label_selector' => 'h2', @@ -364,4 +377,80 @@ protected function pressToolbarEditButton() { $this->assertSession()->assertWaitOnAjaxRequest(); } + /** + * Creates a custom block. + * + * @param bool|string $title + * (optional) Title of block. When no value is given uses a random name. + * Defaults to FALSE. + * @param string $bundle + * (optional) Bundle name. Defaults to 'basic'. + * @param bool $save + * (optional) Whether to save the block. Defaults to TRUE. + * + * @return \Drupal\block_content\Entity\BlockContent + * Created custom block. + */ + protected function createBlockContent($title = FALSE, $bundle = 'basic', $save = TRUE) { + $title = $title ?: $this->randomName(); + $block_content = BlockContent::create([ + 'info' => $title, + 'type' => $bundle, + 'langcode' => 'en', + 'body' => [ + 'value' => 'The name "llama" was adopted by European settlers from native Peruvians.', + 'format' => 'plain_text', + ], + ]); + if ($block_content && $save === TRUE) { + $block_content->save(); + } + return $block_content; + } + + /** + * Creates a custom block type (bundle). + * + * @param string $label + * The block type label. + * @param bool $create_body + * Whether or not to create the body field. + * + * @return \Drupal\block_content\Entity\BlockContentType + * Created custom block type. + */ + protected function createBlockContentType($label, $create_body = FALSE) { + $bundle = BlockContentType::create([ + 'id' => $label, + 'label' => $label, + 'revision' => FALSE, + ]); + $bundle->save(); + if ($create_body) { + block_content_add_body_field($bundle->id()); + } + return $bundle; + } + + /** + * Tests that contextual links in custom blocks are changed. + * + * "Quick edit" is quickedit.module link. + * "Quick edit settings" is outside_in.module link. + */ + public function testCustomBlockLinks() { + $this->drupalGet('user'); + $page = $this->getSession()->getPage(); + $links = $page->findAll('css', "#block-custom .contextual-links li a"); + $link_labels = []; + /** @var \Behat\Mink\Element\NodeElement $link */ + foreach ($links as $link) { + $link_labels[$link->getAttribute('href')] = $link->getText(); + } + $href = array_search('Quick edit', $link_labels); + $this->assertEquals('', $href); + $href = array_search('Quick edit settings', $link_labels); + $this->assertTrue(strstr($href, '/admin/structure/block/manage/custom/offcanvas?destination=user/2') !== FALSE); + } + } diff --git a/core/modules/quickedit/quickedit.services.yml b/core/modules/quickedit/quickedit.services.yml index 692ba2f..3d7d934 100644 --- a/core/modules/quickedit/quickedit.services.yml +++ b/core/modules/quickedit/quickedit.services.yml @@ -3,7 +3,7 @@ services: class: Drupal\quickedit\Plugin\InPlaceEditorManager parent: default_plugin_manager access_check.quickedit.entity_field: - class: Drupal\quickedit\Access\EditEntityFieldAccessCheck + class: Drupal\quickedit\Access\QuickEditEntityFieldAccessCheck tags: - { name: access_check, applies_to: _access_quickedit_entity_field } quickedit.editor.selector: diff --git a/core/modules/quickedit/src/Access/EditEntityFieldAccessCheck.php b/core/modules/quickedit/src/Access/EditEntityFieldAccessCheck.php index 7b38838..f937908 100644 --- a/core/modules/quickedit/src/Access/EditEntityFieldAccessCheck.php +++ b/core/modules/quickedit/src/Access/EditEntityFieldAccessCheck.php @@ -2,61 +2,9 @@ namespace Drupal\quickedit\Access; -use Drupal\Core\Access\AccessResult; -use Drupal\Core\Routing\Access\AccessInterface; -use Drupal\Core\Session\AccountInterface; -use Drupal\Core\Entity\EntityInterface; - /** - * Access check for editing entity fields. + * @deprecated in Drupal 8.4.x and will be removed before Drupal 9.0.0. */ -class EditEntityFieldAccessCheck implements AccessInterface, EditEntityFieldAccessCheckInterface { - - /** - * Checks Quick Edit access to the field. - * - * @param \Drupal\Core\Entity\EntityInterface $entity - * The entity containing the field. - * @param string $field_name - * The field name. - * @param string $langcode - * The langcode. - * @param \Drupal\Core\Session\AccountInterface $account - * The currently logged in account. - * - * @return \Drupal\Core\Access\AccessResultInterface - * The access result. - * - * @todo Use the $account argument: https://www.drupal.org/node/2266809. - */ - public function access(EntityInterface $entity, $field_name, $langcode, AccountInterface $account) { - if (!$this->validateRequestAttributes($entity, $field_name, $langcode)) { - return AccessResult::forbidden(); - } - - return $this->accessEditEntityField($entity, $field_name); - } - - /** - * {@inheritdoc} - */ - public function accessEditEntityField(EntityInterface $entity, $field_name) { - return $entity->access('update', NULL, TRUE)->andIf($entity->get($field_name)->access('edit', NULL, TRUE)); - } - - /** - * Validates request attributes. - */ - protected function validateRequestAttributes(EntityInterface $entity, $field_name, $langcode) { - // Validate the field name and language. - if (!$field_name || !$entity->hasField($field_name)) { - return FALSE; - } - if (!$langcode || !$entity->hasTranslation($langcode)) { - return FALSE; - } - - return TRUE; - } +class EditEntityFieldAccessCheck extends QuickEditEntityFieldAccessCheck { } diff --git a/core/modules/quickedit/src/Access/EditEntityFieldAccessCheckInterface.php b/core/modules/quickedit/src/Access/EditEntityFieldAccessCheckInterface.php index c7f14e2..cfdb32d 100644 --- a/core/modules/quickedit/src/Access/EditEntityFieldAccessCheckInterface.php +++ b/core/modules/quickedit/src/Access/EditEntityFieldAccessCheckInterface.php @@ -2,24 +2,9 @@ namespace Drupal\quickedit\Access; -use Drupal\Core\Entity\EntityInterface; - /** - * Access check for editing entity fields. + * @deprecated in Drupal 8.4.x and will be removed before Drupal 9.0.0. */ -interface EditEntityFieldAccessCheckInterface { - - /** - * Checks access to edit the requested field of the requested entity. - * - * @param \Drupal\Core\Entity\EntityInterface $entity - * The entity. - * @param string $field_name - * The field name. - * - * @return \Drupal\Core\Access\AccessResultInterface - * The access result. - */ - public function accessEditEntityField(EntityInterface $entity, $field_name); +interface EditEntityFieldAccessCheckInterface extends QuickEditEntityFieldAccessCheckInterface { } diff --git a/core/modules/quickedit/src/Access/QuickEditEntityFieldAccessCheck.php b/core/modules/quickedit/src/Access/QuickEditEntityFieldAccessCheck.php new file mode 100644 index 0000000..5404b04 --- /dev/null +++ b/core/modules/quickedit/src/Access/QuickEditEntityFieldAccessCheck.php @@ -0,0 +1,62 @@ +validateRequestAttributes($entity, $field_name, $langcode)) { + return AccessResult::forbidden(); + } + + return $this->accessEditEntityField($entity, $field_name); + } + + /** + * {@inheritdoc} + */ + public function accessEditEntityField(EntityInterface $entity, $field_name) { + return $entity->access('update', NULL, TRUE)->andIf($entity->get($field_name)->access('edit', NULL, TRUE)); + } + + /** + * Validates request attributes. + */ + protected function validateRequestAttributes(EntityInterface $entity, $field_name, $langcode) { + // Validate the field name and language. + if (!$field_name || !$entity->hasField($field_name)) { + return FALSE; + } + if (!$langcode || !$entity->hasTranslation($langcode)) { + return FALSE; + } + + return TRUE; + } + +} diff --git a/core/modules/quickedit/src/Access/QuickEditEntityFieldAccessCheckInterface.php b/core/modules/quickedit/src/Access/QuickEditEntityFieldAccessCheckInterface.php new file mode 100644 index 0000000..1428b48 --- /dev/null +++ b/core/modules/quickedit/src/Access/QuickEditEntityFieldAccessCheckInterface.php @@ -0,0 +1,25 @@ +accessChecker = $access_checker; $this->editorSelector = $editor_selector; $this->editorManager = $editor_manager; diff --git a/core/modules/quickedit/src/Tests/QuickEditLoadingTest.php b/core/modules/quickedit/src/Tests/QuickEditLoadingTest.php index a6705f4..41f26a8 100644 --- a/core/modules/quickedit/src/Tests/QuickEditLoadingTest.php +++ b/core/modules/quickedit/src/Tests/QuickEditLoadingTest.php @@ -115,12 +115,12 @@ public function testUserWithoutPermission() { $this->assertIdentical(Json::encode(['message' => "The 'access in-place editing' permission is required."]), $response); $this->assertResponse(403); - // Quick Edit's JavaScript would SearchRankingTestnever hit these endpoints if the metadata + // Quick Edit's JavaScript would never hit these endpoints if the metadata // was empty as above, but we need to make sure that malicious users aren't // able to use any of the other endpoints either. $post = ['editors[0]' => 'form'] + $this->getAjaxPageStatePostData(); $response = $this->drupalPost('quickedit/attachments', '', $post, ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']]); - $message = Json::encode(['message' => "A fatal error occurred: The 'access in-place editing' permission is required."]); + $message = Json::encode(['message' => "The 'access in-place editing' permission is required."]); $this->assertIdentical($message, $response); $this->assertResponse(403); $post = ['nocssjs' => 'true'] + $this->getAjaxPageStatePostData(); diff --git a/core/modules/quickedit/tests/modules/src/MockEditEntityFieldAccessCheck.php b/core/modules/quickedit/tests/modules/src/MockEditEntityFieldAccessCheck.php index b5d9456..7596aa8 100644 --- a/core/modules/quickedit/tests/modules/src/MockEditEntityFieldAccessCheck.php +++ b/core/modules/quickedit/tests/modules/src/MockEditEntityFieldAccessCheck.php @@ -2,19 +2,9 @@ namespace Drupal\quickedit_test; -use Drupal\Core\Entity\EntityInterface; -use Drupal\quickedit\Access\EditEntityFieldAccessCheckInterface; - /** - * Access check for editing entity fields. + * @deprecated in Drupal 8.4.x and will be removed before Drupal 9.0.0. */ -class MockEditEntityFieldAccessCheck implements EditEntityFieldAccessCheckInterface { - - /** - * {@inheritdoc} - */ - public function accessEditEntityField(EntityInterface $entity, $field_name) { - return TRUE; - } +class MockEditEntityFieldAccessCheck extends MockQuickEditEntityFieldAccessCheck { } diff --git a/core/modules/quickedit/tests/modules/src/MockQuickEditEntityFieldAccessCheck.php b/core/modules/quickedit/tests/modules/src/MockQuickEditEntityFieldAccessCheck.php new file mode 100644 index 0000000..2459c50 --- /dev/null +++ b/core/modules/quickedit/tests/modules/src/MockQuickEditEntityFieldAccessCheck.php @@ -0,0 +1,20 @@ +editorManager = $this->container->get('plugin.manager.quickedit.editor'); - $this->accessChecker = new MockEditEntityFieldAccessCheck(); + $this->accessChecker = new MockQuickEditEntityFieldAccessCheck(); $this->editorSelector = new EditorSelector($this->editorManager, $this->container->get('plugin.manager.field.formatter')); $this->metadataGenerator = new MetadataGenerator($this->accessChecker, $this->editorSelector, $this->editorManager); } diff --git a/core/modules/quickedit/tests/src/Unit/Access/EditEntityFieldAccessCheckTest.php b/core/modules/quickedit/tests/src/Unit/Access/EditEntityFieldAccessCheckTest.php deleted file mode 100644 index 7e7e0bb..0000000 --- a/core/modules/quickedit/tests/src/Unit/Access/EditEntityFieldAccessCheckTest.php +++ /dev/null @@ -1,149 +0,0 @@ -editAccessCheck = new EditEntityFieldAccessCheck(); - - $cache_contexts_manager = $this->prophesize(CacheContextsManager::class); - $cache_contexts_manager->assertValidTokens()->willReturn(TRUE); - $cache_contexts_manager->reveal(); - $container = new Container(); - $container->set('cache_contexts_manager', $cache_contexts_manager); - \Drupal::setContainer($container); - } - - /** - * Provides test data for testAccess(). - * - * @see \Drupal\Tests\edit\Unit\quickedit\Access\EditEntityFieldAccessCheckTest::testAccess() - */ - public function providerTestAccess() { - $data = []; - $data[] = [TRUE, TRUE, AccessResult::allowed()]; - $data[] = [FALSE, TRUE, AccessResult::neutral()]; - $data[] = [TRUE, FALSE, AccessResult::neutral()]; - $data[] = [FALSE, FALSE, AccessResult::neutral()]; - - return $data; - } - - /** - * Tests the method for checking access to routes. - * - * @param bool $entity_is_editable - * Whether the subject entity is editable. - * @param bool $field_storage_is_accessible - * Whether the user has access to the field storage entity. - * @param \Drupal\Core\Access\AccessResult $expected_result - * The expected result of the access call. - * - * @dataProvider providerTestAccess - */ - public function testAccess($entity_is_editable, $field_storage_is_accessible, AccessResult $expected_result) { - $entity = $this->createMockEntity(); - $entity->expects($this->any()) - ->method('access') - ->willReturn(AccessResult::allowedIf($entity_is_editable)->cachePerPermissions()); - - $field_storage = $this->getMock('Drupal\field\FieldStorageConfigInterface'); - $field_storage->expects($this->any()) - ->method('access') - ->willReturn(AccessResult::allowedIf($field_storage_is_accessible)); - - $expected_result->cachePerPermissions(); - - $field_name = 'valid'; - $entity_with_field = clone $entity; - $entity_with_field->expects($this->any()) - ->method('get') - ->with($field_name) - ->will($this->returnValue($field_storage)); - $entity_with_field->expects($this->once()) - ->method('hasTranslation') - ->with(LanguageInterface::LANGCODE_NOT_SPECIFIED) - ->will($this->returnValue(TRUE)); - - $account = $this->getMock('Drupal\Core\Session\AccountInterface'); - $access = $this->editAccessCheck->access($entity_with_field, $field_name, LanguageInterface::LANGCODE_NOT_SPECIFIED, $account); - $this->assertEquals($expected_result, $access); - } - - /** - * Tests checking access to routes that result in AccessResult::isForbidden(). - * - * @dataProvider providerTestAccessForbidden - */ - public function testAccessForbidden($field_name, $langcode) { - $account = $this->getMock('Drupal\Core\Session\AccountInterface'); - $entity = $this->createMockEntity(); - $this->assertEquals(AccessResult::forbidden(), $this->editAccessCheck->access($entity, $field_name, $langcode, $account)); - } - - /** - * Provides test data for testAccessForbidden. - */ - public function providerTestAccessForbidden() { - $data = []; - // Tests the access method without a field_name. - $data[] = [NULL, LanguageInterface::LANGCODE_NOT_SPECIFIED]; - // Tests the access method with a non-existent field. - $data[] = ['not_valid', LanguageInterface::LANGCODE_NOT_SPECIFIED]; - // Tests the access method without a langcode. - $data[] = ['valid', NULL]; - // Tests the access method with an invalid langcode. - $data[] = ['valid', 'xx-lolspeak']; - return $data; - } - - /** - * Returns a mock entity. - * - * @return \Drupal\Core\Entity\EntityInterface|\PHPUnit_Framework_MockObject_MockObject - */ - protected function createMockEntity() { - $entity = $this->getMockBuilder('Drupal\entity_test\Entity\EntityTest') - ->disableOriginalConstructor() - ->getMock(); - - $entity->expects($this->any()) - ->method('hasTranslation') - ->will($this->returnValueMap([ - [LanguageInterface::LANGCODE_NOT_SPECIFIED, TRUE], - ['xx-lolspeak', FALSE], - ])); - $entity->expects($this->any()) - ->method('hasField') - ->will($this->returnValueMap([ - ['valid', TRUE], - ['not_valid', FALSE], - ])); - - return $entity; - } - -} diff --git a/core/modules/quickedit/tests/src/Unit/Access/QuickEditEntityFieldAccessCheckTest.php b/core/modules/quickedit/tests/src/Unit/Access/QuickEditEntityFieldAccessCheckTest.php new file mode 100644 index 0000000..0827436 --- /dev/null +++ b/core/modules/quickedit/tests/src/Unit/Access/QuickEditEntityFieldAccessCheckTest.php @@ -0,0 +1,149 @@ +editAccessCheck = new QuickEditEntityFieldAccessCheck(); + + $cache_contexts_manager = $this->prophesize(CacheContextsManager::class); + $cache_contexts_manager->assertValidTokens()->willReturn(TRUE); + $cache_contexts_manager->reveal(); + $container = new Container(); + $container->set('cache_contexts_manager', $cache_contexts_manager); + \Drupal::setContainer($container); + } + + /** + * Provides test data for testAccess(). + * + * @see \Drupal\Tests\edit\Unit\quickedit\Access\QuickEditEntityFieldAccessCheckTest::testAccess() + */ + public function providerTestAccess() { + $data = []; + $data[] = [TRUE, TRUE, AccessResult::allowed()]; + $data[] = [FALSE, TRUE, AccessResult::neutral()]; + $data[] = [TRUE, FALSE, AccessResult::neutral()]; + $data[] = [FALSE, FALSE, AccessResult::neutral()]; + + return $data; + } + + /** + * Tests the method for checking access to routes. + * + * @param bool $entity_is_editable + * Whether the subject entity is editable. + * @param bool $field_storage_is_accessible + * Whether the user has access to the field storage entity. + * @param \Drupal\Core\Access\AccessResult $expected_result + * The expected result of the access call. + * + * @dataProvider providerTestAccess + */ + public function testAccess($entity_is_editable, $field_storage_is_accessible, AccessResult $expected_result) { + $entity = $this->createMockEntity(); + $entity->expects($this->any()) + ->method('access') + ->willReturn(AccessResult::allowedIf($entity_is_editable)->cachePerPermissions()); + + $field_storage = $this->getMock('Drupal\field\FieldStorageConfigInterface'); + $field_storage->expects($this->any()) + ->method('access') + ->willReturn(AccessResult::allowedIf($field_storage_is_accessible)); + + $expected_result->cachePerPermissions(); + + $field_name = 'valid'; + $entity_with_field = clone $entity; + $entity_with_field->expects($this->any()) + ->method('get') + ->with($field_name) + ->will($this->returnValue($field_storage)); + $entity_with_field->expects($this->once()) + ->method('hasTranslation') + ->with(LanguageInterface::LANGCODE_NOT_SPECIFIED) + ->will($this->returnValue(TRUE)); + + $account = $this->getMock('Drupal\Core\Session\AccountInterface'); + $access = $this->editAccessCheck->access($entity_with_field, $field_name, LanguageInterface::LANGCODE_NOT_SPECIFIED, $account); + $this->assertEquals($expected_result, $access); + } + + /** + * Tests checking access to routes that result in AccessResult::isForbidden(). + * + * @dataProvider providerTestAccessForbidden + */ + public function testAccessForbidden($field_name, $langcode) { + $account = $this->getMock('Drupal\Core\Session\AccountInterface'); + $entity = $this->createMockEntity(); + $this->assertEquals(AccessResult::forbidden(), $this->editAccessCheck->access($entity, $field_name, $langcode, $account)); + } + + /** + * Provides test data for testAccessForbidden. + */ + public function providerTestAccessForbidden() { + $data = []; + // Tests the access method without a field_name. + $data[] = [NULL, LanguageInterface::LANGCODE_NOT_SPECIFIED]; + // Tests the access method with a non-existent field. + $data[] = ['not_valid', LanguageInterface::LANGCODE_NOT_SPECIFIED]; + // Tests the access method without a langcode. + $data[] = ['valid', NULL]; + // Tests the access method with an invalid langcode. + $data[] = ['valid', 'xx-lolspeak']; + return $data; + } + + /** + * Returns a mock entity. + * + * @return \Drupal\Core\Entity\EntityInterface|\PHPUnit_Framework_MockObject_MockObject + */ + protected function createMockEntity() { + $entity = $this->getMockBuilder('Drupal\entity_test\Entity\EntityTest') + ->disableOriginalConstructor() + ->getMock(); + + $entity->expects($this->any()) + ->method('hasTranslation') + ->will($this->returnValueMap([ + [LanguageInterface::LANGCODE_NOT_SPECIFIED, TRUE], + ['xx-lolspeak', FALSE], + ])); + $entity->expects($this->any()) + ->method('hasField') + ->will($this->returnValueMap([ + ['valid', TRUE], + ['not_valid', FALSE], + ])); + + return $entity; + } + +} diff --git a/core/modules/rest/rest.module b/core/modules/rest/rest.module index afdb9ff..edd74b9 100644 --- a/core/modules/rest/rest.module +++ b/core/modules/rest/rest.module @@ -15,13 +15,13 @@ function rest_help($route_name, RouteMatchInterface $route_match) { case 'help.page.rest': $output = ''; $output .= '

' . t('About') . '

'; - $output .= '

' . t('The RESTful Web Services module provides a framework for exposing REST resources on your site. It provides support for content entities (see the Field module help page for more information about entities) such as content, users, taxonomy terms, etc.; REST support for content items of the Node module is enabled by default, and support for other types of content entities can be enabled. Other modules may add support for other types of REST resources. For more information, see the online documentation for the RESTful Web Services module.', [':rest' => 'https://www.drupal.org/documentation/modules/rest', ':field' => (\Drupal::moduleHandler()->moduleExists('field')) ? \Drupal::url('help.page', ['name' => 'field']) : '#']) . '

'; + $output .= '

' . t('The RESTful Web Services module provides a framework for exposing REST resources on your site. It provides support for content entity types such as the main site content, comments, custom blocks, taxonomy terms, and user accounts, etc. (see the Field module help page for more information about entities). REST support for content items of the Node module is enabled by default, and support for other types of content entities can be enabled. Other modules may add support for other types of REST resources. For more information, see the online documentation for the RESTful Web Services module.', [':rest' => 'https://www.drupal.org/documentation/modules/rest', ':field' => (\Drupal::moduleHandler()->moduleExists('field')) ? \Drupal::url('help.page', ['name' => 'field']) : '#']) . '

'; $output .= '

' . t('Uses') . '

'; $output .= '
'; $output .= '
' . t('Installing supporting modules') . '
'; $output .= '
' . t('In order to use REST on a web site, you need to install modules that provide serialization and authentication services. You can use the Core module HAL for serialization and HTTP Basic Authentication for authentication, or install a contributed or custom module.', [':hal' => (\Drupal::moduleHandler()->moduleExists('hal')) ? \Drupal::url('help.page', ['name' => 'hal']) : '#', ':basic_auth' => (\Drupal::moduleHandler()->moduleExists('basic_auth')) ? \Drupal::url('help.page', ['name' => 'basic_auth']) : '#']) . '
'; $output .= '
' . t('Enabling REST support for an entity type') . '
'; - $output .= '
' . t('REST support for content items of the Node module is enabled by default, and support for other types of content entities can be enabled. To enable support, you can use a process based on configuration editing or the contributed Rest UI module.', [':config' => 'https://www.drupal.org/documentation/modules/rest', ':restui' => 'https://www.drupal.org/project/restui']) . '
'; + $output .= '
' . t('REST support for content types (provided by the Node module) is enabled by default. To enable support for other content entity types, you can use a process based on configuration editing or the contributed REST UI module.', [':node' => (\Drupal::moduleHandler()->moduleExists('node')) ? \Drupal::url('help.page', ['name' => 'node']) : '#', ':config' => 'https://www.drupal.org/documentation/modules/rest', ':restui' => 'https://www.drupal.org/project/restui']) . '
'; $output .= '
' . t('You will also need to grant anonymous users permission to perform each of the REST operations you want to be available, and set up authentication properly to authorize web requests.') . '
'; $output .= '
'; return $output; diff --git a/core/modules/rest/src/Annotation/RestResource.php b/core/modules/rest/src/Annotation/RestResource.php index 0af11a8..d21f24c 100644 --- a/core/modules/rest/src/Annotation/RestResource.php +++ b/core/modules/rest/src/Annotation/RestResource.php @@ -23,14 +23,14 @@ class RestResource extends Plugin { /** - * The resource plugin ID. + * The REST resource plugin ID. * * @var string */ public $id; /** - * The human-readable name of the resource plugin. + * The human-readable name of the REST resource plugin. * * @ingroup plugin_translatable * @@ -41,8 +41,22 @@ class RestResource extends Plugin { /** * The serialization class to deserialize serialized data into. * + * @see \Symfony\Component\Serializer\SerializerInterface's "type" parameter. + * * @var string (optional) */ public $serialization_class; + /** + * The URI paths that this REST resource plugin provides. + * + * Key-value pairs, with link relation type plugin IDs as keys, and URL + * templates as values. + * + * @see core/core.link_relation_types.yml + * + * @var string[] + */ + public $uri_paths = []; + } diff --git a/core/modules/rest/src/Plugin/ResourceBase.php b/core/modules/rest/src/Plugin/ResourceBase.php index 3062aa2..50d8d75 100644 --- a/core/modules/rest/src/Plugin/ResourceBase.php +++ b/core/modules/rest/src/Plugin/ResourceBase.php @@ -111,16 +111,6 @@ public function routes() { switch ($method) { case 'POST': $route->setPath($create_path); - // Restrict the incoming HTTP Content-type header to the known - // serialization formats. - $route->addRequirements(['_content_type_format' => implode('|', $this->serializerFormats)]); - $collection->add("$route_name.$method", $route); - break; - - case 'PATCH': - // Restrict the incoming HTTP Content-type header to the known - // serialization formats. - $route->addRequirements(['_content_type_format' => implode('|', $this->serializerFormats)]); $collection->add("$route_name.$method", $route); break; diff --git a/core/modules/rest/src/Plugin/views/display/RestExport.php b/core/modules/rest/src/Plugin/views/display/RestExport.php index 15abaa6..cd96195 100644 --- a/core/modules/rest/src/Plugin/views/display/RestExport.php +++ b/core/modules/rest/src/Plugin/views/display/RestExport.php @@ -435,7 +435,7 @@ public function render() { $build['#markup'] = ViewsRenderPipelineMarkup::create($build['#markup']); } - parent::applyDisplayCachablityMetadata($build); + parent::applyDisplayCacheabilityMetadata($build); return $build; } diff --git a/core/modules/rest/src/RequestHandler.php b/core/modules/rest/src/RequestHandler.php index dd8e31a..e5437cc 100644 --- a/core/modules/rest/src/RequestHandler.php +++ b/core/modules/rest/src/RequestHandler.php @@ -11,8 +11,9 @@ use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; /** * Acts as intermediate request forwarder for resource plugins. @@ -59,22 +60,20 @@ public static function create(ContainerInterface $container) { * The response object. */ public function handle(RouteMatchInterface $route_match, Request $request) { - $method = strtolower($request->getMethod()); - // Symfony is built to transparently map HEAD requests to a GET request. In // the case of the REST module's RequestHandler though, we essentially have // our own light-weight routing system on top of the Drupal/symfony routing - // system. So, we have to do the same as what the UrlMatcher does: map HEAD - // requests to the logic for GET. This also guarantees response headers for - // HEAD requests are identical to those for GET requests, because we just - // return a GET response. Response::prepare() will transform it to a HEAD - // response at the very last moment. + // system. So, we have to respect the decision that the routing system made: + // we look not at the request method, but at the route's method. All REST + // routes are guaranteed to have _method set. + // Response::prepare() will transform it to a HEAD response at the very last + // moment. // @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4 // @see \Symfony\Component\Routing\Matcher\UrlMatcher::matchCollection() // @see \Symfony\Component\HttpFoundation\Response::prepare() - if ($method === 'head') { - $method = 'get'; - } + $method = strtolower($route_match->getRouteObject()->getMethods()[0]); + assert(count($route_match->getRouteObject()->getMethods()) === 1); + $resource_config_id = $route_match->getRouteObject()->getDefault('_rest_resource_config'); /** @var \Drupal\rest\RestResourceConfigInterface $resource_config */ @@ -89,29 +88,32 @@ public function handle(RouteMatchInterface $route_match, Request $request) { if (!empty($received)) { $format = $request->getContentType(); - // Only allow serialization formats that are explicitly configured. If no - // formats are configured allow all and hope that the serializer knows the - // format. If the serializer cannot handle it an exception will be thrown - // that bubbles up to the client. - $request_method = $request->getMethod(); - if (in_array($format, $resource_config->getFormats($request_method))) { - $definition = $resource->getPluginDefinition(); + $definition = $resource->getPluginDefinition(); + + // First decode the request data. We can then determine if the + // serialized data was malformed. + try { + $unserialized = $serializer->decode($received, $format, ['request_method' => $method]); + } + catch (UnexpectedValueException $e) { + // If an exception was thrown at this stage, there was a problem + // decoding the data. Throw a 400 http exception. + throw new BadRequestHttpException($e->getMessage()); + } + + // Then attempt to denormalize if there is a serialization class. + if (!empty($definition['serialization_class'])) { try { - if (!empty($definition['serialization_class'])) { - $unserialized = $serializer->deserialize($received, $definition['serialization_class'], $format, ['request_method' => $method]); - } - // If the plugin does not specify a serialization class just decode - // the received data. - else { - $unserialized = $serializer->decode($received, $format, ['request_method' => $method]); - } + $unserialized = $serializer->denormalize($unserialized, $definition['serialization_class'], $format, ['request_method' => $method]); } + // These two serialization exception types mean there was a problem + // with the structure of the decoded data and it's not valid. catch (UnexpectedValueException $e) { - throw new BadRequestHttpException($e->getMessage()); + throw new UnprocessableEntityHttpException($e->getMessage()); + } + catch (InvalidArgumentException $e) { + throw new UnprocessableEntityHttpException($e->getMessage()); } - } - else { - throw new UnsupportedMediaTypeHttpException(); } } diff --git a/core/modules/rest/src/Routing/ResourceRoutes.php b/core/modules/rest/src/Routing/ResourceRoutes.php index 6aec267..c21f13c 100644 --- a/core/modules/rest/src/Routing/ResourceRoutes.php +++ b/core/modules/rest/src/Routing/ResourceRoutes.php @@ -113,8 +113,15 @@ protected function getRoutesForResourceConfig(RestResourceConfigInterface $rest_ continue; } - // The configuration seems legit at this point, so we set the - // authentication provider and add the route. + // The configuration has been validated, so we update the route to: + // - set the allowed request body content types/formats for methods that + // allow request bodies to be sent + // - set the allowed authentication providers + if (in_array($method, ['POST', 'PATCH', 'PUT'], TRUE)) { + // Restrict the incoming HTTP Content-type header to the allowed + // formats. + $route->addRequirements(['_content_type_format' => implode('|', $rest_resource_config->getFormats($method))]); + } $route->setOption('_auth', $rest_resource_config->getAuthenticationProviders($method)); $route->setDefault('_rest_resource_config', $rest_resource_config->id()); $collection->add("rest.$name", $route); diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php index 0be7622..d86f9b1 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php @@ -122,9 +122,7 @@ protected function getExpectedCacheContexts() { protected function getExpectedCacheTags() { // Because the 'user.permissions' cache context is missing, the cache tag // for the anonymous user role is never added automatically. - return array_values(array_filter(parent::getExpectedCacheTags(), function ($tag) { - return $tag !== 'config:user.role.anonymous'; - })); + return array_values(array_diff(parent::getExpectedCacheTags(), ['config:user.role.anonymous'])); } /** diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php index 7fe517a..a731727 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php @@ -280,8 +280,8 @@ public function testPostDxWithoutCriticalBaseFields() { $response = $this->request('POST', $url, $request_options); // @todo Uncomment, remove next 3 lines in https://www.drupal.org/node/2820364. $this->assertSame(500, $response->getStatusCode()); - $this->assertSame(['application/json'], $response->getHeader('Content-Type')); - $this->assertSame('{"message":"A fatal error occurred: Internal Server Error"}', (string) $response->getBody()); + $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type')); + $this->assertStringStartsWith('The website encountered an unexpected error. Please try again later.

Symfony\Component\HttpKernel\Exception\HttpException: Internal Server Error in Drupal\rest\Plugin\rest\resource\EntityResource->post()', (string) $response->getBody()); //$this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nentity_type: This value should not be null.\n", $response); // DX: 422 when missing 'entity_id' field. @@ -303,10 +303,9 @@ public function testPostDxWithoutCriticalBaseFields() { // DX: 422 when missing 'entity_type' field. $request_options[RequestOptions::BODY] = $this->serializer->encode(array_diff_key($this->getNormalizedPostEntity(), ['field_name' => TRUE]), static::$format); $response = $this->request('POST', $url, $request_options); - // @todo Uncomment, remove next 3 lines in https://www.drupal.org/node/2820364. + // @todo Uncomment, remove next 2 lines in https://www.drupal.org/node/2820364. $this->assertSame(500, $response->getStatusCode()); - $this->assertSame(['application/json'], $response->getHeader('Content-Type')); - $this->assertSame('{"message":"A fatal error occurred: Field is unknown."}', (string) $response->getBody()); + $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type')); //$this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nfield_name: This value should not be null.\n", $response); } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ContentLanguageSettings/ContentLanguageSettingsJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ContentLanguageSettings/ContentLanguageSettingsJsonAnonTest.php new file mode 100644 index 0000000..344c733 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/ContentLanguageSettings/ContentLanguageSettingsJsonAnonTest.php @@ -0,0 +1,24 @@ +grantPermissionsToTestedRole(['administer languages']); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + // Create a "Camelids" node type. + $camelids = NodeType::create([ + 'name' => 'Camelids', + 'type' => 'camelids', + ]); + $camelids->save(); + + $entity = ContentLanguageSettings::create([ + 'target_entity_type_id' => 'node', + 'target_bundle' => 'camelids', + ]); + $entity->setDefaultLangcode('site_default') + ->save(); + + return $entity; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + return [ + 'default_langcode' => 'site_default', + 'dependencies' => [ + 'config' => [ + 'node.type.camelids', + ], + ], + 'id' => 'node.camelids', + 'langcode' => 'en', + 'language_alterable' => FALSE, + 'status' => TRUE, + 'target_bundle' => 'camelids', + 'target_entity_type_id' => 'node', + 'uuid' => $this->entity->uuid(), + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + // @todo Update in https://www.drupal.org/node/2300677. + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheContexts() { + return [ + 'languages:language_interface', + 'user.permissions', + ]; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Editor/EditorJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Editor/EditorJsonAnonTest.php new file mode 100644 index 0000000..b01ac2a --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Editor/EditorJsonAnonTest.php @@ -0,0 +1,24 @@ +grantPermissionsToTestedRole(['administer filters']); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + // Create a "Llama" filter format. + $llama_format = FilterFormat::create([ + 'name' => 'Llama', + 'format' => 'llama', + 'langcode' => 'es', + 'filters' => [ + 'filter_html' => [ + 'status' => TRUE, + 'settings' => [ + 'allowed_html' => '

', + ], + ], + ], + ]); + + $llama_format->save(); + + // Create a "Camelids" editor. + $camelids = Editor::create([ + 'format' => 'llama', + 'editor' => 'ckeditor', + ]); + $camelids + ->setImageUploadSettings([ + 'status' => FALSE, + 'scheme' => file_default_scheme(), + 'directory' => 'inline-images', + 'max_size' => '', + 'max_dimensions' => [ + 'width' => '', + 'height' => '', + ], + ]) + ->save(); + + return $camelids; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + return [ + 'dependencies' => [ + 'config' => [ + 'filter.format.llama', + ], + 'module' => [ + 'ckeditor', + ], + ], + 'editor' => 'ckeditor', + 'format' => 'llama', + 'image_upload' => [ + 'status' => FALSE, + 'scheme' => 'public', + 'directory' => 'inline-images', + 'max_size' => '', + 'max_dimensions' => [ + 'width' => NULL, + 'height' => NULL, + ], + ], + 'langcode' => 'en', + 'settings' => [ + 'toolbar' => [ + 'rows' => [ + [ + [ + 'name' => 'Formatting', + 'items' => [ + 'Bold', + 'Italic', + ], + ], + [ + 'name' => 'Links', + 'items' => [ + 'DrupalLink', + 'DrupalUnlink', + ], + ], + [ + 'name' => 'Lists', + 'items' => [ + 'BulletedList', + 'NumberedList', + ], + ], + [ + 'name' => 'Media', + 'items' => [ + 'Blockquote', + 'DrupalImage', + ], + ], + [ + 'name' => 'Tools', + 'items' => [ + 'Source', + ], + ], + ], + ], + ], + 'plugins' => [ + 'language' => [ + 'language_list' => 'un', + ], + ], + ], + 'status' => TRUE, + 'uuid' => $this->entity->uuid(), + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + // @todo Update in https://www.drupal.org/node/2300677. + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheContexts() { + // @see ::createEntity() + return ['user.permissions']; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedUnauthorizedAccessMessage($method) { + if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) { + return parent::getExpectedUnauthorizedAccessMessage($method); + } + + return "The 'administer filters' permission is required."; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php index e7ce862..81baf9e 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -531,17 +531,17 @@ public function testGet() { // DX: 406 when requesting unsupported format. $response = $this->request('GET', $url, $request_options); $this->assert406Response($response); - $this->assertNotSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type')); $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType; - // DX: 406 when requesting unsupported format but specifying Accept header. - // @todo Update in https://www.drupal.org/node/2825347. + // DX: 406 when requesting unsupported format but specifying Accept header: + // should result in a text/plain response. $response = $this->request('GET', $url, $request_options); $this->assert406Response($response); - $this->assertSame(['application/json'], $response->getHeader('Content-Type')); + $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type')); $url = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET.' . static::$format); @@ -634,7 +634,7 @@ public function testPost() { // missing ?_format query string. $response = $this->request('POST', $url, $request_options); $this->assertSame(415, $response->getStatusCode()); - $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type')); $this->assertContains(htmlspecialchars('No "Content-Type" request header specified'), (string) $response->getBody()); @@ -724,10 +724,7 @@ public function testPost() { // DX: 415 when request body in existing but not allowed format. $response = $this->request('POST', $url, $request_options); - // @todo Update this in https://www.drupal.org/node/2826407. Also move it - // higher, before the "no request body" test. That's impossible right now, - // because the format validation happens too late. - $this->assertResourceErrorResponse(415, '', $response); + $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response); $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType; @@ -807,7 +804,7 @@ public function testPatch() { if ($has_canonical_url) { $this->assertSame(405, $response->getStatusCode()); $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow')); - $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type')); } else { $this->assertSame(404, $response->getStatusCode()); @@ -836,7 +833,7 @@ public function testPatch() { // DX: 415 when no Content-Type request header. $response = $this->request('PATCH', $url, $request_options); $this->assertSame(415, $response->getStatusCode()); - $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type')); $this->assertTrue(FALSE !== strpos((string) $response->getBody(), htmlspecialchars('No "Content-Type" request header specified'))); @@ -936,10 +933,7 @@ public function testPatch() { // DX: 415 when request body in existing but not allowed format. $response = $this->request('PATCH', $url, $request_options); - // @todo Update this in https://www.drupal.org/node/2826407. Also move it - // higher, before the "no request body" test. That's impossible right now, - // because the format validation happens too late. - $this->assertResourceErrorResponse(415, '', $response); + $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response); $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType; @@ -996,13 +990,13 @@ public function testDelete() { $request_options = []; - // DX: 405 when resource not provisioned, but HTML if canonical route. Plain + // DX: 404 when resource not provisioned, but 405 if canonical route. Plain // text or HTML response because missing ?_format query string. $response = $this->request('DELETE', $url, $request_options); if ($has_canonical_url) { $this->assertSame(405, $response->getStatusCode()); $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow')); - $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type')); } else { $this->assertSame(404, $response->getStatusCode()); @@ -1103,10 +1097,9 @@ protected function assertNormalizationEdgeCases($method, Url $url, array $reques $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); - // DX: 400 when incorrect entity type bundle is specified. - // @todo Change to 422 in https://www.drupal.org/node/2827084. + // DX: 422 when incorrect entity type bundle is specified. $response = $this->request($method, $url, $request_options); - $this->assertResourceErrorResponse(400, '"bad_bundle_name" is not a valid bundle type for denormalization.', $response); + $this->assertResourceErrorResponse(422, '"bad_bundle_name" is not a valid bundle type for denormalization.', $response); } @@ -1114,10 +1107,9 @@ protected function assertNormalizationEdgeCases($method, Url $url, array $reques $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); - // DX: 400 when no entity type bundle is specified. - // @todo Change to 422 in https://www.drupal.org/node/2827084. + // DX: 422 when no entity type bundle is specified. $response = $this->request($method, $url, $request_options); - $this->assertResourceErrorResponse(400, sprintf('Could not determine entity type bundle: "%s" field is missing.', $bundle_field_name), $response); + $this->assertResourceErrorResponse(422, sprintf('Could not determine entity type bundle: "%s" field is missing.', $bundle_field_name), $response); } } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/FieldConfig/FieldConfigJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/FieldConfig/FieldConfigJsonAnonTest.php new file mode 100644 index 0000000..0e6883a --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/FieldConfig/FieldConfigJsonAnonTest.php @@ -0,0 +1,24 @@ +grantPermissionsToTestedRole(['administer node fields']); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $camelids = NodeType::create([ + 'name' => 'Camelids', + 'type' => 'camelids', + ]); + $camelids->save(); + + $field_storage = FieldStorageConfig::create([ + 'field_name' => 'field_llama', + 'entity_type' => 'node', + 'type' => 'text', + ]); + $field_storage->save(); + + $entity = FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => 'camelids', + ]); + $entity->save(); + + return $entity; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + return [ + 'bundle' => 'camelids', + 'default_value' => [], + 'default_value_callback' => '', + 'dependencies' => [ + 'config' => [ + 'field.storage.node.field_llama', + 'node.type.camelids', + ], + 'module' => [ + 'text', + ], + ], + 'description' => '', + 'entity_type' => 'node', + 'field_name' => 'field_llama', + 'field_type' => 'text', + 'id' => 'node.camelids.field_llama', + 'label' => 'field_llama', + 'langcode' => 'en', + 'required' => FALSE, + 'settings' => [], + 'status' => TRUE, + 'translatable' => TRUE, + 'uuid' => $this->entity->uuid(), + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + // @todo Update in https://www.drupal.org/node/2300677. + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheContexts() { + return [ + 'user.permissions', + ]; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedUnauthorizedAccessMessage($method) { + if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) { + return parent::getExpectedUnauthorizedAccessMessage($method); + } + + return "The 'administer node fields' permission is required."; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php index cfbd971..92fad1e 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php @@ -23,7 +23,6 @@ * {@inheritdoc} */ protected static $patchProtectedFieldNames = [ - 'uid', 'created', 'changed', 'promote', diff --git a/core/modules/rest/tests/src/Functional/EntityResource/RestResourceConfig/RestResourceConfigJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/RestResourceConfig/RestResourceConfigJsonAnonTest.php new file mode 100644 index 0000000..df07140 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/RestResourceConfig/RestResourceConfigJsonAnonTest.php @@ -0,0 +1,24 @@ +grantPermissionsToTestedRole(['administer rest resources']); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $rest_resource_config = RestResourceConfig::create([ + 'id' => 'llama', + 'plugin_id' => 'dblog', + 'granularity' => 'method', + 'configuration' => [ + 'GET' => [ + 'supported_formats' => [ + 'json', + ], + 'supported_auth' => [ + 'cookie', + ], + ], + ], + ]); + $rest_resource_config->save(); + + return $rest_resource_config; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + return [ + 'uuid' => $this->entity->uuid(), + 'langcode' => 'en', + 'status' => TRUE, + 'dependencies' => [ + 'module' => [ + 'dblog', + 'serialization', + 'user', + ], + ], + 'id' => 'llama', + 'plugin_id' => 'dblog', + 'granularity' => 'method', + 'configuration' => [ + 'GET' => [ + 'supported_formats' => [ + 'json', + ], + 'supported_auth' => [ + 'cookie', + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + // @todo Update in https://www.drupal.org/node/2300677. + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheContexts() { + return [ + 'user.permissions', + ]; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ShortcutSet/ShortcutSetJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ShortcutSet/ShortcutSetJsonAnonTest.php new file mode 100644 index 0000000..3720ac6 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/ShortcutSet/ShortcutSetJsonAnonTest.php @@ -0,0 +1,24 @@ +grantPermissionsToTestedRole(['access shortcuts']); + break; + + case 'POST': + case 'PATCH': + $this->grantPermissionsToTestedRole(['access shortcuts', 'customize shortcut links']); + break; + + case 'DELETE': + $this->grantPermissionsToTestedRole(['administer shortcuts']); + break; + } + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $set = ShortcutSet::create([ + 'id' => 'llama_set', + 'label' => 'Llama Set', + ]); + $set->save(); + return $set; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + return [ + 'id' => 'llama_set', + 'uuid' => $this->entity->uuid(), + 'label' => 'Llama Set', + 'status' => TRUE, + 'langcode' => 'en', + 'dependencies' => [], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + // @todo Update in https://www.drupal.org/node/2300677. + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php index 04fe435..199ac09 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php @@ -213,7 +213,7 @@ public function testPatchDxForSecuritySensitiveBaseFields() { RequestOptions::HEADERS => [], RequestOptions::BODY => $this->serializer->encode($request_body, 'json'), ]; - $response = $this->httpClient->request('POST', Url::fromRoute('user.login.http')->setRouteParameter('_format', 'json')->toString(), $request_options); + $response = $this->request('POST', Url::fromRoute('user.login.http')->setRouteParameter('_format', 'json'), $request_options); $this->assertSame(200, $response->getStatusCode()); } diff --git a/core/modules/rest/tests/src/Functional/ResourceTestBase.php b/core/modules/rest/tests/src/Functional/ResourceTestBase.php index c346157..c9e4c44 100644 --- a/core/modules/rest/tests/src/Functional/ResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php @@ -94,11 +94,6 @@ public static $modules = ['rest']; /** - * @var \GuzzleHttp\ClientInterface - */ - protected $httpClient; - - /** * {@inheritdoc} */ public function setUp() { @@ -135,10 +130,6 @@ public function setUp() { // Ensure there's a clean slate: delete all REST resource config entities. $this->resourceConfigStorage->delete($this->resourceConfigStorage->loadMultiple()); $this->refreshTestStateAfterRestConfigChange(); - - // Set up a HTTP client that accepts relative URLs. - $this->httpClient = $this->container->get('http_client_factory') - ->fromOptions(['base_uri' => $this->baseUrl]); } /** @@ -344,7 +335,8 @@ protected function grantPermissionsToTestedRole(array $permissions) { protected function request($method, Url $url, array $request_options) { $request_options[RequestOptions::HTTP_ERRORS] = FALSE; $request_options = $this->decorateWithXdebugCookie($request_options); - return $this->httpClient->request($method, $url->toString(), $request_options); + $client = $this->getSession()->getDriver()->getClient()->getClient(); + return $client->request($method, $url->setAbsolute(TRUE)->toString(), $request_options); } /** diff --git a/core/modules/rest/tests/src/Kernel/RequestHandlerTest.php b/core/modules/rest/tests/src/Kernel/RequestHandlerTest.php index b417e2a..2b88559 100644 --- a/core/modules/rest/tests/src/Kernel/RequestHandlerTest.php +++ b/core/modules/rest/tests/src/Kernel/RequestHandlerTest.php @@ -49,7 +49,7 @@ public function setUp() { */ public function testHandle() { $request = new Request(); - $route_match = new RouteMatch('test', new Route('/rest/test', ['_rest_resource_config' => 'restplugin'], ['_format' => 'json'])); + $route_match = new RouteMatch('test', (new Route('/rest/test', ['_rest_resource_config' => 'restplugin'], ['_format' => 'json']))->setMethods(['GET'])); $resource = $this->prophesize(StubRequestHandlerResourcePlugin::class); $resource->get(NULL, $request) @@ -76,7 +76,7 @@ public function testHandle() { $this->assertEquals($response, $handler_response); // We will call the patch method this time. - $route_match = new RouteMatch('test', new Route('/rest/test', ['_rest_resource_config' => 'restplugin'], ['_content_type_format' => 'json'])); + $route_match = new RouteMatch('test', (new Route('/rest/test', ['_rest_resource_config' => 'restplugin'], ['_content_type_format' => 'json']))->setMethods(['PATCH'])); $request->setMethod('PATCH'); $response = new ResourceResponse([]); $resource->patch(NULL, $request) diff --git a/core/modules/search/search.module b/core/modules/search/search.module index bf00878..ae99a87 100644 --- a/core/modules/search/search.module +++ b/core/modules/search/search.module @@ -8,6 +8,7 @@ use Drupal\Component\Utility\Html; use Drupal\Component\Utility\Unicode; use Drupal\Core\Cache\Cache; +use Drupal\Core\Database\Query\Condition; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Routing\RouteMatchInterface; @@ -218,7 +219,7 @@ function search_update_totals() { // search_total. We use a LEFT JOIN between the two tables and keep only the // rows which fail to join. $result = db_query("SELECT t.word AS realword, i.word FROM {search_total} t LEFT JOIN {search_index} i ON t.word = i.word WHERE i.word IS NULL", [], ['target' => 'replica']); - $or = db_or(); + $or = new Condition('OR'); foreach ($result as $word) { $or->condition('word', $word->realword); } diff --git a/core/modules/search/src/Controller/SearchController.php b/core/modules/search/src/Controller/SearchController.php index ac078e1..4beb844 100644 --- a/core/modules/search/src/Controller/SearchController.php +++ b/core/modules/search/src/Controller/SearchController.php @@ -5,6 +5,7 @@ use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Render\RendererInterface; +use Drupal\search\Form\SearchPageForm; use Drupal\search\SearchPageInterface; use Drupal\search\SearchPageRepositoryInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -84,7 +85,7 @@ public function view(Request $request, SearchPageInterface $entity) { } $build['#title'] = $plugin->suggestedTitle(); - $build['search_form'] = $this->entityFormBuilder()->getForm($entity, 'search'); + $build['search_form'] = $this->formBuilder()->getForm(SearchPageForm::class, $entity); // Build search results, if keywords or other search parameters are in the // GET parameters. Note that we need to try the search if 'keys' is in diff --git a/core/modules/search/src/Entity/SearchPage.php b/core/modules/search/src/Entity/SearchPage.php index 298331f..85ddc26 100644 --- a/core/modules/search/src/Entity/SearchPage.php +++ b/core/modules/search/src/Entity/SearchPage.php @@ -22,7 +22,6 @@ * "form" = { * "add" = "Drupal\search\Form\SearchPageAddForm", * "edit" = "Drupal\search\Form\SearchPageEditForm", - * "search" = "Drupal\search\Form\SearchPageForm", * "delete" = "Drupal\Core\Entity\EntityDeleteForm" * } * }, diff --git a/core/modules/search/src/Form/SearchPageForm.php b/core/modules/search/src/Form/SearchPageForm.php index 88b99e5..e46195b 100644 --- a/core/modules/search/src/Form/SearchPageForm.php +++ b/core/modules/search/src/Form/SearchPageForm.php @@ -2,9 +2,10 @@ namespace Drupal\search\Form; -use Drupal\Core\Entity\EntityForm; +use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Url; +use Drupal\search\SearchPageInterface; /** * Provides a search form for site wide search. @@ -15,10 +16,10 @@ * trigger the search being processed by the controller, and adding in any * additional query parameters they need to execute search. */ -class SearchPageForm extends EntityForm { +class SearchPageForm extends FormBase { /** - * {@inheritdoc} + * The search page entity. * * @var \Drupal\search\SearchPageInterface */ @@ -34,7 +35,9 @@ public function getFormId() { /** * {@inheritdoc} */ - public function form(array $form, FormStateInterface $form_state) { + public function buildForm(array $form, FormStateInterface $form_state, SearchPageInterface $search_page = NULL) { + $this->entity = $search_page; + $plugin = $this->entity->getPlugin(); $form_state->set('search_page_id', $this->entity->id()); @@ -72,16 +75,7 @@ public function form(array $form, FormStateInterface $form_state) { // Allow the plugin to add to or alter the search form. $plugin->searchFormAlter($form, $form_state); - - return parent::form($form, $form_state); - } - - /** - * {@inheritdoc} - */ - protected function actions(array $form, FormStateInterface $form_state) { - // The submit button is added in the form directly. - return []; + return $form; } /** diff --git a/core/modules/search/src/Plugin/ConfigurableSearchPluginBase.php b/core/modules/search/src/Plugin/ConfigurableSearchPluginBase.php index 50a44db..7ad95f1 100644 --- a/core/modules/search/src/Plugin/ConfigurableSearchPluginBase.php +++ b/core/modules/search/src/Plugin/ConfigurableSearchPluginBase.php @@ -23,7 +23,7 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition) { parent::__construct($configuration, $plugin_id, $plugin_definition); - $this->configuration = NestedArray::mergeDeep($this->defaultConfiguration(), $this->configuration); + $this->setConfiguration($configuration); } /** @@ -44,7 +44,7 @@ public function getConfiguration() { * {@inheritdoc} */ public function setConfiguration(array $configuration) { - $this->configuration = $configuration; + $this->configuration = NestedArray::mergeDeep($this->defaultConfiguration(), $configuration); } /** diff --git a/core/modules/search/src/Plugin/SearchInterface.php b/core/modules/search/src/Plugin/SearchInterface.php index 2f2403c..8f95ce8 100644 --- a/core/modules/search/src/Plugin/SearchInterface.php +++ b/core/modules/search/src/Plugin/SearchInterface.php @@ -110,7 +110,7 @@ public function getHelp(); * * The core search module only invokes this method on active module plugins * when building a form for them in - * \Drupal\search\Form\SearchPageForm::form(). A plugin implementing this + * \Drupal\search\Form\SearchPageForm::buildForm(). A plugin implementing this * will also need to implement the buildSearchUrlQuery() method. * * @param array $form diff --git a/core/modules/search/src/Plugin/views/argument/Search.php b/core/modules/search/src/Plugin/views/argument/Search.php index 82afbbf..7e9f00d 100644 --- a/core/modules/search/src/Plugin/views/argument/Search.php +++ b/core/modules/search/src/Plugin/views/argument/Search.php @@ -2,6 +2,7 @@ namespace Drupal\search\Plugin\views\argument; +use Drupal\Core\Database\Query\Condition; use Drupal\views\Plugin\views\argument\ArgumentPluginBase; use Drupal\views\Plugin\views\display\DisplayPluginBase; use Drupal\views\ViewExecutable; @@ -76,7 +77,7 @@ public function query($group_by = FALSE) { else { $search_index = $this->ensureMyTable(); - $search_condition = db_and(); + $search_condition = new Condition('AND'); // Create a new join to relate the 'search_total' table to our current 'search_index' table. $definition = [ @@ -109,7 +110,7 @@ public function query($group_by = FALSE) { // Add the keyword conditions, as is done in // SearchQuery::prepareAndNormalize(), but simplified because we are // only concerned with relevance ranking so we do not need to normalize. - $or = db_or(); + $or = new Condition('OR'); foreach ($words as $word) { $or->condition("$search_index.word", $word); } diff --git a/core/modules/search/src/Plugin/views/filter/Search.php b/core/modules/search/src/Plugin/views/filter/Search.php index c95fd96..ac0f671 100644 --- a/core/modules/search/src/Plugin/views/filter/Search.php +++ b/core/modules/search/src/Plugin/views/filter/Search.php @@ -2,6 +2,7 @@ namespace Drupal\search\Plugin\views\filter; +use Drupal\Core\Database\Query\Condition; use Drupal\Core\Form\FormStateInterface; use Drupal\views\Plugin\views\filter\FilterPluginBase; use Drupal\views\Plugin\views\display\DisplayPluginBase; @@ -150,7 +151,7 @@ public function query() { else { $search_index = $this->ensureMyTable(); - $search_condition = db_and(); + $search_condition = new Condition('AND'); // Create a new join to relate the 'search_total' table to our current // 'search_index' table. @@ -184,7 +185,7 @@ public function query() { // Add the keyword conditions, as is done in // SearchQuery::prepareAndNormalize(), but simplified because we are // only concerned with relevance ranking so we do not need to normalize. - $or = db_or(); + $or = new Condition('OR'); foreach ($words as $word) { $or->condition("$search_index.word", $word); } diff --git a/core/modules/search/src/SearchQuery.php b/core/modules/search/src/SearchQuery.php index 7c4e07e..dbd0534 100644 --- a/core/modules/search/src/SearchQuery.php +++ b/core/modules/search/src/SearchQuery.php @@ -2,6 +2,7 @@ namespace Drupal\search; +use Drupal\Core\Database\Query\Condition; use Drupal\Component\Utility\Unicode; use Drupal\Core\Database\Query\SelectExtender; use Drupal\Core\Database\Query\SelectInterface; @@ -205,7 +206,7 @@ public function searchExpression($expression, $type) { $this->addTag('search_' . $type); // Initialize conditions and status. - $this->conditions = db_and(); + $this->conditions = new Condition('AND'); $this->status = 0; return $this; @@ -313,7 +314,7 @@ protected function parseSearchExpression() { } $has_or = TRUE; $has_new_scores = FALSE; - $queryor = db_or(); + $queryor = new Condition('OR'); foreach ($key as $or) { list($num_new_scores) = $this->parseWord($or); $has_new_scores |= $num_new_scores; @@ -401,7 +402,7 @@ public function prepareAndNormalize() { } // Build the basic search query: match the entered keywords. - $or = db_or(); + $or = new Condition('OR'); foreach ($this->words as $word) { $or->condition('i.word', $word); } diff --git a/core/modules/search/src/ViewsSearchQuery.php b/core/modules/search/src/ViewsSearchQuery.php index ca4b16c..ca804a8 100644 --- a/core/modules/search/src/ViewsSearchQuery.php +++ b/core/modules/search/src/ViewsSearchQuery.php @@ -74,8 +74,8 @@ public function conditionReplaceString($search, $replace, &$condition) { $conditions =& $condition['field']->conditions(); foreach ($conditions as $key => &$subcondition) { if (is_numeric($key)) { - // As conditions can have subconditions, for example db_or(), the - // function has to be called recursively. + // As conditions can be nested, the function has to be called + // recursively. $this->conditionReplaceString($search, $replace, $subcondition); } } diff --git a/core/modules/serialization/serialization.services.yml b/core/modules/serialization/serialization.services.yml index 3e47b00..3607182 100644 --- a/core/modules/serialization/serialization.services.yml +++ b/core/modules/serialization/serialization.services.yml @@ -33,6 +33,7 @@ services: # this modules generic field item normalizer. # @todo Find a better way for this in https://www.drupal.org/node/2575761. - { name: normalizer, priority: 8 } + arguments: ['@entity.repository'] serialization.normalizer.field_item: class: Drupal\serialization\Normalizer\FieldItemNormalizer tags: diff --git a/core/modules/serialization/src/Normalizer/EntityReferenceFieldItemNormalizer.php b/core/modules/serialization/src/Normalizer/EntityReferenceFieldItemNormalizer.php index 4706bf4..ea2e020 100644 --- a/core/modules/serialization/src/Normalizer/EntityReferenceFieldItemNormalizer.php +++ b/core/modules/serialization/src/Normalizer/EntityReferenceFieldItemNormalizer.php @@ -2,12 +2,15 @@ namespace Drupal\serialization\Normalizer; +use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; /** * Adds the file URI to embedded file entities. */ -class EntityReferenceFieldItemNormalizer extends ComplexDataNormalizer { +class EntityReferenceFieldItemNormalizer extends FieldItemNormalizer { /** * The interface or class that this Normalizer supports. @@ -17,6 +20,23 @@ class EntityReferenceFieldItemNormalizer extends ComplexDataNormalizer { protected $supportedInterfaceOrClass = EntityReferenceItem::class; /** + * The entity repository. + * + * @var \Drupal\Core\Entity\EntityRepositoryInterface + */ + protected $entityRepository; + + /** + * Constructs a EntityReferenceFieldItemNormalizer object. + * + * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository + * The entity repository. + */ + public function __construct(EntityRepositoryInterface $entity_repository) { + $this->entityRepository = $entity_repository; + } + + /** * {@inheritdoc} */ public function normalize($field_item, $format = NULL, array $context = []) { @@ -35,8 +55,32 @@ public function normalize($field_item, $format = NULL, array $context = []) { $values['url'] = $url; } } - return $values; } + /** + * {@inheritdoc} + */ + protected function constructValue($data, $context) { + if (isset($data['target_uuid'])) { + /** @var \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem $field_item */ + $field_item = $context['target_instance']; + if (empty($data['target_uuid'])) { + throw new InvalidArgumentException(sprintf('If provided "target_uuid" cannot be empty for field "%s".', $data['target_type'], $data['target_uuid'], $field_item->getName())); + } + $target_type = $field_item->getFieldDefinition()->getSetting('target_type'); + if (!empty($data['target_type']) && $target_type !== $data['target_type']) { + throw new UnexpectedValueException(sprintf('The field "%s" property "target_type" must be set to "%s" or omitted.', $field_item->getFieldDefinition()->getName(), $target_type)); + } + if ($entity = $this->entityRepository->loadEntityByUuid($target_type, $data['target_uuid'])) { + return ['target_id' => $entity->id()]; + } + else { + // Unable to load entity by uuid. + throw new InvalidArgumentException(sprintf('No "%s" entity found with UUID "%s" for field "%s".', $data['target_type'], $data['target_uuid'], $field_item->getName())); + } + } + return parent::constructValue($data, $context); + } + } diff --git a/core/modules/serialization/tests/src/Unit/CompilerPass/RegisterSerializationClassesCompilerPassTest.php b/core/modules/serialization/tests/src/Unit/CompilerPass/RegisterSerializationClassesCompilerPassTest.php index 630535d..f9a5b77 100644 --- a/core/modules/serialization/tests/src/Unit/CompilerPass/RegisterSerializationClassesCompilerPassTest.php +++ b/core/modules/serialization/tests/src/Unit/CompilerPass/RegisterSerializationClassesCompilerPassTest.php @@ -4,6 +4,7 @@ use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\serialization\RegisterSerializationClassesCompilerPass; +use Drupal\Tests\UnitTestCase; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\Serializer\Serializer; @@ -11,7 +12,7 @@ * @coversDefaultClass \Drupal\serialization\RegisterSerializationClassesCompilerPass * @group serialization */ -class RegisterSerializationClassesCompilerPassTest extends \PHPUnit_Framework_TestCase { +class RegisterSerializationClassesCompilerPassTest extends UnitTestCase { /** * @covers ::process diff --git a/core/modules/serialization/tests/src/Unit/Normalizer/EntityReferenceFieldItemNormalizerTest.php b/core/modules/serialization/tests/src/Unit/Normalizer/EntityReferenceFieldItemNormalizerTest.php index d010a04..e0561a1 100644 --- a/core/modules/serialization/tests/src/Unit/Normalizer/EntityReferenceFieldItemNormalizerTest.php +++ b/core/modules/serialization/tests/src/Unit/Normalizer/EntityReferenceFieldItemNormalizerTest.php @@ -3,11 +3,18 @@ namespace Drupal\Tests\serialization\Unit\Normalizer; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\TypedData\TypedDataInterface; +use Drupal\Core\Entity\EntityRepositoryInterface; +use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Field\FieldItemInterface; +use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; use Drupal\serialization\Normalizer\EntityReferenceFieldItemNormalizer; use Drupal\Tests\UnitTestCase; use Prophecy\Argument; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Serializer; /** @@ -38,10 +45,25 @@ class EntityReferenceFieldItemNormalizerTest extends UnitTestCase { protected $fieldItem; /** + * The mock entity repository. + * + * @var \Drupal\Core\Entity\EntityRepositoryInterface|\Prophecy\Prophecy\ObjectProphecy + */ + protected $entityRepository; + + /** + * The mock field definition. + * + * @var \Drupal\Core\Field\FieldDefinitionInterface|\Prophecy\Prophecy\ObjectProphecy + */ + protected $fieldDefinition; + + /** * {@inheritdoc} */ protected function setUp() { - $this->normalizer = new EntityReferenceFieldItemNormalizer(); + $this->entityRepository = $this->prophesize(EntityRepositoryInterface::class); + $this->normalizer = new EntityReferenceFieldItemNormalizer($this->entityRepository->reveal()); $this->serializer = $this->prophesize(Serializer::class); // Set up the serializer to return an entity property. @@ -53,6 +75,9 @@ protected function setUp() { $this->fieldItem = $this->prophesize(EntityReferenceItem::class); $this->fieldItem->getIterator() ->willReturn(new \ArrayIterator(['target_id' => []])); + + $this->fieldDefinition = $this->prophesize(FieldDefinitionInterface::class); + } /** @@ -64,6 +89,14 @@ public function testSupportsNormalization() { } /** + * @covers ::supportsDenormalization + */ + public function testSupportsDenormalization() { + $this->assertTrue($this->normalizer->supportsDenormalization([], EntityReferenceItem::class)); + $this->assertFalse($this->normalizer->supportsDenormalization([], FieldItemInterface::class)); + } + + /** * @covers ::normalize */ public function testNormalize() { @@ -121,4 +154,149 @@ public function testNormalizeWithNoEntity() { $this->assertSame($expected, $normalized); } + /** + * @covers ::denormalize + */ + public function testDenormalizeWithTypeAndUuid() { + $data = [ + 'target_id' => ['value' => 'test'], + 'target_type' => 'test_type', + 'target_uuid' => '080e3add-f9d5-41ac-9821-eea55b7b42fb', + ]; + + $entity = $this->prophesize(FieldableEntityInterface::class); + $entity->id() + ->willReturn('test') + ->shouldBeCalled(); + $this->entityRepository + ->loadEntityByUuid($data['target_type'], $data['target_uuid']) + ->willReturn($entity) + ->shouldBeCalled(); + + $this->fieldItem->setValue(['target_id' => 'test'])->shouldBeCalled(); + + $this->assertDenormalize($data); + } + + /** + * @covers ::denormalize + */ + public function testDenormalizeWithUuidWithoutType() { + $data = [ + 'target_id' => ['value' => 'test'], + 'target_uuid' => '080e3add-f9d5-41ac-9821-eea55b7b42fb', + ]; + + $entity = $this->prophesize(FieldableEntityInterface::class); + $entity->id() + ->willReturn('test') + ->shouldBeCalled(); + $this->entityRepository + ->loadEntityByUuid('test_type', $data['target_uuid']) + ->willReturn($entity) + ->shouldBeCalled(); + + $this->fieldItem->setValue(['target_id' => 'test'])->shouldBeCalled(); + + $this->assertDenormalize($data); + } + + /** + * @covers ::denormalize + */ + public function testDenormalizeWithUuidWithIncorrectType() { + $this->setExpectedException(UnexpectedValueException::class, 'The field "field_reference" property "target_type" must be set to "test_type" or omitted.'); + + $data = [ + 'target_id' => ['value' => 'test'], + 'target_type' => 'wrong_type', + 'target_uuid' => '080e3add-f9d5-41ac-9821-eea55b7b42fb', + ]; + + $this->fieldDefinition + ->getName() + ->willReturn('field_reference') + ->shouldBeCalled(); + + $this->assertDenormalize($data); + } + + /** + * @covers ::denormalize + */ + public function testDenormalizeWithTypeWithIncorrectUuid() { + $this->setExpectedException(InvalidArgumentException::class, 'No "test_type" entity found with UUID "unique-but-none-non-existent" for field "field_reference"'); + + $data = [ + 'target_id' => ['value' => 'test'], + 'target_type' => 'test_type', + 'target_uuid' => 'unique-but-none-non-existent', + ]; + $this->entityRepository + ->loadEntityByUuid($data['target_type'], $data['target_uuid']) + ->willReturn(NULL) + ->shouldBeCalled(); + $this->fieldItem + ->getName() + ->willReturn('field_reference') + ->shouldBeCalled(); + + + $this->assertDenormalize($data); + } + + /** + * @covers ::denormalize + */ + public function testDenormalizeWithEmtpyUuid() { + $this->setExpectedException(InvalidArgumentException::class, 'If provided "target_uuid" cannot be empty for field "test_type".'); + + $data = [ + 'target_id' => ['value' => 'test'], + 'target_type' => 'test_type', + 'target_uuid' => '', + ]; + $this->fieldItem + ->getName() + ->willReturn('field_reference') + ->shouldBeCalled(); + + + $this->assertDenormalize($data); + } + + /** + * @covers ::denormalize + */ + public function testDenormalizeWithId() { + $data = [ + 'target_id' => ['value' => 'test'], + ]; + $this->fieldItem->setValue($data)->shouldBeCalled(); + + $this->assertDenormalize($data); + } + + /** + * Asserts denormalization process is correct for give data. + * + * @param array $data + * The data to denormalize. + */ + protected function assertDenormalize(array $data) { + $this->fieldItem->getParent() + ->willReturn($this->prophesize(FieldItemListInterface::class)->reveal()); + $this->fieldItem->getFieldDefinition()->willReturn($this->fieldDefinition->reveal()); + if (!empty($data['target_uuid'])) { + $this->fieldDefinition + ->getSetting('target_type') + ->willReturn('test_type') + ->shouldBeCalled(); + } + + $context = ['target_instance' => $this->fieldItem->reveal()]; + $denormalized = $this->normalizer->denormalize($data, EntityReferenceItem::class, 'json', $context); + $this->assertSame($context['target_instance'], $denormalized); + } + } diff --git a/core/modules/shortcut/src/ShortcutSetAccessControlHandler.php b/core/modules/shortcut/src/ShortcutSetAccessControlHandler.php index b5ce0e8..3a55f74 100644 --- a/core/modules/shortcut/src/ShortcutSetAccessControlHandler.php +++ b/core/modules/shortcut/src/ShortcutSetAccessControlHandler.php @@ -19,6 +19,8 @@ class ShortcutSetAccessControlHandler extends EntityAccessControlHandler { */ protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) { switch ($operation) { + case 'view': + return AccessResult::allowedIf($account->hasPermission('access shortcuts'))->cachePerPermissions(); case 'update': if ($account->hasPermission('administer shortcuts')) { return AccessResult::allowed()->cachePerPermissions(); diff --git a/core/modules/simpletest/simpletest.install b/core/modules/simpletest/simpletest.install index b6cfc80..5302288 100644 --- a/core/modules/simpletest/simpletest.install +++ b/core/modules/simpletest/simpletest.install @@ -6,6 +6,7 @@ */ use Drupal\Component\Utility\Environment; +use PHPUnit\Framework\TestCase; /** * Minimum value of PHP memory_limit for SimpleTest. @@ -18,7 +19,7 @@ function simpletest_requirements($phase) { $requirements = []; - $has_phpunit = class_exists('\PHPUnit_Framework_TestCase'); + $has_phpunit = class_exists(TestCase::class); $has_curl = function_exists('curl_init'); $open_basedir = ini_get('open_basedir'); diff --git a/core/modules/simpletest/simpletest.module b/core/modules/simpletest/simpletest.module index 7d72aa4..8b1cc23 100644 --- a/core/modules/simpletest/simpletest.module +++ b/core/modules/simpletest/simpletest.module @@ -13,6 +13,7 @@ use Drupal\Core\Test\TestDatabase; use Drupal\simpletest\TestDiscovery; use Drupal\Tests\Listeners\SimpletestUiPrinter; +use PHPUnit\Framework\TestCase; use Symfony\Component\Process\PhpExecutableFinder; use Drupal\Core\Test\TestStatus; @@ -412,7 +413,7 @@ function _simpletest_batch_operation($test_list_init, $test_id, &$context) { // Perform the next test. $test_class = array_shift($test_list); - if (is_subclass_of($test_class, \PHPUnit_Framework_TestCase::class)) { + if (is_subclass_of($test_class, TestCase::class)) { $phpunit_results = simpletest_run_phpunit_tests($test_id, [$test_class]); simpletest_process_phpunit_results($phpunit_results); $test_results[$test_class] = simpletest_summarize_phpunit_result($phpunit_results)[$test_class]; diff --git a/core/modules/simpletest/src/ContentTypeCreationTrait.php b/core/modules/simpletest/src/ContentTypeCreationTrait.php index 9ad0a21..ec15b9a 100644 --- a/core/modules/simpletest/src/ContentTypeCreationTrait.php +++ b/core/modules/simpletest/src/ContentTypeCreationTrait.php @@ -4,6 +4,7 @@ use Drupal\Component\Render\FormattableMarkup; use Drupal\node\Entity\NodeType; +use PHPUnit\Framework\TestCase; /** * Provides methods to create content type from given values. @@ -40,7 +41,7 @@ protected function createContentType(array $values = []) { $status = $type->save(); node_add_body_field($type); - if ($this instanceof \PHPUnit_Framework_TestCase) { + if ($this instanceof TestCase) { $this->assertSame($status, SAVED_NEW, (new FormattableMarkup('Created content type %type.', ['%type' => $type->id()]))->__toString()); } else { diff --git a/core/modules/simpletest/src/Form/SimpletestTestForm.php b/core/modules/simpletest/src/Form/SimpletestTestForm.php index f954546..1bf43ce 100644 --- a/core/modules/simpletest/src/Form/SimpletestTestForm.php +++ b/core/modules/simpletest/src/Form/SimpletestTestForm.php @@ -71,7 +71,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { $form['clean'] = [ '#type' => 'fieldset', '#title' => $this->t('Clean test environment'), - '#description' => $this->t('Remove tables with the prefix "simpletest" and temporary directories that are left over from tests that crashed. This is intended for developers when creating tests.'), + '#description' => $this->t('Remove tables with the prefix "test" followed by digits and temporary directories that are left over from tests that crashed. This is intended for developers when creating tests.'), '#weight' => 200, ]; $form['clean']['op'] = [ diff --git a/core/modules/simpletest/tests/fixtures/simpletest_phpunit_run_command_test.php b/core/modules/simpletest/tests/fixtures/simpletest_phpunit_run_command_test.php index a4ffc7a..42dadfd 100644 --- a/core/modules/simpletest/tests/fixtures/simpletest_phpunit_run_command_test.php +++ b/core/modules/simpletest/tests/fixtures/simpletest_phpunit_run_command_test.php @@ -2,6 +2,8 @@ namespace Drupal\Tests\simpletest\Unit; +use Drupal\Tests\UnitTestCase; + /** * This test crashes PHP. * @@ -11,7 +13,7 @@ * * @see \Drupal\Tests\simpletest\Unit\SimpletestPhpunitRunCommandTest::testSimpletestPhpUnitRunCommand() */ -class SimpletestPhpunitRunCommandTestWillDie extends \PHPUnit_Framework_TestCase { +class SimpletestPhpunitRunCommandTestWillDie extends UnitTestCase { /** * Performs the status specified by SimpletestPhpunitRunCommandTestWillDie. diff --git a/core/modules/simpletest/tests/src/Unit/SimpletestPhpunitRunCommandTest.php b/core/modules/simpletest/tests/src/Unit/SimpletestPhpunitRunCommandTest.php index a96db96..63fe1bb 100644 --- a/core/modules/simpletest/tests/src/Unit/SimpletestPhpunitRunCommandTest.php +++ b/core/modules/simpletest/tests/src/Unit/SimpletestPhpunitRunCommandTest.php @@ -4,15 +4,19 @@ use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\File\FileSystemInterface; +use PHPUnit\Framework\TestCase; /** * Tests simpletest_run_phpunit_tests() handles PHPunit fatals correctly. * + * We don't extend Drupal\Tests\UnitTestCase here because its $root property is + * not static and we need it to be static here. + * * @group simpletest * * @runTestsInSeparateProcesses */ -class SimpletestPhpunitRunCommandTest extends \PHPUnit_Framework_TestCase { +class SimpletestPhpunitRunCommandTest extends TestCase { /** * Path to the app root. diff --git a/core/modules/statistics/src/StatisticsSettingsForm.php b/core/modules/statistics/src/StatisticsSettingsForm.php index 71a1764..4c06e0b 100644 --- a/core/modules/statistics/src/StatisticsSettingsForm.php +++ b/core/modules/statistics/src/StatisticsSettingsForm.php @@ -21,7 +21,7 @@ class StatisticsSettingsForm extends ConfigFormBase { protected $moduleHandler; /** - * Constructs a \Drupal\user\StatisticsSettingsForm object. + * Constructs a \Drupal\statistics\StatisticsSettingsForm object. * * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * The factory for configuration objects. diff --git a/core/modules/statistics/src/StatisticsStorageInterface.php b/core/modules/statistics/src/StatisticsStorageInterface.php index ccb51e4..ad6d6d5 100644 --- a/core/modules/statistics/src/StatisticsStorageInterface.php +++ b/core/modules/statistics/src/StatisticsStorageInterface.php @@ -27,7 +27,10 @@ public function recordView($id); * @param array $ids * An array of IDs of entities to fetch the views for. * - * @return array \Drupal\statistics\StatisticsViewsResult + * @return \Drupal\statistics\StatisticsViewsResult[] + * An array of value objects representing the number of times each entity + * has been viewed. The array is keyed by entity ID. If an ID does not + * exist, it will not be present in the array. */ public function fetchViews($ids); @@ -37,7 +40,9 @@ public function fetchViews($ids); * @param int $id * The ID of the entity to fetch the views for. * - * @return \Drupal\statistics\StatisticsViewsResult + * @return \Drupal\statistics\StatisticsViewsResult|false + * If the entity exists, a value object representing the number of times if + * has been viewed. If it does not exist, FALSE is returned. */ public function fetchView($id); diff --git a/core/modules/statistics/src/Tests/StatisticsLoggingTest.php b/core/modules/statistics/src/Tests/StatisticsLoggingTest.php index 152c95a..a75f896 100644 --- a/core/modules/statistics/src/Tests/StatisticsLoggingTest.php +++ b/core/modules/statistics/src/Tests/StatisticsLoggingTest.php @@ -3,6 +3,7 @@ namespace Drupal\statistics\Tests; use Drupal\simpletest\WebTestBase; +use Drupal\node\Entity\Node; /** * Tests request logging for cached and uncached pages. @@ -125,6 +126,17 @@ public function testLogging() { $this->client->post($base_root . $stats_path, ['form_params' => $post]); $node_counter = statistics_get($this->node->id()); $this->assertIdentical($node_counter['totalcount'], '1'); + + // Try fetching statistics for an invalid node ID and verify it returns + // FALSE. + $node_id = 1000000; + $node = Node::load($node_id); + $this->assertNull($node); + + // This is a test specifically for the deprecated statistics_get() function + // and so should remain unconverted until that function is removed. + $result = statistics_get($node_id); + $this->assertIdentical($result, FALSE); } } diff --git a/core/modules/statistics/statistics.module b/core/modules/statistics/statistics.module index a7f1e10..4d7f42a 100644 --- a/core/modules/statistics/statistics.module +++ b/core/modules/statistics/statistics.module @@ -10,6 +10,7 @@ use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Url; use Drupal\node\NodeInterface; +use Drupal\statistics\StatisticsViewsResult; /** * Implements hook_help(). @@ -125,6 +126,12 @@ function statistics_get($id) { if ($id > 0) { /** @var \Drupal\statistics\StatisticsViewsResult $statistics */ $statistics = \Drupal::service('statistics.storage.node')->fetchView($id); + + // For backwards compatibility, return FALSE if an invalid node ID was + // passed in. + if (!($statistics instanceof StatisticsViewsResult)) { + return FALSE; + } return [ 'totalcount' => $statistics->getTotalCount(), 'daycount' => $statistics->getDayCount(), diff --git a/core/modules/statistics/tests/src/Kernel/Migrate/d6/MigrateStatisticsConfigsTest.php b/core/modules/statistics/tests/src/Kernel/Migrate/d6/MigrateStatisticsConfigsTest.php index da6ab30..a9c18a7 100644 --- a/core/modules/statistics/tests/src/Kernel/Migrate/d6/MigrateStatisticsConfigsTest.php +++ b/core/modules/statistics/tests/src/Kernel/Migrate/d6/MigrateStatisticsConfigsTest.php @@ -32,7 +32,7 @@ protected function setUp() { */ public function testStatisticsSettings() { $config = $this->config('statistics.settings'); - $this->assertIdentical(0, $config->get('count_content_views')); + $this->assertSame(1, $config->get('count_content_views')); $this->assertConfigSchema(\Drupal::service('config.typed'), 'statistics.settings', $config->get()); } diff --git a/core/modules/system/config/schema/system.schema.yml b/core/modules/system/config/schema/system.schema.yml index c23ed7e..dce4e97 100644 --- a/core/modules/system/config/schema/system.schema.yml +++ b/core/modules/system/config/schema/system.schema.yml @@ -7,6 +7,8 @@ system.site: uuid: type: string label: 'Site UUID' + constraints: + NotNull: [] name: type: label label: 'Site name' diff --git a/core/modules/system/src/Form/CronForm.php b/core/modules/system/src/Form/CronForm.php index d4726aa..0779c15 100644 --- a/core/modules/system/src/Form/CronForm.php +++ b/core/modules/system/src/Form/CronForm.php @@ -131,7 +131,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#type' => 'checkbox', '#title' => t('Detailed cron logging'), '#default_value' => $this->config('system.cron')->get('logging'), - '#description' => 'Run times of individual cron jobs will be written to watchdog', + '#description' => $this->t('Run times of individual cron jobs will be written to watchdog'), ]; $form['actions']['#type'] = 'actions'; diff --git a/core/modules/system/src/Form/ModulesUninstallForm.php b/core/modules/system/src/Form/ModulesUninstallForm.php index 80348c5..bcd6065 100644 --- a/core/modules/system/src/Form/ModulesUninstallForm.php +++ b/core/modules/system/src/Form/ModulesUninstallForm.php @@ -114,12 +114,15 @@ public function buildForm(array $form, FormStateInterface $form_state) { return $form; } - $profile = drupal_get_profile(); + $profiles = \Drupal::service('profile_handler')->getProfiles(); // Sort all modules by their name. uasort($uninstallable, 'system_sort_modules_by_info_name'); $validation_reasons = $this->moduleInstaller->validateUninstall(array_keys($uninstallable)); + // Remove any profiles from the list. + $uninstallable = array_diff_key($uninstallable, $profiles); + $form['uninstall'] = ['#tree' => TRUE]; foreach ($uninstallable as $module_key => $module) { $name = $module->info['name'] ?: $module->getName(); @@ -140,10 +143,10 @@ public function buildForm(array $form, FormStateInterface $form_state) { $form['uninstall'][$module->getName()]['#disabled'] = TRUE; } // All modules which depend on this one must be uninstalled first, before - // we can allow this module to be uninstalled. (The installation profile - // is excluded from this list.) + // we can allow this module to be uninstalled. (Installation profiles are + // excluded from this list.) foreach (array_keys($module->required_by) as $dependent) { - if ($dependent != $profile && drupal_get_installed_schema_version($dependent) != SCHEMA_UNINSTALLED) { + if (!in_array($dependent, array_keys($profiles)) && drupal_get_installed_schema_version($dependent) != SCHEMA_UNINSTALLED) { $name = isset($modules[$dependent]->info['name']) ? $modules[$dependent]->info['name'] : $dependent; $form['modules'][$module->getName()]['#required_by'][] = $name; $form['uninstall'][$module->getName()]['#disabled'] = TRUE; diff --git a/core/modules/system/src/Plugin/Block/SystemMenuBlock.php b/core/modules/system/src/Plugin/Block/SystemMenuBlock.php index c142c86..52daa09 100644 --- a/core/modules/system/src/Plugin/Block/SystemMenuBlock.php +++ b/core/modules/system/src/Plugin/Block/SystemMenuBlock.php @@ -5,7 +5,6 @@ use Drupal\Core\Block\BlockBase; use Drupal\Core\Cache\Cache; use Drupal\Core\Form\FormStateInterface; -use Drupal\Core\Menu\MenuActiveTrailInterface; use Drupal\Core\Menu\MenuLinkTreeInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -30,13 +29,6 @@ class SystemMenuBlock extends BlockBase implements ContainerFactoryPluginInterfa protected $menuTree; /** - * The active menu trail service. - * - * @var \Drupal\Core\Menu\MenuActiveTrailInterface - */ - protected $menuActiveTrail; - - /** * Constructs a new SystemMenuBlock. * * @param array $configuration @@ -47,13 +39,10 @@ class SystemMenuBlock extends BlockBase implements ContainerFactoryPluginInterfa * The plugin implementation definition. * @param \Drupal\Core\Menu\MenuLinkTreeInterface $menu_tree * The menu tree service. - * @param \Drupal\Core\Menu\MenuActiveTrailInterface $menu_active_trail - * The active menu trail service. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, MenuLinkTreeInterface $menu_tree, MenuActiveTrailInterface $menu_active_trail) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, MenuLinkTreeInterface $menu_tree) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->menuTree = $menu_tree; - $this->menuActiveTrail = $menu_active_trail; } /** @@ -64,8 +53,7 @@ public static function create(ContainerInterface $container, array $configuratio $configuration, $plugin_id, $plugin_definition, - $container->get('menu.link_tree'), - $container->get('menu.active_trail') + $container->get('menu.link_tree') ); } diff --git a/core/modules/system/src/Tests/Entity/Update/MoveRevisionMetadataFieldsUpdateTest.php b/core/modules/system/src/Tests/Entity/Update/MoveRevisionMetadataFieldsUpdateTest.php new file mode 100644 index 0000000..ba7e028 --- /dev/null +++ b/core/modules/system/src/Tests/Entity/Update/MoveRevisionMetadataFieldsUpdateTest.php @@ -0,0 +1,83 @@ +databaseDumpFiles = [ + __DIR__ . '/../../../../tests/fixtures/update/drupal-8.2.0.bare.standard_with_entity_test_revlog_enabled.php.gz', + __DIR__ . '/../../../../tests/fixtures/update/drupal-8.entity-data-revision-metadata-fields-2248983.php', + __DIR__ . '/../../../../tests/fixtures/update/drupal-8.views-revision-metadata-fields-2248983.php', + ]; + } + + /** + * Tests that the revision metadata fields are moved correctly. + */ + public function testSystemUpdate8400() { + $this->runUpdates(); + + foreach (['entity_test_revlog', 'entity_test_mul_revlog'] as $entity_type_id) { + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = \Drupal::entityTypeManager()->getStorage($entity_type_id); + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type */ + $entity_type = $storage->getEntityType(); + $revision_metadata_field_names = $entity_type->getRevisionMetadataKeys(); + + $database_schema = \Drupal::database()->schema(); + + // Test that the revision metadata fields are present only in the + // revision table. + foreach ($revision_metadata_field_names as $revision_metadata_field_name) { + if ($entity_type->isTranslatable()) { + $this->assertFalse($database_schema->fieldExists($entity_type->getDataTable(), $revision_metadata_field_name)); + $this->assertFalse($database_schema->fieldExists($entity_type->getRevisionDataTable(), $revision_metadata_field_name)); + } + else { + $this->assertFalse($database_schema->fieldExists($entity_type->getBaseTable(), $revision_metadata_field_name)); + } + $this->assertTrue($database_schema->fieldExists($entity_type->getRevisionTable(), $revision_metadata_field_name)); + } + + // Test that the revision metadata values have been transferred correctly + // and that the moved fields are accessible. + /** @var \Drupal\Core\Entity\RevisionLogInterface $entity_rev_first */ + $entity_rev_first = $storage->loadRevision(1); + $this->assertEqual($entity_rev_first->getRevisionUserId(), '1'); + $this->assertEqual($entity_rev_first->getRevisionLogMessage(), 'first revision'); + $this->assertEqual($entity_rev_first->getRevisionCreationTime(), '1476268517'); + + /** @var \Drupal\Core\Entity\RevisionLogInterface $entity_rev_second */ + $entity_rev_second = $storage->loadRevision(2); + $this->assertEqual($entity_rev_second->getRevisionUserId(), '1'); + $this->assertEqual($entity_rev_second->getRevisionLogMessage(), 'second revision'); + $this->assertEqual($entity_rev_second->getRevisionCreationTime(), '1476268518'); + + + // Test that the views using revision metadata fields are updated + // properly. + $view = View::load($entity_type_id . '_for_2248983'); + $displays = $view->get('display'); + foreach ($displays as $display => $display_data) { + foreach ($display_data['display_options']['fields'] as $property_data) { + if (in_array($property_data['field'], $revision_metadata_field_names)) { + $this->assertEqual($property_data['table'], $entity_type->getRevisionTable()); + } + } + } + } + } + +} diff --git a/core/modules/system/system.install b/core/modules/system/system.install index 61b2650..614ad9e 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -12,6 +12,9 @@ use Drupal\Core\Path\AliasStorage; use Drupal\Core\Url; use Drupal\Core\Database\Database; +use Drupal\Core\Entity\ContentEntityTypeInterface; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\DrupalKernel; use Drupal\Core\Site\Settings; use Drupal\Core\StreamWrapper\PrivateStream; @@ -1789,3 +1792,138 @@ function system_update_8301() { ->set('profile', \Drupal::installProfile()) ->save(); } + +/** + * Move revision metadata fields to the revision table. + */ +function system_update_8400(&$sandbox) { + // Due to the fields from RevisionLogEntityTrait not being explicitly + // mentioned in the storage they might have been installed wrongly in the base + // table for revisionable untranslatable entities and in the data and revision + // data tables for revisionable and translatable entities. + $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager(); + $database = \Drupal::database(); + $database_schema = $database->schema(); + + if (!isset($sandbox['current'])) { + // This must be the first run. Initialize the sandbox. + $sandbox['current'] = 0; + + $definitions = array_filter(\Drupal::entityTypeManager()->getDefinitions(), function (EntityTypeInterface $entity_type) use ($entity_definition_update_manager) { + if ($entity_type = $entity_definition_update_manager->getEntityType($entity_type->id())) { + return is_subclass_of($entity_type->getClass(), FieldableEntityInterface::class) && ($entity_type instanceof ContentEntityTypeInterface) && $entity_type->isRevisionable(); + } + return FALSE; + }); + $sandbox['entity_type_ids'] = array_keys($definitions); + $sandbox['max'] = count($sandbox['entity_type_ids']); + } + + $current_entity_type_key = $sandbox['current']; + for ($i = $current_entity_type_key; ($i < $current_entity_type_key + 1) && ($i < $sandbox['max']); $i++) { + $entity_type_id = $sandbox['entity_type_ids'][$i]; + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type */ + $entity_type = $entity_definition_update_manager->getEntityType($entity_type_id); + + $base_fields = \Drupal::service('entity_field.manager')->getBaseFieldDefinitions($entity_type_id); + $revision_metadata_fields = $entity_type->getRevisionMetadataKeys(); + $fields_to_update = array_intersect_key($base_fields, array_flip($revision_metadata_fields)); + + if (!empty($fields_to_update)) { + // Initialize the entity table names. + // @see \Drupal\Core\Entity\Sql\SqlContentEntityStorage::initTableLayout() + $base_table = $entity_type->getBaseTable() ?: $entity_type_id; + $data_table = $entity_type->getDataTable() ?: $entity_type_id . '_field_data'; + $revision_table = $entity_type->getRevisionTable() ?: $entity_type_id . '_revision'; + $revision_data_table = $entity_type->getRevisionDataTable() ?: $entity_type_id . '_field_revision'; + $revision_field = $entity_type->getKey('revision'); + + // No data needs to be migrated if the entity type is not translatable. + if ($entity_type->isTranslatable()) { + if (!isset($sandbox[$entity_type_id])) { + // This must be the first run for this entity type. Initialize the + // sub-sandbox for it. + + // Calculate the number of revisions to process. + $count = \Drupal::entityQuery($entity_type_id) + ->allRevisions() + ->count() + ->accessCheck(FALSE) + ->execute(); + + $sandbox[$entity_type_id]['current'] = 0; + $sandbox[$entity_type_id]['max'] = $count; + } + // Define the step size. + $steps = Settings::get('entity_update_batch_size', 50); + + // Collect the revision IDs to process. + $revisions = \Drupal::entityQuery($entity_type_id) + ->allRevisions() + ->range($sandbox[$entity_type_id]['current'], $sandbox[$entity_type_id]['current'] + $steps) + ->sort($revision_field, 'ASC') + ->accessCheck(FALSE) + ->execute(); + $revisions = array_keys($revisions); + + foreach ($fields_to_update as $revision_metadata_field_name => $definition) { + // If the revision metadata field is present in the data and the + // revision data table, install its definition again with the updated + // storage code in order for the field to be installed in the + // revision table. Afterwards, copy over the field values and remove + // the field from the data and the revision data tables. + if ($database_schema->fieldExists($data_table, $revision_metadata_field_name) && $database_schema->fieldExists($revision_data_table, $revision_metadata_field_name)) { + // Install the field in the revision table. + if (!isset($sandbox[$entity_type_id]['storage_definition_installed'][$revision_metadata_field_name])) { + $entity_definition_update_manager->installFieldStorageDefinition($revision_metadata_field_name, $entity_type_id, $entity_type->getProvider(), $definition); + $sandbox[$entity_type_id]['storage_definition_installed'][$revision_metadata_field_name] = TRUE; + } + + // Apply the field value from the revision data table to the + // revision table. + foreach ($revisions as $rev_id) { + $field_value = $database->select($revision_data_table, 't') + ->fields('t', [$revision_metadata_field_name]) + ->condition($revision_field, $rev_id) + ->execute() + ->fetchField(); + $database->update($revision_table) + ->condition($revision_field, $rev_id) + ->fields([$revision_metadata_field_name => $field_value]) + ->execute(); + } + } + } + + $sandbox[$entity_type_id]['current'] += count($revisions); + $sandbox[$entity_type_id]['finished'] = ($sandbox[$entity_type_id]['current'] == $sandbox[$entity_type_id]['max']) || empty($revisions); + + if ($sandbox[$entity_type_id]['finished']) { + foreach ($fields_to_update as $revision_metadata_field_name => $definition) { + // Drop the field from the data and revision data tables. + $database_schema->dropField($data_table, $revision_metadata_field_name); + $database_schema->dropField($revision_data_table, $revision_metadata_field_name); + } + $sandbox['current']++; + } + } + else { + foreach ($fields_to_update as $revision_metadata_field_name => $definition) { + if ($database_schema->fieldExists($base_table, $revision_metadata_field_name)) { + // Install the field in the revision table. + $entity_definition_update_manager->installFieldStorageDefinition($revision_metadata_field_name, $entity_type_id, $entity_type->getProvider(), $definition); + // Drop the field from the base table. + $database_schema->dropField($base_table, $revision_metadata_field_name); + } + } + $sandbox['current']++; + } + } + else { + $sandbox['current']++; + } + + } + + $sandbox['#finished'] = $sandbox['current'] == $sandbox['max']; +} diff --git a/core/modules/system/system.module b/core/modules/system/system.module index a1a6b71..3d01317 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -994,27 +994,13 @@ function system_get_info($type, $name = NULL) { function _system_rebuild_module_data() { $listing = new ExtensionDiscovery(\Drupal::root()); - // Find installation profiles. This needs to happen before performing a - // module scan as the module scan requires knowing what the active profile is. - // @todo Remove as part of https://www.drupal.org/node/2186491. - $profiles = $listing->scan('profile'); - $profile = drupal_get_profile(); - if ($profile && isset($profiles[$profile])) { - // Prime the drupal_get_filename() static cache with the profile info file - // location so we can use drupal_get_path() on the active profile during - // the module scan. - // @todo Remove as part of https://www.drupal.org/node/2186491. - drupal_get_filename('profile', $profile, $profiles[$profile]->getPathname()); - } - // Find modules. $modules = $listing->scan('module'); - // Include the installation profile in modules that are loaded. - if ($profile) { - $modules[$profile] = $profiles[$profile]; - // Installation profile hooks are always executed last. - $modules[$profile]->weight = 1000; - } + + // Find profiles. + /** @var \Drupal\Core\Extension\ProfileHandlerInterface $profile_handler */ + $profile_handler = \Drupal::service('profile_handler'); + $modules = array_merge($modules, $profile_handler->getProfiles()); // Set defaults for module info. $defaults = [ @@ -1028,7 +1014,14 @@ function _system_rebuild_module_data() { // Read info files for each module. foreach ($modules as $key => $module) { // Look for the info file. - $module->info = \Drupal::service('info_parser')->parse($module->getPathname()); + // @todo On the longrun we should leverage the extension lists services, + // see https://www.drupal.org/node/2208429. + if ($module->getType() === 'profile') { + $module->info = $profile_handler->getProfileInfo($module->getName()); + } + else { + $module->info = \Drupal::service('info_parser')->parse($module->getPathname()); + } // Add the info file modification time, so it becomes available for // contributed modules to use for ordering module lists. @@ -1037,12 +1030,6 @@ function _system_rebuild_module_data() { // Merge in defaults and save. $modules[$key]->info = $module->info + $defaults; - // Installation profiles are hidden by default, unless explicitly specified - // otherwise in the .info.yml file. - if ($key == $profile && !isset($modules[$key]->info['hidden'])) { - $modules[$key]->info['hidden'] = TRUE; - } - // Invoke hook_system_info_alter() to give installed modules a chance to // modify the data in the .info.yml files if necessary. // @todo Remove $type argument, obsolete with $module->getType(). @@ -1056,15 +1043,18 @@ function _system_rebuild_module_data() { _system_rebuild_module_data_ensure_required($module, $modules); } - - if ($profile && isset($modules[$profile])) { - // The installation profile is required, if it's a valid module. - $modules[$profile]->info['required'] = TRUE; - // Add a default distribution name if the profile did not provide one. - // @see install_profile_info() - // @see drupal_install_profile_distribution_name() - if (!isset($modules[$profile]->info['distribution']['name'])) { - $modules[$profile]->info['distribution']['name'] = 'Drupal'; + // This must be done after _system_rebuild_module_data_ensure_required(). + $profiles = \Drupal::service('profile_handler')->getProfiles(); + foreach ($profiles as $profile_name => $profile) { + if (isset($modules[$profile_name])) { + // Installation profiles are required, if it's a valid module. + $modules[$profile_name]->info['required'] = TRUE; + // Add a default distribution name if the profile did not provide one. + // @see install_profile_info() + // @see drupal_install_profile_distribution_name() + if (!isset($modules[$profile_name]->info['distribution']['name'])) { + $modules[$profile_name]->info['distribution']['name'] = 'Drupal'; + } } } diff --git a/core/modules/system/tests/fixtures/update/drupal-8.2.0.bare.standard_with_entity_test_revlog_enabled.php.gz b/core/modules/system/tests/fixtures/update/drupal-8.2.0.bare.standard_with_entity_test_revlog_enabled.php.gz new file mode 100644 index 0000000..1977264 --- /dev/null +++ b/core/modules/system/tests/fixtures/update/drupal-8.2.0.bare.standard_with_entity_test_revlog_enabled.php.gz @@ -0,0 +1,517 @@ +‹ŠRXdrupal-8.2.0.bare.standard_with_entity_test_revlog_enabled.phpì]is⺶ýÞ¿‚Jݪ>}ê(ǃ<¥ß=ï&IIHB n%[28ÛØfÊ­ûߟŒÍ3&ýŽ¿t¬akkí¥½·dñ?ÿë4œoþ™ú—fcê—|daäb/_·l—d “|ûó÷ß¿¥~OýK>Ð?ÎSùHEI!ZÊó -…;-'¥ÛnÊ'ô³UO9×±=âÒA¥§†á¥‚R=ä¥êÄ".ò N©ƒ”ß ©ŒÛq™’O™VoÛ¦wJ%Kyšk8~ÐÈŸß¾u¼qÁ§©pÿΌĘüñóÛ·h¶eÍ7l+õÏÔøÁÙYøéÉ“ß~Ì—yZƒ´Ðo?À_šK¨`OH5ÉoßUÓÖš5ZÐ'–ÿýr]4øí[*õ]7ˆ‰½ï©þ5ý’~màèWôKàá×ß=âÈüþÇø‰eû)«cšÃ§OÏ—“'žñ>ªCç¡5[§cyFÝ"8RçGøßw—t Ž©ö(F0 ¥rdÏoK{dÒÕ* ºÈÕÈ­!O3Œ8:1‰U÷aežû>ß]§óÁ€wêŽåäH&²êÔf8DvnˆÃ¾;®ÑBî Õ$ƒeØ›ìXF»C‚r‹ CumˆäZ-Ð_­ÖEfgÙ†ÚWÀ|+µõ¸›}º0(äObÊ(—þ‹\jÑk0¾ÐIkàµÍZ0Hó‰[󈪹ãërK…´Æ­h¡VSm<ˆCjÇÂæA#O«`¢£Ž9cdê01‰O¶å‡Yú¾ûŸ¬EÉZô·[‹Éú]”~¡5àÿ!û!cÿ Àdô¦]?pîcsKÝÐ2×YßQQŸ$‚¸:Ú}’NÁñt‘$‚?9¼ÄJrÛ/¢ßÓ…Áö=ê—;qVíÙS`¬ €@9†56…EÃ&¶×‰GGug+!}Çp÷CDhíÊ'†´:-êjq¤p\¢…®q¸@Ãi%OC#køίî”a¢†Ž)Üž×,ê(®’|T_€ïþ“Ö¤ü| £ˆÉ–Z,Wu%ˆGŽ@<´ (4a„uÖIXçh¬ã#à +’O ñ$Ä“OB<Ç!ž¸§nÎI8'ᜄsöÂ9£éKh'¡„vÚ9ížfw‰kÃ1!ž„xâIˆgÄî&¬“°NÂ: ë‹uZÄê$œ“pNÂ9 ç‹s‚kHT”lb%´“ÐNB;Ç¢áÅ Zî)g<цեˆÆ÷ÖãꀶÿñäÊ:М٭VÌË¡>Z$Žð®ÉHÚZr9Ó‘/gšgŸu·3§h«{™Ö“[¤éY0¬£l6‡¥ÀQOá’wj†‹ròNÍvr$ïÔ,’ÿß­™ëòxëå—zÅ%žr—•: +…”ëùÔ9 . åÕ‰½–2èÇ4’îHÞSTNkñUñp%·Pë ÜGîíaƒyþÔ£1Z„â®å|âÎÐÆ”'®íö#·û³ìh,Ÿfw¬õ §H¸ÅÂ1Ë ‹¶ùñŠ¾+ +n¦ßùçh¡ÈQÖ“Í.Ö;8}ÍHû.“p>û¦é]}†=‰áuÔ7 +åÃÐ<Œ(}{fßÓh·¢µa¤'Nˆjµa·ˆƒê‡ØâåX Ûó¦ÇŬØn÷bÅ¡Åiúÿh·~áEgÛÌó÷[ù ª½XIÌw2öf¬ÈÂÚ±Ç`bƒ^¿ÚÅaš±Í…a“¼åÆW…Mû[¦Œ©—߶÷”îb›Ãôº¦ *ÕJâ?Y^½wm¹‡áÌ*nD+Ú±:­šEzK‰:òK]ùåêŠöº0œÈäÎuFt H2K!ÀaQ¢ÑBug$Àb™SY™$Y9ht ÏG¡ðŸ4›ùÒë%{ûÄe¯/íŠÕN»i‡d/ç´®Z…~¡i¾4%÷AÓëà'&0Flz§„ujº¡ûm.s맂¬ >¨¥øìðÉaƒæHöÒ ¦ÓZ8 9MdtŒÔáØF[ã9UfR«ÇL” ˆ 2ò*C4QS "(¨" Ȳ Qä9 ¬ë>¨Ï-ÎûÜüŸêq»}%ïâLWºÅl:Ȭž÷q#_Õ+yR,4Ø;о±ª½\4èœt<„͜ÎÄsËüæP¡¹Ük«Î9ÜÂ( µPר_ß<˜Ó}4›!žGƒêØü¯°Ça 0‚@™PãÑ`‰eeQÁ:Ï¿vâå&÷¿–^t“̧·÷¼&µó]«cx¾ +=®È4¯à[ÚÖ[ÍüÊ üX©˜ÅDƒÂÝ Oð6ÀÎÀ8ì>nVq]Í(þKÃÙKÍŽðK'a‚ŒQÍ7|“ÄÍCN'@×eŠpu€4LƒLY”DE†'Ü×õp$÷Qѽ×..p9¹X)œWúJõ¿º2ï nÕ©øê9ô•§h^\š wª²ý88B 'ø4é6®s¾¢Òd)Âád<_ØwìqG'¡bP1‘ˆeH aU q@R™gdñ’þkSñÛÃÃósùŽ{î»B»êkÊ»v}s‘î;7ÙvïØ—»oW*™(Osà#n挬ͲíñŒú¯©ƒÍ÷x–Õ¦ÅïÃ2)uʸ™_ž‘=œóŠ r¤ ‰2®, aâXI•$SwˇùPÞƒü^ÁB9ó\_»ê ]²:/•KÒ +°}sÝ/ËË>o á|e–{,blŒ£è )È]o i³s¶4ÖÍð ÙMö{–Õš6í§4Ò0Ož-³4‚p0•ÇtpéU\ZÓÈÊ: 8™ºÑr +P‰ÌMÒIG˜ƒ<øüí£Z-Ü=^?¼ì ¨Giž›XÀhû3€H~&nœ²>æ\_wÖèßO3mnÆ볦ðÉ›<íÙÚâ,‹L­…‡‰C/\ + s+iºŠ0d?ÙÍ Îñ£Ûþår‡+>x½zúÝ7•"y}Ë°´¿~þ²PõJõûB%Óà:×W´E—}A«Q˜Ê–d9À7;Ç¢Kb QFãÄt1P$VU$¢rƒÅÝwý?Ü•ÇŠ(_—›èÙ¸"U³\è²Ö;É×3-¦Zí½VêNûٹ;÷Î+‹ÞÍœFc;9IJG8o°'ÏòXWuE€Â@fP?©¼¬LvwlvÙ“ß ÉwÏÂy *­ÞmN÷„~ö½u«²Àô§þl›ýBI¹»ËòÝ.ŒÒ43Fò²ù•0žÙêÞã½øP±ÛlÅCcF¡\+P/@A€*¨:À«îäÞËînæºÖåsWó®Qlø¼x]yŒîгS`×'[w;:žÅPa§ä­—UŠæ­‡¯ûLF³ßp>? ª}ÃD‘• À˜!ƒ @P”V5¤CEWxPoáàžïëu»Ü»õÙÛLÚBïÚåøмn¥oßšð‘©Õ€ì1Å¢Ò'woÑ#|pŒäUÛˆ«cºdñ ØÞ|‘¨*/I¨Á^9ä$P\³@gFgFã…Ýϧʹ¨–ÏÛ%t‹™ûæÃYý·—û‹Â»ŸÇÞΑ¦R2®P»Z‹zºâ¹«·WbW\ùZ¬ó§ï Ä‘ÖGgh6?Öl¦°P. ¯*C½ +^bŠeóœ(IêîÇ:…WŒlÑ»PËý·Þe^~¹e»FæE¥­ÝæŠÍŽ /úêSûöâÉ‹K&x]ÔÜ€û©û‚ûÂþ¡Oø){<}a{sˆ#‰ç ©@‚DÑ¿T‰³ +FŠÌBÝxïT/HÚ0trùÄ_‘²Xõ¯›—Fï±xžyª6ýlý¦Ü»7HF >É/ÕÝFž…ã’Ún™µCAû…£áíìÌ°îhÞX½§Ám$§*òF?º2‡ne ¸È«,Ò9  $¨³PTˆèG¨‰ŠLd–Ù= p‹ýíºùþÖªô æŠAu¥Pe¸s[º.b§üØ»îîkÅnÖ~Ë"gJ‡Çé¨f;ؤf!4¾ñy Æ &žæÎøœ&¤MŸ§ÔiÕa–—BáòžB¦·>ŸRa¶žÌÑ-Aá4N~ì"ÎDŠŠˆM怌:‘,G€Œˆ¼.ˈÕD:ŸŸ4‘j»êÝñìg1Ïoš:H;Å{CÉpF‘ñ΋¹G)k]zJ»ÐE‘œÓ0Äê`a*©¸™°ÇÔ¨ˆ7Ž%F—gÍÜÂT ›£³FÃ^2ÉÕ̹iÚ=oÜ"%–”mMî»L+…‹æŸNuB°Š´æ’å‡[–‡:ƒEN©‡¥k@¢¤†£&««˜…ä³,´;håÌúõ€/:yáêêMù×·Þ#ÓBšñ7xᔯ-»Ý›ŸX9tB-,›Ù2QƒR³eÚ ~"Ê1¹ŽL&íþEú¨å˜CËGç.qÌ!MÓúKZyMŽ—½ŠÝqÇfŠŽ7¥b¥¼À.'œ *Œ?iwOÐpˆëÙÖèÖÅ¡¡F$žÚ¼*ð€Pá)y«pX%²ŽeB>Íæßl«[÷ùtó¹À‘ÂíS¶WÖs†IÊåLVþ½+kN\×Ö%už·úز,Ë»ŸBB˜É@*J¶%fLŒ«Î¿’±Mš0tÒûž—t2FZŸÖ¼´ä¿&ÇÙ^’¶ªÝFvokDTØÝA6» év#é¿?VÿS¶ÀáÞW÷¹gIñhÕûŒÄ{®ë¬BX’|í~;X©þs¯–†•À—Çì‚Àbô~6ãœh_|ý6S·lBþ’¶\s‚|ÚuÞ*•9«¿–œ“´vXØûP^Ñî »í‡”ó?¤‡þCXr}× ¿qáË2¹m)@U î,2‰ Œ«T3Lh˸Œ­ Æ*ÔQK`ìÈSÌ~^¬Ý ú¨ª/x½ìi¾Ÿª½¿ódÊãÍÀpâŽß+í—†ì/â¯uËÒ•½»W% V*wGßÊHApGøù0äµ-öüÎà…ÎïÞpOðÅMôdŒÆÙ9`Æ÷qÛ[îW%¤ºïÑÁ¨'"ÙáçÚšjÁfÛ þÚ G‚Ò¦ÿ.çµõ¥†ð*zË­'±é‹DÍêÜAc½¾¿ÿUæ- s9_ˆI8_…z–TŽˆ(«ë]·Çdç 3Eì~ÞIòƒ‡K8θd¦n #¼_"3ôHWEÜ P3 Ê,r¾ |š¢1º½Šž}yõ¦‹gZ¾ÕÓ‰žÚ3Rƒé´újhîùÕÑd<¹Û·A6pÜÚ‰…t8v#©v³¤Z¸é¬e£dê ™ºê—úë¦ÿoçßo7àæþïöEakùýžlÊ#Ïâpƒç„ WÉÞgÈ6 @¹© +ÏÀa:Á¶ðÎ/‚> ·»B©ç×JÅ~ÓƵôìôÚnôXòî¡£ÛJµ TèŒ+`÷¼‰xíŠ +»À‰±ûêSî&Žý,ñÕ7ÐÎu ’×E!ª·‚uMðQ€‰© ÊT‹ó/ƒ©3ãÉÞ´ž-¿f{òäÝðÒµë³T¦öì²wZ¥óåz/=ÞM«j8IRìúÚXÑ¿â.-Dì?UÁY +—G®/”Ñ8Ø4U…¶  #*ŒÕÖ€@ %bŽâ˜ÎùçhNƒ­n<Ž“µéSãÅ©æ'9A +üª5ˆF€¤ëÝj?6óÓ´ÂvÛc*!lö0SBÌž¢Ñ_¶Äëò(Ï[¦…fHp3„ûlRˆ0ò¢ºƒ Ft‹ž_|HOÆü½m¿ukn•çúÐt¼ÍÜÞ¶[­ZJ-ÌÇ´ø djõ»xÇV‘¬ÇQâ«']¢)cGZ˜ÚVU@¬ C#* ;€« ›6¶ˆÀð‹ š>¦ÐËÈ}nLÓÍ œ4³¨o'éJváÍ™jÌ» gê Ã.‹‡HRáD/áØ1Zê?//—‡hÎhL—±8ˆTuS£‚m°*5”D j ÙTǶBÎïÞqD…ë¶KãW³l=—`ƒt +÷·9•›o9V|5¦CžÏ4íÒÂ;`HH*‚è-û•^]ž>a𙚑ƒ8°)ŠS˜ªc•0fBÓ¤6û*KBÒé“÷X¼+&»n½ ‹T©¦Òè4ÛÙ{^]´üi¯_›í  VôØSMd2ùÔsœ(¦vi°çÆ5&Žå+…aÍB +]ªÀ‹PÀ‘ª«šnéúâ §áå6Éèåµu;¾·rOS-é¶oïêæ{eÞJߦû¶U{iLs6Ï[;é$é½j"¼UÅ…ìåx<>Ê>oÉ—üu“¾éüus —ªÏœö¸¤¬&ƒ†" u[v…HøSB]é&Ô“3Å6¾ +®Á"'LNˆò×ìkƒT{Uö°Ç„[¾üæÎBµ3è¼’û³Y íþÜ™;kUɯœÃŒ†Ýbv×rgÿÚÿµ¥ “&KÕw„eßÛ»PÿyÔÄ]Õ¶»ñ¦Íæ…›—¬ü_žj:Ñ–ù¦LÚ ‡±kþbSïÍ8Æ $¯Œuõ©o·îyL›,䫉l¾îÚ?ÒB ŠÀ>gž9Hƒe`C–œè\Óà¨*rÓ4lýü.¨¹hq–T°ŽMÛ,¶ÎnÇØîXdʨçš2•Ébžn9¹ñlЮŽÆƒÚ Æ‡üIü,ìéF¦mùÏ™÷ïwó[…ygØ1ÕwG1Æemí˘¯µ"þ§ÝþéÚí·ìþ§ÞÎQo²Ú}ùçsê #]±©Æ…f#Š¼‹‚e +˜D3 ¨^ âõ&­ñMõ¶^GP¿?lÛþØc‘ªúÀï?¨í6ùPÝ…}[NPw¹\¢Í˜»x¹õ”`ÑJÏÊÃÒûà±?,ÜòÃÀ/@m_Ýíaô uµ5ÙUwÑçGª;]®KzûÑ}ʱH588~“ÚÓåCá^‡.ãþSJø”T8 wpà)Å<·‘>Êý6.ç^›ã%äË]$%b/VŒM+LW±¢šŽ¬{BÀRe'“qðäÙïóûÌD(Ø/é”ó½×VÕêú`Q]7œËuÓüCsË‹¤çÝ':Þ¨Ô|éܾގF­D½“OAÕ^ÜÎÉ辑Ó'Yê±’øÞ¹euÇÑ£ƒrMÄ#i´-Waèç +ožn¥_OnqÊyÙWÝÀºM0Ü! Dm`jâ*RLlAf xÝ +Œ? ûªÎoÙƒó¤5°*n¯OßšÕìkýå­F FwŠg£E&Aªo#ò-³¯0:iº›Xý\žgtGñ’ª(×4'¶XéŒ ¦J‰ŽtKÉzwÌ@4Çšm:¶nPŠ/pcó>« N–M>#"VÅ1ÙÏ £ydöS‹Qme?eåÙÏ(¹¹éõ$--ê+e>—òc•ù<ÝŽSgðù)wÛšËE +¥~hÔ󨔧‹i²_T_Që6ȺÞn‡ª¯Î|êqZö·ˆ‰ƒbj/…¹7§#R˜ªªìXÄâÿÔr'ý,SöûO5² ÇzA’Ð<-¹»šõ¾Üwºx„+Çqåà õÂ#ØJ4,eðg¢:A²ŸÜ]„1Ѭͷíf'ÍËÅ„Ãëè u· …÷Îúƒ‰\[ay£¸îgqÊ +b9ª¦Œ f0@™M€a!G‡H¡_£änUn£)1Ê*ð郉…üéªéís™ @ÉT^Õt?íºÕ;v÷¥è&š=VùæYÍÔ ?t‹¸XØé'Æîö¹¨"‘7È._ãªÀohKù¬Ný¬° wâ7bHŸÑQ\d*–' F5‘ +°E4€LSq€ujrŽ,횤¦}ȓ˵üÑlI¶-Æè€Ì§LF탈zd ÂsyºFš­ÙÃë€<ÜÁ!.ÁinX|Ĭ ä5ˆíåÔÜ̧Êw¿{ìG¾7f^”­ƒ¼pôÖmÎF¿0aT·e€]¯á{m± Ž*&Q#…+¿Óè±As™¿ßèwt²½ò›,/¼y.kßôÚ$ç·³À.ai²ãN³À´0©ó UÀ E6ˆÚPW™Ó0@ŒËc¾¶4S™ÌFðºñê«ÙDBñ쬣[Æd˜}˜uf98š·ú£÷I)=º(™4¯Ï]\ð*1YÇ/*²Ù=ñt¹x›ä‡ëEÜ®šÀ;À,Ÿ2–ƒ†Ã `ƒ›ÂX• c‡ØŠ¤\î¶ã5¯h§K¿d,ø•Œååá«Øõă9»Uq«ßH1Ë,=<α°AJÑÉÂ^×Ë£Ä~—ã«쌓Ùê3H,‡i‚|+Ö[WbÈ"jé¶m‹1fÕQ®†åhúPåxŒ½]œrU#z——ëT¤ ß·ä…ð²°æZV䩵lÔ¹ÂU°Âd… Ö€É!–© #Ò±T¬]îŠô@-ìûÓÌË}ئÊɧlY¸ØÙq£ÓŸ”T%õ¬æHË'í|]ß½‘ôëjÙ`Ü>szOãI©“*8“{gJpƒXö BËF5“~â…Gr×wï+#»…A’v«¬ÓúÑtøñ§Ê„®Vv`¥ÇG[5 !‰0ˆ*ò¦  +'Ät Ùxúšho?N:¹b°|׌ü]vŠ:Íúói½Ûó K‹Í© ÓàýÔkn÷æÐu5I,Àr\"|³Q­r\¹Ëu!ü•ãñåÖ Mv]£Üì`@,û‡ËQ¦YÖñÕQ'à»r™N÷®çùÙÁ,Ó©;F†Òáƒ^0`ÜlÃVfgéöÄoÿ¾0Úo¢·¢ÓÀ®¸ò‡ï7 ›þŒ¿UEã¶Í  a&D´† –iF¡ø@7 ׯʴg:é½-<à !7óOCPêyh”îôÞËïÀTú¶+Ô Ïå3Ó¤·#!¨aNnSñÚr¥ò}1êÙ-Y¾ÎfÇÖus3Á¢Ô‘ñ È…;J¨*7MS bî?§ùä‘YÜ–ïÀtX¾·xJ§Úsgвî +¯Ü›”mLm;ÆÝ#XÃV‚ñ›Õø·‚Ùc£X÷&¶;3¦\EŽ ÃB+6‘-KMYt§2¡‚-¦°o‹3Î$ %Ð)²åX÷ ýí¥„­*IòŒõîݾ=*ÉÆëºÓ껾=ÙÆyI±=.^½|àfuù«¼ô¨=Ž¿‘´>îånƱ®Q,$4Au…jwµ+Š…9dú³}Úk¡þ´0ß_<µ[{{Túr"“yúk'~"6hÇÃù Û-ÐP£€ð* ºãU?r½.¦>¹·?oøLÞ†u¼eéÂtVtÀ1VR‰ܦA„•ešÛB|óóù@+OE…S? ^P,>vFùRqâfS¸–˜ôr®ý–2 ü”ïXf}Øœ/ôÆíËn®~ã§×TÛ“Þâ©jøÔ|j}wÊ~ÓÖ{¯þÇAäX¿‰Ø\SLdê‚™MNU q ZÒujßÂóZç° 5YIx\¾h7Ç8…;Y”Í÷ÔÇDµK[}6¯¥o憎—9%»¾qŠÙ Ïn‚-ÔyòSÛßëñ¬Œ,S!&D€Y†p˜lfb#„—¤0‚ħôxV>M7ŸŽi¿”M7¸ûÖéž+žù4´S(Y¥wô° &æ)§‘u~¦õ´ç+­ègzÕÄàÍêÔé :^š¼ +sDm–hu<\ž+ïˆ + Û[_£©‡!™Õµšû1šp@£Ý¡ÛlFeÃÚvp"uõÆÿcïZ—G’õ«ókNÄT·î—™‰àblÀ€mŒÝøĆ¢¤*@H  `GôsœÚÛ*Ý@¸±ÁݽgûGƒ[Hª[fVfeæ—dÛÿ#ñ™¿ +Ç̹[’ôÜmkQ6A×@KZÛ–)ÉZC¤¿Ù«"dˆg2c–)Oæ=k¹ØYz{Â%ö¢‡­r/K£ÑríÇá&¡•^ŠÎÆí|ä¿ör¿O°½Ètbù¤Ϲ6²³ïäøt5WóÎ ‹-Æ +@vƒ•Úòós·?ta‹Ž’Ô¼]†V¼vé5ê9¦¥P²¡ÄÊiþÔ˜âa~%ýg?ÀóÜ=ÛÍwϱô7p][‡ù) +ÜÐ˽3\ÄÈ;R k‚ +4?7%l:Ktæò„›ä}D÷¢žH«ý¢Ä+Ì$ÑJYUg!á#?)K¶¥[úþsî‚NCêf¹ÅLÊ+')oZÜåÃ’e‡ÕþCØO• ˆf·N«Û[íesíBû™­u/†§Wä£Sö‰ +hÂ+ÛéK‹ó•½dŽ2˱ªKG¦d\¬i´.¨¶ÈÇÝÉ”Ñ +Õ +ÕdÑÒBu”w\7È* è%§Q‡ +BRæÝ,ÈR¦ÙïÙ'¦h$W‡L0·þtnõ×ü£²Z/F]ÔžкƒæX_v†÷£îIS‰©WŠG¦I(ž»Ö²bq¾}LXQxxé{»nzþ3²GCQnžú½úÀ¼{fÁìIí¼\éâi…caú)ùŠ2|5ZâHõ‚'ö½¢B€Æ*\Š¬óÀ4e(qDÇ`ÓS÷÷›l>;–ÖŸÈúí^ î/ìyêÒ¾™÷YézÕn ¦5‘Bá²yÛzYzbß¹W‡ƒ©1¼|œqã´ÛÂvÑ>œëfaSÛé\¹§//·AŸ1tS®Ì¢”þ‘Ææ¤|J{ÐŒúCß›¨7„ßçi™Ø®¹ñÞÉƽlЦ)à_¿ÒKû/&»&èµ—Ò¶^¸€öu²I¦ë_:ñæI›àJši~àn€¼ªF?L%zg¾‰^H³éuÚ_ÞD—,|u 5ªb-C7È|ÞT¬ÆchÍ“z¢4&¾dú¥äñÊáEÈhUJ—*~ŸXÞß{B{S’Ç€ìÏFÔ§¯)T•Ž­¼ôg)3>ÛØ'Êžîæûør&Ç£7ÐéŒ]ÉáÂv!ʶ‰=ž¦Œx_Žw…P·cÊdQm_7F/£ª«E׃èÕ~Âs¸ÑbÜ£l'‰á’6"šd¤Jo¹ƒLÁÚB[E‰º¥ó0g“–Ô¼xƒ°%A1KÓ^5€bò2àÌ!ÅP%Q:þœô=Â’ß–Ù>RVª’lM§òB²doílÙc”ÇÛ&®4ƒãGÕ 3QB°Öê_ºû²’¦´l;ù³IJùÛ’2a£ xÖ §i5”Ù!­¿ÕÙM³gTzUß^‹Rdþü d 1…™//þû$±ðFI¯•“©5À'2äÊõ¬ªóÚwåÿh™]XÁÄ]Gú9Êú%ÉÏ!Éø9²”9’uÕˆ¬@ä:£ÒÈŽœ®p +yé Äz ¦Q–òG.¢0÷â ?©åNÓ¤µ¼®Ùìö$OñÒYZU•›*þ¢31 ±k-±áuwêÑí,¾”OJ¹*‹?÷Ëʤ¿ äh'4ª–{ïIåÑÙ¤'{åÑ“LîíEÐãU·Øœ"¬bìW;§.QÔ}Aö< õбLÅË '@ž(K†Y€@—¨*Uh°"Ô?²<iR›2Ô÷?ºŸW;Óž×–ïî$f´X®ÚKívy×#¬ ”âxGy¸«/­Ç]´eü¨Øá2¶Ú‡)*寣!’öý+Y8Ú±ÜÅ~?Üg!5á ¸ãy8g/gûHvQ9C’XbV°˜¡12 /@׃ƒˆUø0m‹<;D#)$)¾áý R}æun²8cq{߬iv/TÜÓ&|h$€ìšoÈ«kÜ£Cá;ÅÄù·o6Çåpÿ?ÙdØ°É”¡É-2Ë‹u cA+" #(1†Î‰"˨â‡r‹TÊ-;[Ì[æÔ¸cÿeÐ_÷ü®›Üox˜ÝÈÒݤÑ7“Þ0\¿Ü}ñj'(f+_¼Ç6Å­à,œ£d{ˆÿ±›HþÀ @ °š•ùéúÙЧsoñ ôþŠù¯ðS?yXÚ¹#̽x‹`¨%>l+q¼‡±Å4U,}pž8' …ÍSIÌh1m?Bü%º4Œg8óAÇužçn˜ù2å Ò8~e© (îù¥CxàH E#“€"BH&š§`tr³,V…EgJ%ÄàÊ{¤ÄÖ[ýŒÛ/“ª‘¿”œ7õþ*œ6õHà ¤ô奓–4ûg?OprË7J€íåø}˜Ô£¸*ÇÚÜ‚ÎÉ2–Ãë<z + äÖM I¼¡È"ƒãô$rvOèeIä[«¡—¢F½"ó’tqaÁòiEv(=´‰Úù)~Á»àÜÐplj:Æ.¼±ž;®×\1KÖ4Ú‹Ç+-|º_bmã>´¡á•z=€U*óvàpÎgYÜçÞ·#ñòó…и(3 / GÍ*¶åŸè‘wo ­hòg¥:§ôÎÿ¨tñÆ2ÜJôîÊou¯0! ôG¥?±Ü,FS÷OŽe9v˲{Ѹ‡x7‹¾‹µx¾ hKéZe~þö£Òñ[l½¬„ ¥?'ª>‘ \ÑÀ>-.–”&¯—šSÒ>8ÔY%G†r¤ÀàLYWeEF0d€9 DCPFUy^ùØ*ÑGœ:àrž#oèëI—&«Í¦1ÒW ãê÷f¯Uëé7#uÙ¿±SšÕzƒÅŽ”Ⱥ˜ƒŸ9ßyCÈå×aû٣1áH618j2¨ jBºŠ (!‘á°„xùØ$#kžÝe“ì‡ïí`\Ë;·WvJW c2šùÄuPƒ…c¦:X\>Ó^k§\ +—†é—týБõÞ]¥\ó-ˆÃê›âûv‡ª0#7ô*+Ë£qϸB¶Ï¤Ÿ~ +ÛâæA©)Bk@$ƒò?c“”¤˜s@8àÏËM^nj¢ë´ \Bôå@›t'ÄT&±i šÒý¢¨¦±^ĘŠ;ç¸1Óoטâ§EâRK0ñÝŸ‰šOðÄóºÎ¨4¢ª„ž V!ê>Ö¯«5%òOÿPzÞ¯r!ûë‘['"i2Rnûb¸ö„æðna›Î°>´75ÁBMåñò¡¾«¦ç#>Èež#æ¢sù çm»# W’)#F‚©ª@€ Œ%@èU€º¡sœú¡„›Kó:™j/jCW^ö—Ðz‚·Ú]]õ¯GbËmt“/¬dù¡u5¨Öç;þì6æ få/Q{<™¾ÍÍ¡Š‘tB©oÚ!8I‘$l"ûˆÌë×EìûÑfAÝêfþ¸=µ†f¿>|nÜ®0rÄ»Àõ»ùEkÝ›¯FÓ]]K¨õläÍ+=oÈÉìRÏä)ïÅ;}gjf¾5¿ÝÊ+P¤I@D¨K‡¨2V¤@!V6䑽|Nö¦I½*‡½Ÿ¼³/­›ÎÓÈju»S››Îf&*w#SRŒ«ÁZì=ÝŒ´ví¹{q›ùü¾Ïçq#K=ý¤µÐ³´‘ÜßÏ:f³3õÜ€2Û6Wi¾hüç34Î$̵À;ø™gäM6ÉóLzW†ÏNÛŠ?|ýÙ¸ø þ@5X,³"U åP±I´(,èÈZá;*P'ÃÂ<.úšî+5_»qŽ/;œ§"Çe@m3[â…ºþUu'xÒÜž+òþ»¼JUêÜyì¼È:%3%šW<[ÇñøÏÛ¬Þ{@Ïs"Ï’hà1x „,P, ’Œ‘ |„þõ†Ýêý'îÒ <Ôš–ݸ_ƃþ=åzs|Õ´ $6Àlygm‚+E½LJJt|Øaû¯ýê×~µÏÈZh}ód9MdÒDLZ›d¶âKÉ4¾-¸ƒuº–ÊÇÄç¦}ûLñÓªOošBåºØ/ãõu NFwŽ¯É†Â$žCR{‚–_:¾°jL0¼”.NÂR¤‡#Qí ‚‚.ê@”%ˆ3ª›Ô M•1$ÃàN÷¾…•K]~¿QZT[®Û K¼ô×õ—Çé–ý^îÛÒ‡A¼MPòZIÎVåê¾{rÌëxE˜9Íωe6½–<–\/@%nˆƒ‰=we¡ÌãšB-•'›æë¹¥¸?{¡9•ì¶»Æ(k‡Éþ†• Ñ]ÐTþÆsòážýÏ°L¾ô,}¸’\¡KO¾B»B¹ŽüåÚÒ7/Hÿo[äÙô# ˆ|L¸Š}óÉ·|‹É·/h“íÇ:äËš+¾gT ÅÆ[‰6¡ +¥gï €¶[¸@ ;¾@öƒ±ÿiÀHlü#U's³¯ÅV[Ïcþ7Ç5]:ƒ[žXÊFwDï/Yݽ;NY^yqS'f†ð¢%Ã+éKÉ=§ôF9Ø›-ÞLè«·h#ߪ Ó~ÑKÕ¼:•œƒ­¨æm'jŸã÷õ¼X¿RŠ›1OࢄxÝwv­¯ÝµzeêçÎï§ô£¤ñZÊù6r¥enO +wñº¸Œï8s?ÏËh&Ú³e‰®}äþ˱&¦Q f$ HP:G¬/AexY5¡)¡Ókè¾Û:Ãi[¹·´§öÀž\våB›OÚ7@™ÏoïzÚt×tM‰;³Çm"…bJ§ã€e%ÒHÏ”a^ñ?»yÅ +êOj^½fù¤¹dÇ[>?’eÿÜébå[ça4P9 jÖ^ÛÉ8šûd¯á³¯ùw­e×2pjø¾U<4­›i—D5MóÂfU®I÷×kÇ҆Ɠ>Ç3ñRÐ9´¹m›êI[IµÅƒ“ëÞ|–I½õ +ÓxóÂQ^ôËg?ÊÕú´ˆcÇ(Ü@ÄÚåÄÖ§FHÄZÁÀZa?*à‡ Ò„ïkD³›inDåIÞ7y3ðÜh^éËž¹À`àÕKèúu›k(áúùb$b¿©Œ/Ö瘼¨T¥M²÷çO)Ù•^ÕEQ€*J:d²!鲡ÅÀ!ÕMýGíJmÎaîÍ—ôñày3õ%o€Æ›ëé@¿¯Ž—ÏëÞFS%¡»»+EÉ +Þ~ºŠ×ô‡Êï‚ÂüëÿÈÇÿüï<Ø4Éôny>ŠaFHæ!$º±ŽhÙE¤èK¤;ÈätVâÄ¢Ì(Ü>êÙ,91 ú?ŠÂIˆpãa¶-å‘áÈXþ*¸kÒKÔµHÞË÷3‘Ùœ˜>áü8:c A¬*ž¦Ç  +@°/"AUq§# ¼ÎFjoî<]Ý.9¦:<ß÷ªRÛY^»m à´'—`>l¢îžöCO:¢IØ#49†l ç•ß9ŽPùxÒt„LQ½£E YPf(zo@r"™µC”vܳg¤42–]J‹/}¥eÕ[#6¨@UǬI¤¡3Aâe ˆ& Å‘‰$ŽÅ§£¦½ØŒºE6Ò«ñõz*ºóK^´ýŽ!\Ï…n Í #˜=¸ ÁµÅͱíV±ÍÓݵîÓ+¿³ !9òñɱ¹Æ© +P$Ž¡Ðr*ÐÉ#À0tÉD†‘eõÉ÷ìI.®x_ ¹øÒYI.)Pñ-ç+=ÐN‹ h1¸¿æc;9ÀzŸš0Xv¡÷Òšnê|SAO®Ú¼èoÌe£ÓÐ|ôȺѼ`kUîå´z I­¶×Ëb¡ AD“Ç’Zz“ñ¦aqýî—úÀø2ì_F­† +“i_Xò’ã8°ûØÚÍÅòéå’=ðò0%!/\™¤àE‰C4-Éä‰E@†C@‡²"é +æLæGmKUüÈ.cíßßVëÕÇËÉ˪;sº£jÛ6žBfjOM<ä]ßÝ•„)ª[„bðo¡ AÊEFlr§ÿ÷gjÑ»£BᙀOs%©Êœ–Y­ØÖ W¼öÿ¨DjsÅ#Ì}ìÓŒOZ,¢²pýÀÿ”t&¶D·yˆ4Ú™(óT§÷s(Jôzªè硘èÖúoö®¼7q%Û^k¤™+M¥½/ýZw !ìDH¨\U³ØŽm£ÜïþªlKHzZšù' +ÞÊuÎÏg©:Ë6Ú˜ZA°®’òøq÷£{¿mÁÓ‹ÐÓKàÓû&òˆZrˆbPý"q25Ÿ©TÊ&ÏcY×ä^  ÞA½B=Š¡^ ¢^‚¢^jò½8ê¥Ô‹‘ÔKC©wK½L½oåY˜•îJµ.š–³£F°€Ü¼Ž„Gzí›P¹ž4…V6¬Ü5 ÔKàÔ‹ðÔK\Í^²wÎè?–bÛüØœ¡¸ê¥TUzp -có¨ºB–0wqYC럆†èg ù{Ö ®Xfæÿ=ºób30T/FTï[ïÛš|iP½$lV½®zûÀê%jåÛ©Èò°y1…®KåínúÙÅrÑt•— C†(°z4¦t!àΔ É„ûŠ2}üÛ5`¿¬ßìªE”îÌmËù‚ã-…ŠûÀ¹•êÕLÉ­ŠœË>>VËcc0îPyí÷½ÝÞ[yµT+›ºÿb–lc- +ǃ?r)’ðër•˜íÛ`•--#‚¶Q¢EÏq ¶»ç7{Rç<®‚7\{íÚæ,=·¾ó¯DŒRè±i¥Ræ“×bõqé±8˜ŽòD[W’ïQ<÷Øl;OïÎ965„âýˆ¸öMáøû8Eý¾CŠÊw¾¯þâ}+¶(%é ïg,~ˆSÛÜ6cë}³7·ÃY0ŒvÈý¤R@„ M¤%«¯Â þsŒÜ´@JWez§4B˜pŠªˆ€çxê[i*u êÈ+<°ˆEr_Úëè„ú¹§ç¸\cǸ!¢ª“ü *0ºófTPÝüSaÆËûBàv¼ÁØsŽ¤p¥Í­#BèÝ…¬^•@Ù%J =ñ©’pßÿÊŸßSþlòPÏ;oÞ~@ìüõRñ½ƒb[¦Í‚W…—¨tëÛC Û÷µX6 ¹\|ûk 0Hæ_0¨=·ê vx´?P\¢ÅÌ’:»k:+,Ö|sñ6!Eˆû¬n óÆÍFv3ÛÞÁ.a;ýmåÒÝT›=2ƒúÛyk&{êä€uL—˜"6LÌñ*¼$A%@â +U‚'ˆ˜W¿¢\û ð~¤*ÚéŠDn— {"uˆ?_=…¦«–ì<Ú:X,½ÛK[Uܧ¹[¼Ë^N\«µöûêž½ªA:Ĩ­IpPƒ(ÿÕ ÿYä ×ÿxÙÿI¢öE’ãÅ‘lÖC2×PˆÊñ¢‰d ‰Ñ–¤ÆCб¤êP’¸ó«Û¾Ñ«ô•º´Ç¤ïÙY¯ŽŒg‹°ÉçZ¥¥„º/•×9,•·:º5ŒZ¬“É/ª(’$\½ùœ”\Nßùißä\¾×–Г±$ "ŠkM *à4q2ÒÎdx%¼ý¬ìÔV±5™ºÙ »•B¶ŸåæŽ% ©!WrõÅíܺÎÊ¥v²66„zù— L ÛG̶06»˜S˶¦³iáxxStÝ—Úý˜”1T„$êuÈGñ6r ¶¡¤%¿Äè×úœý’’_ò:w‡ýP6­t)G6ÃRÍúßZÿaÙ‹*ðÅʧ©+a’9‘¸Kg0ˆŒ–ÓB!†óûå-_¹%|…ëÇ…Ï=øæl8‡y«~Õ·]À D¹¢v ÞuÁ.üåìjUåž.KšÐÂùeµRñvºäñú–c)²í¡‚ÞЄQÑ!ñÊ¿ 3û$i9Žr¢@4…ð@Ñ$pyÁøAQ¹µl¥Ù¯ù7jÕ‘4.¿lJJ^R®+ZðT;ÕÛ°¤¶Š—á®´¶<Û¥Ý2è[¶×ל 1ÞêË1’˜LHðA‘t‘Ç€ ( §¨R¿D×yÄ!ÄÉ©.9ËI}\r£<_ž åàoBmV%¾PÉÍôB¾"-j ¶oÚWÆøzùmŸd{ ˜æg¬CÇvñÒgMI‹£ÿ5@˜Â1u+ âA54‘£æ©[*Q]¨¥!SUTˆyYRøÏëRöÙh𫆠Ü4CÑ,ºñ¼Áuî”þ°ò4ê+˜÷æÆܯ\> áÎz׺•ØºíA‚¾B…^µ S¯~'.Þè‚cfŸÉ ¨SÃSÇ&ƒ‡ CUÊ›Ø4 þ«×/N‡Ç$_ѧóëbv¦5š€Y®*5=5ìÊ÷5/_[> ¸e¬ì¬Õ ršk;”Û«óð ëëß ‘·û5 q=gê|T—èÄÐLŠ`"ž­–cXÖÁ©ªBT,Aþ÷]ð¼ãùËÖ“U–pÙ¹“\Éé#®Yì„—Âìm›èP5]Mß]ðLvÓwh¶ zY-¾bƒÀɘý?³^zn9>Ü/‚Æ)F¨Æ‰@’Mj„BMR¶¯"Š¦Cý¾fÆį G²G ÞwŠå\­Ø-v +£N¿Vëî «LA {¨áì®|nxõgõC†Æñ~ >î£ò¯kœ¦“ç*$X:4¨·*è +«$%ˆø÷m½ä»þêv‰Šû8m@T€J˜7Œ! +w C$³fxN©88\žüÞé‡0päÑ¿ÔÛ9EUÈ&‘dUB,±E’jê@S0ëÊÀ«º)ˆ—ÎO?ø*Ü\ºÁÈ6gÕJàãr8OºK§: Õ\«Ô—“q8Ùö Ü®¡¹^òߥÚ.X‹ò™:©|(ª(¦×¯ŽøËrŠÆ@¯`"›é<1Yî1§U D“tÓPá—®e…A±Þµš¾»(N¸IXm*‹~)WsçÅl-¬§üÝmaÑ©†ø>Ø]³HñëC ‘¯òå ˆöê Æ}Ï™¬Ñxe…Z~@‡tg,}$Š¼ é +…… +Ä«è¢AO]B¢¡Šgž_h쵘#1 “`óx9×>·[°¶‹Éá×鲋Í—Å8 I&›¾0ÃÁ¬Kv&N"&83Kí²îÑ™‚wø…Ž"$ÎÿñÍKô« ¾C’OPܬ.~ň‰h +ÏÊÑ3£CV.@(DG +2tMP¾Ú=} >†îªe¶†]ž+ê êúSͼ2noCoØÔ½ríÞŸfiãä0ÙöÜúÚ—ì¢}ìüÃÿã½èyu¸_#a´™œ€Þ€ºÉA$H†ÊÖÀtðÚ,ŠŠ!ä~ß {îÝêFÁš¬.çSÁUu¯nòîeVÅ——íQsØlÊ]äÛûk`Gè¶gœÐ¹èª}Œd ŠÂo?€•×þ5`ñ"[ë\‘L}ÌLÖjLSy@=SÒ4ãüV¿J#%*Þ$Í.:dqcºÑK‘%{ºf¾Øo£œfö©ê # «@1ˆFÕ“`ƒ1‘J ÆQÿRÿjïøtáÓ¬ùí®ßHÃ|(Èb³åäé‡ ûá¢ÛMõ¶e«ã]¿hmþ Üž‚Ò˜ákœ­¢Þò åN”Ô`­ä3 ëà V"8ê=bÒ—Ú„¥ÜýïÇy²RDìkiUóÂÉÕ Ð7Pub"¿ÛY˜×­Õ(/¡?n`sð_ V;2¯¨ÒGü¡3Ùæ°É&͹£l†þz¶…ñª që°ðÏc-kÊÁé +ÇÒXƒ“ÛœÍ÷µUéaµ¼*4;£°”ëÌnÂ!&+*}غîZ6']}aXVÍ‘âƒÑ¾2Õ™Þ6»æe0RTÓ䯸ª¸åùAÃ0UY’PY96ÙõåÝluñjÆÂ¥¿—ÈÄ·ì±õâÃH#Ý>Õî†^Çx’Ꜿy¶•Ôõü(éïõöÌë +ÅZ‹“ï:å¢n +ÅNý©PZ¢6G+ /Fn¨vçSHo™æLF¤·ã,¥u¾[ðµlâ÷'⊬SÛ ŽOÐ/ÓšlÎœ@¾í)¼.;ýyEëËåÀÇv0Ò*°ª·/KÝ7¹S8±æwÎë‹“€I“#«‚°W¾ræNø²Ñz*GÚDm¢v“ÙG}t¦®ã%kéùïÁÔe>Ÿ:ÙŸÂe¬ºnÈÅ+Ü)Á¤ rm=¿z0G†hd«×O¥ÛÕð–Ë /'Ý¡Ò—/åZèèö)BîH³Î5¢Ç™Œ­ ÑËüí„á™×)Á2¹„OÖ;®%5žÙüˆ¯ÎÌEyÒ÷dë›ÌAÜŸ³˜LoJ|?Y×é+þŸo$cù*ÔX1½I˜™Q#Øˤv‘éPrèÌ&8c KïÌDQÑ“ð"ÓB{ÌêGmëHQ)g±ž ¯™àb’+]Ëô +ªZ¼Ê–\±®X–¶h£ÂìA»ÌëUoæ˜Öö)´$öì"ñÒßçÖJPC’ÊC`R³H’!ÍÄ:0!'  +ú„Ú§E{Wú—Cwü|Ø®K‹{®8h÷ïðåÝÓxUÑTÿv9‚9\éS{±íÐààÂzj%#ÃØ/l'QËì–TQq:–B¾^se~u€Ó+" %¶­Ï©,÷‚—Ì“}'ÈñH1 €–€dH@ÍÀ›ºÊñ‘žü›8éC÷ÊòEçZë×­~gp}/ÌÛ¸®ä›¨ÄÝ‹f6lAœ½ÝñåÄ—ßã£ÄÇ7NþÚ­Ûg"‹¸I_È. ?ΰ$ ÿ˸f:Np( õÛ8EQE‚@ eÖ³EÓ¤0/h‚lpÂù-Nc›z“Ž¼ÒWx ˺¤VýS·Ü¬ÔÍ’>i6šU)ot +j¶°ÓŽž%×F$Øå=QØœ8Ä.¶K‰v;®ZO1¯˜Æx§è‡dPЀIØ + €  ‹¦4…`M%ª ñçï.œÆ©ºÐ€âÈt®ïÇœ)6GË›®Âa£^¸¿Ìîê!_¯Z`l;ÚKNI¬ðÂïKfn–±áܼö1÷=â˜ü‹¹ÅÌï‰W‡ØÅ 6¶ÊÏ+H2'M—!Pu"ð¢Žež».s0¿çW«¼-Ýñ~Ê6ú¹Y±:Ò²…›yk©^–'}­ˆ–hOFØ[—ýñ­µ>~€I¢ž¨=vsÌ¡f“Jˆ1ÕlF˜Iú |ç\j®²¯ù …·.ƒ õ?ÓYánºÊ3áríJrëª#Èß.›Ç‰ãÄõD|?µÂ1XYI“¿"n›Ðú«9·]£¡&£1YO8zæôÆÔ ÄûÞ»øÇ¿~ËàÙµÏË|¦Ó¿Ïtüç‘ÿl!çÙ_˜ÏædþŒÖ³é…“g<™<“%y†¾ûÇß¾[ë•G²D“YT:5áøÞûNGˆj—Óç1-"Ƚïß׾˺‰ŒBáðóòÕ\ë¡v•a‡ÿü™ü%ÿù3*¶ó'`æÎ 2jŸàŸßãƒ?¿Ç—°j/ôrþÏÔô×ÏÿgïI›G’ý+ +¾ì--ßÌ‹c t› ˜c`™PèÄjdÉH6`&:bÿÖîÏÙ_ò*³²J%Y ÌÎƾÙؘ6:JU™YYyçôO™€æ8/‰çìd_Ûø õíÁJµˆ=îÃãÛé³1ÀUÝ{Iå~˜²Áù°?àdá~[CØNcf…復˜!‰µkTßdE° †«ä›'½Óízc¿ç÷woÎOªw}÷ññbüpg_·Î?D9MÒUÝH¨8Pä…¹äÏÂŽVAHfãe0„]Ÿ¥ ÿˆ +¡Rð˜{BR^壔˜ÓðKûðð¶ÑšŽýÇym2:ónjýêåõ9=ë{Û°»w‡Ûï+ÉM°ÝîµÃ6šuÃÑk6žÜžZ®¡û~­Ùìzv£]·iûÈöSl,åÓ1]ï>;¥‚€çOÞ#oãÄ¢KÆ7V†“ÂŽôj]Ü„??ª Ò1º‹~ÿ@ÁGüøäEÓÙw¦L³ <Ú²r%Ïè¦jñ`SËÅWá‚:?„˜pÞM;Ã3Ç ýÞþÉŽL¶ÛÙU­ë Ã;w·õ4¨OÛ‹»±ébýø¶€‚F€Ù¤ó)&J†J˜·oY­?üæ%Ýqoûò2²û{çW»WƒÁAÍz:ݽXÜtW·»Û_üž}ö!G\må&Œm«Äè#x†Ï1jMÞ $òI0ñ„' <^¼ƒÑ}äV&“Ä);à+ü\í"I°jñîW¢ª:/µy¤”ÓcrPÂ%ž†ðìÈ”8ð­Mg†Íy©]X³pŸ8$IÚ²MªXUVâ€Ç®-É«”±K§Æ;Ö´vÁ”¤wÖ8º·½ƒ­ãGk~|×»ùö:³{eôÒÖÓŽÕzº? û[ï3ãÈ2W/´´ø£© zyaU s¦úu²ßD±Ñ(ð’IjNÊÐbƒ¶”`,kû!¿N­‹Ó–³çÔfçÇÇÖé5û¼³oõïÎÌxq²˜´žNC£}XéÇ€§P앧…×4n§©w ÛÒN­É¸nivµmµšÍªó{iá°ùœ4ÝóAíhºã}¹JGCÿX¿l^ãÑö׋Ž~pà·æñ$-¶n`?O©2Xd¡×ÉšcËÒE«Æ{6À#`ÕºIâùTÝ@¶K™ŒÁÀËn²]˜2uœ=Åv¯w'lh"í‚9ÝK ¼ÉB©Ý¢sy¦½ÇÙ‹MU”º+é|2ŸSÖhïmΦ­[ïÁn·O¶†_ë¦õóÁQ:íL;íÛ©Öï'ýæÍÖþÓQØŸ¿ÏÐ?‹çIÿÂ[ªÏƒÂ"72.vp» ƒé°¾îÛPŠòu­–Íd ¿j[õªßh×A‚âI®) D\êk”>Ss\‹¸’7ôšpô/‹«‡ï¨ó¥Õœn;wñSäݺmëiêuCóâ4z4†ÕkR,®7&r:ÎÏ5ãk2^oœ³û´]ΚQä–´´~Ÿm‹ÁàKΊ”Ñ]Sv¼0j]¨¡†H­À÷°ù"L:F<÷r·ìeÑ[B BM6fÁ”pS•ãqÀ™€w¨ã¥Òò3|…Î'‹éÄ -ö5Þï ‚«e’£ôô“ÆĺÊ'ìE‚t ±4ŒHªh=IÇŠ0ÀŒmöVÀtñ 6ÂÃ8€n&‰§…jäc/ñÀ}%ÝÊ|Æ š!@ÔsKalB°f³$°%m“…åë¡ê˦Oèì0 *Ä-¡?t!p¸vU«^Y8 R–•³ü— ª‹<âV§ÜÓdþg€ "0ÂÆ,—ÿ£)‡¤fE®†5‡ù-'Œ#ö~P`š›ÄSÝ"qƘãD÷L›qL‹q´´uÁ¡c'¿ŽÐógÙ¦} ®Œe\1&–ÎCÞRµ¦Šn`/ß\å®…-`ëCÜš2@žWðNÇLÇD¾•Äªh;¸…þ 4¢ƒ¼ Pö‚g±}¦¨ï1… áâ“6OèÞ,ˆí!±e«hÃDýÄ.ªäm†ä„0šÆÙ7ã(dƒL=T Ë[¦èOÆÉð%°Îè °`…ˆ7½íe¶× ƒD 3,|¦Â€ëqË ÿ)Õl‹MÔŸGÎŒ2k·öy5b/jý„#I`-C® ÃÁù“Ä<;&N 我=?$Ô½Gl„¾d2‘Ú`ŠZ2ç‰|/2œÎÚ„k¼xø¾ Ûæì^¤Ú—^Y ò†€üj’í>G²ð}8:œå„Û]›pk¯€ß3d›ÛÕÔ;°\6^ñä:áïHy3:øk¼×ÒhxÐX€TùoN£¥*™Ðð?@BÅ Jß×y«Äk^¿ûÍmçS»ÉR +†è±§tv@P¢»×Dñ +º=fÑu +°dóPüT*§àÃñfÎXÚŸ—­Íy©g=K…ÎÕæaüØ9‡ÉýUÑò"ŠgŠQ<ËþòÀµÜ$„u‰üd1ÌcªÊ¼2ñDæ€â$Åy1aòM¡ŸÆñÓÙ¾_}Šï÷Žë{ÕÖe7™ûOO~ ÷/þéi¸ãïíãÅûÌ_˜Èæ‡q\”Ü"÷ÆN‘ȦäâPº®© Òfž5‘\›Z{ræ z4­Üœf@.Á_×ܯÌÛë¦)V +g+ðf+è¢Q}5]à0xtˆ‘³èé7Ñ 4zfýH2`XÒ±é4;ˆ$9d­Cx!’ÅA)Œ&ßB´B†PøÉ~÷Ê#^ƒQ9ΡQÑ3I1–’í«ˆæOo¦8O<†3“ç4\t.xr¢1@0I¼P”êåN·V*†«‰ÃôsÙ¸…#½Ð7¸xÌɈèŠ+êÿ­ä‚0B2!!&ŸTaŒEv˜û®c6q¡ûsp›sÛ.V +ÐÞ$Jà(Äw6ÈÙg×¢À&P`/*àx5BË鮫Ò]›lo$1`”º’üÏñ®|Ž‡ $Ï–þ ²ÿp"k(G£ëÍ@:^±5U'tšT¶‚¸ 4,~q1®7†$éã ÊÎÈe1ÑÜ41·Š6R”ƒ•ßòTÌ%žãUÁ‚#†aáVBUNlá)ÌíÑŸ–éæŽ@Ž¤´ÚÚÝ7£Mû³0 Ž¿p|¢“q%>Í̵ŒÕV£©_Ä’ç0TÉv284ùþý÷.¸–¹ë±ŒÁýsgrËx†n/Ua­ä,æ$&>ñ_/±ý^Ü‘0  —l5ßA}‚©üEQˆÞlž °‚Úže’—¥ +`Øhü5%ß· ÚŸé‹b­Íl­\ïZ¹Ønõ½‹XsµïA­ø$-÷¹  7ÅîL{õ“a|_7†;wó“ËÏûöc}4u·?Ïﻡù´7;¼ÆwnÿM~o7ø•W?zm¨¸ë¸ÍvÃЫ:Ô¯nùºÕjµõN½Ó¶º¾×´ï¯~ÿ¶à¿á¤µ»3lß_]Ü ìó{ÿqæÞ^xɱqt|ÐH/ÎÜÖqý*ò‹–kò¸ê¹ôÇx¹ãšA–µTÄ€Òâj›S/™)YGß[GA^G‹I<mÔfÝjtº~ÇÓkõFMot,†8·Óлf·ÓêN½ù{Em~k>í ݽ­»~Õ>¸¹³qó̳o§ÉÞVçÆ4vþr7ö§{Oq!C­¹†%œAn«¸©­l“W@ZµiMµk7cN^šfõDG›E5ð.P„XzfI•—LžvDÁCMeÑ¡˜¼uå-`>àáåm™,£ÊˆhŽMñ²‰| ]ÍÙÉÍ@ÊöJÌÊåÁŒ%Øvµ®7jnSo´Ù:mßÓ»ÕnÕpí¶cWß_©øm´åîU÷íÃÑ·æ]ëçó«Þçt~¾=Ý; +»¼=|MΆÃiÒ¹,c +*,– *‹ª”YíÕDÖy™È òd‘Èl+ œ×Ò¬j§³Ü lTMaoƒ©¸!Egaöe¶¸/N>HÝóRéöþ ¨vµçªK•>r<¢«°… B¦U,¨Ô§Àù•„>÷¼‹@R+¦P¨FbyGRv¦o¨oåeCIÍÍ¢$%“ŒÍ¦E™3ÒÝ|Þ¤&—¬TþÔ´ñnYŒg'DÐÊ+œ¼Fo6ŽùNeé®vƒ“‡›©8ø:­FUdoɧ?¥Ðt[ì¬IÍJ·ãáAupñóùÁÙÕöS»Ñ¸tj­á¸Þ¾_Ú½Îíçáx4Ù_ëKôQ?C²èµƒ‹˜e¹†¥ ·lU“r}3kzšipY"á§Êa2Ôl!’Ntž9'BëÌ»¹— ¿Í(Ñrïo.[8Ÿd Óýxh®**¹Ê&r=Z²a»›Â‹m*6Ô._ Ìe¡lS²‹ÉÛˆz)áÛ²™æ%»dKøMlñÆwòÿ†Ö"5Ãàúƒà$ä–h@Z1ãlµ<¦Æ´¢…©v¹ŒC® C?ïÆ͈÷Â]L¶U‰«Z°E/²Ü“?y{pÎ}ÿéÔ e££Þ!Ñî¸F?¾ˆÐf–_? Õ,¿"~@»Y~Eü7ÅñcÜWð>ôïXÑ,¯/;½íú#«Au'†ÃS~RÙ‚y°Ÿý!ŸKé“~ˆk÷VB¿Î{'“Öd*0Ù;8¢û·¶ˆ!øºµ-EÞÊÿž ? ž5¡;ô–Ke„ +ûô+ˆDvÒàpDØ»£éß”þ½)náÄ:q»;ç¡×˜×ïú£“ýýÎNÔÛµj·ç¦ÙMÎí…7¾_v7炘 ²Û‰%‚f£¬…gù]è„Òµœ¶ÞhU ݪ{]Ý·›®mÔ;ì¯4«+Ígª×±âiâUxŽ‰)SƱCeæY¼0l¡t¬±Ô0!9ûï¨${ò¥¶3~샓oÕÚQ÷ñò¾:6÷ýñø- «—wº·í{ +v—»øÔ‹"-l쎲e楙pÀ {a(„ºO¼F4WˆbgÖÜãYÕMØÕ b"é’Ÿ7˜¹ÊÙ*ÐCîA¼LCF2EC é¾#¾"Ž¤¬ö™ü° Ä+íj¬Xí@q` «=ÒY±Üåâqqpøò,¯QB¢ºMñUùãN)Ï‹*æTÑ#ü ʆhŠšœxßTpŠˆª,XË„“•Ž+%$È…:rfJ ÓÄC_‹Ü)U1>ψ$K'\ (¬xˆ!Z\%%®”­¯Á5¡åuÉ&t·L/¢ Ȳ'ÅÁs´§Œ“çû_žø¨Ä,ÑDâãe<óƒ´ày(žÉƒÅÁ6†ˆ¬Êì ÝŠûÑNþº©Ö¡9ájǘ!†ˆæÄ ø´BÌæâs&FÕæ” ¸j¥N6˜µRGà ïÃÆÎÀæžðœÉ +‚ylD-¡:aý ™*-#eľk „¸¸rÏ@%ÔxÆSx)UòcùJ3$”|!KÓÎß1s6¢ÜÓØ=Ù2˜Yá ¹ Žîæ'­Vý¤ìß&–½¼ücùTê+†§rxÕ5`§úFBٙů˜jE°!=ô]I[&DuHÜ&•—=ü¯¿ÿƒý_0v¬íK×ÿÉþ!¥¼0PœZ>Hj›@¢ÀFåMÎ^àêM’žK_&êå$¸3êÃÏ®(NnJÖ±«Ùã‘^é8Y„%Ÿ9óÂìlSÙÔxA0X®ËJxã”[J*9Ò«‰R2¹×¾óo+‚Üz¬ËE±-W|‘Dˆ’FDÈ”øÝR}çѪVr3—ÚFM(sô”¹ð¬ÄÄ3¸W«\o«Rk"[ª*@9æ„PïÑñ¦¹°z- +7/ó@”\äÚ&ûòÜñ2KW}WŲ,)ÌnYíƒõ@ö 8/³T3:Oé‘G 6ÊaQ1ÔƈJ +®£²)DµÜm3SšKkrÍ¢ ÿE~@»lNràú !·M…µËDæ¼Â˜—šEm*œ7vQ«ë~ü`bŽ•Š“l4S¹Ÿy¡!™—O”lTú1á¶à2“¯nS,¸D7+ÍkAE$KZ4«¢˜¾bNT-B>ýœµü»ðÛ0õ\ç®dèÈÖy/¯¡PBº)Ù–R1ž|¾”º”5¿`V¸–ÌdŠÌ«K)3¼Ù'†³t“žÉ›Û¢r.Áï¢`§N¾NF£¢Þ&ë¨Y+µnÈ{+¬Mjg'Ŷ%ãF“!"WM5!T,›6DæØÛ-„ÂŽ$îí)0q½?¤âßG*ݲÞ,øÖV› þRÖÀK8‰rk#aã‰Ã³”ÈRŸKlDÄ-”°‘úG±¥ÒaÞþÿïãËáç ÷¯ôèøV­S5º5Ýj4[z£å:z×q:z­æ7Ú×°ëÕ÷‡¥-7ó“ ãܼ?Ä73I/£àf¼QØÁ–¿8¶ìãÛpÎôæþN?ØM³Võ¶zèß.G¦ShíǾþ<Åñ«…-zHå6MÚ+dyèž¾D.†}OxM2G7]Ós®óÜÄ_tá¨Åó…‡}¯SyßË|8K-ÛÃòK®˜7ûyÖõá|“££z !ãÝ~Žð/}„é9ÕJÿÒ>šßÏGÓ|ƒæ0”y Üÿúû?´#åšâˆ`OB„#wEüáïyÞßó’¡AU^ +¼Gàæcì %†k<žçv¡0Ç*Z”òúÏ‹(ìH ±h™– 䀭âO…øà ´¸‘ŠŒòp¡¬OÙ•ïÊŠϪnÀ>ÍU’î(êPÎsö<¾¿!0* nÔ•B$œ7µ–ÌÙLŠ1yØ)6²‰’d+Rrùÿ¹STñ–Y*.˜ßršEz(Ÿjõwè÷ £Jè51œÞ¼M°Và#Ší­h‰+nƒz;§9*[ReF/ÉŒªIEâ7ò~äbûÛdÓ +¨-ûÎ(Q¨VHM£%¼ƒxÉäMsmú5£ã-CôĺeÇSÝ*OŠñC|®2”çd-ÐR²³gn¬`a¦Sö_áÖý?öÞ|ÉqãÚžˆùož‚Ó1¾v-$v´âÞ¸­ÖbÙRËV·d_' QE5‹,qéEþê‰æ ¾ùï>ٜ܀ÌDY ‹Õ‚Ã’ŠØr;ù˳þÅYŽ7Äœ)ºëùÍìÝ5SìµêèbCPºÁU²¿F{Þ8""vÔ—ó÷úµÍ®l\Û‚„K¶ú,^¶V‰›¦‚ªó‘K6’yƒg´†;5ªˆ,óÛÍ|£h†n@¸Ð&™¹.Ë‹3cUô›Æb0Ã/LÙ­`R륅iT¹Ü[Ê4®ß’™L_×:á‘ÒLCw5w®ÈU¶å×·ÇpRßcDÛx95h|¢ùÄjQkÔ(%o¿[Ó,+k³ ª’01[ñ”ATò2}†T+Tëÿµi¦5fgâFM ²~%ëUu5Ïò-W½!ë7°kŽ¿8u»²Žptu¹“.Et᧯i(9Ѭf\‘¨éa9ìØ›¡Ë©Þ7úù-Õ­Zm +²Øâ:ýVí±J/ë<9×,Óë< “bô‹ä^¦«m0j2«×<~ÌßÐü'‰‘¯ñí\]T)hšÖ—ò3Lf +®›JÜþu¦y¸Ø8Ý—J>ÃáâÊÃEáHÇSe†\vúó‡ïfI_eóãÙ3ž=ãÙó8Ξ{`ž4:Ò¸ëÍV„2Ç‘/Ý-™GEíâ²PâõV.7ÎŒ§êMOÛøUlT©MëߦªÍ t‡†¶VsÔi±¥Ön:Meïy¸ÿïµFGxDàÇ„À¡–nÍ­/07<Õâê=ÍÊd¹PTìF|Á£P±NõÂw +Ï+ì®´eõÞíˆ F%2Àm‰ ˆùÐ`Špܦé¿wÈ€ÞfÓi^™î£üC¢šrx¼ÌÆžMÓŒ¤O„Á=EÔ6ͨ¢§ÖU£MP¸|ÕhŸ¨£*Rà 4h‹Šù˜lhÐPÍJcVm=>#ÚTW0Ì ¸WÚ3~»ÚA•¯ãOÒ#wx\,u\Ìøß—륮Å6ëc:F©î«šÌÖòîé$7û£bìê Yέh'à|Å|xâ5Ë–LK—-WºSm]ÓôéÄÊ ".ÑU»cUI ´^0GË«Y5ù5ßÓoê„îýü¾É«–µÇ—l»½{áö +ôæöO¯Ýw׉f†.-²F˜™!{ÄxŸ©ñ¾88šñ„HQuoÉÞxãÂLú£ù=2ë)š?›éS8õv9M¨÷)#þoG¡@*Ü“å®òM5½²3IÇ}·ýðcó²–C*è•'q®SðYRÜÆa©ÇzÊ{Íj§‹Š¥¿:&êEfRÑò½ã^2DHŒHáAP:—ÇŽ£Ôq1JK7Î\/Ë÷âËÕbD!|ð«€™›Ìûìžñ/ü⻟ÿüúó¿‘Ýßn~ŠæÑOoüö¦¼ž¿úùå¯ßü]¿X}¿Þýã ÷K#þ%¬ú%'´Á£3Ï{zGIb{Xð %ã‡&*Kª~öÉU–4‘.Á€‘.¡%M±çb:¡Ê%2bZÌÇ1--q$æ{uÉþ`ŠÍê†4Gé–ðjŸó~Ê•»Œ‰i6&]‡ñßWý`÷W}ŠØ”Α¢ ¦5Ô\@q¶&>Pe+¾¹ê¼ª•p7_.æK¢nQêA|TÿÕâð×ô=™È}lê‹[YBIFK‘ü%¢­*´-kŸ›×4.·Z ý€-ð9aÇ5`U†ÚM»1S6—eâúuËpfPÞßÃÛäyÙnCÆbç*¹ÀF3Ýg—g¦“£˜éÄðG3Ýf:A÷Q˜é _eyíª5½Žwþê”ýݾFˆûCÜè‰0Bœ qª¸ã+.\3|%­ÊM7.xWÕ›é¹zþi`ÞâÅäšú&Kè ÅÕ'}õIÑN#Ù)U ؽ ¸æÎû&ž íi"Dd-ó¤=—ewɽ™(¥×›iÈv—WÙŒk ™´ÙWs]«ÎŸçú݇žãNîí˜é“C³eß·¨FU;-ê´Óê³ÿ¸ì´ûŒžº–ê˜^®ªiR ³âè4ͼûMµƒšÿüÓ˜U†O4¹?a䱩(L4‰ªÏ›Ú~û®@µÑÕJØß WßÎ7@ÿò¥ëß‹^áÅBÉŠ`O?Ãã€?jÊdvHŠ¸4ð]?#§(RâE‚Ä-Ç+R7JP™+èå$)âÃؽÕ?~ñ2õ~ù.Ê_÷—Íßý¯ÓŸ®7?~½z~ø‘D³×Ÿÿ1Ë—ÿøã‡U£hOKF8¸ó¢¾sHÑžÀ’Žé¬e{6…µ˜¦LÂF;½ÒÌ„f6âùÏl¢"ÕYDWz!ëYrÉÔ:Q®³r·X4ç¨Ã<N2 ÷±‰ËC´ÞÈ[ÿù&_ÉŸÔœ+“Þ·ðçä?ÿïcJæ%Yªa“y O6\@ˆ‹7b#´]Uù_õoTÁP8¼(j®ö£+WÝl3E¼Õ~‹àj²%—`Šõê¶Þ¬Í+f€^=N=YûìÕɸO–«îÔåæS\å/¸‹œ,//±}òv=_Q-Ÿ³ÅØ5mÜ¥Nê…ç–³N¬'—G rn˜vÁ]5õr{ª#þ¥öó¬9ãö³ aá­Œ‚äœt×›ZiÕöâa†RÔf'½ë¶{=¬9©¡à²3a]%t&éyºLFí„ñª*—¼–×?Æ%9ܲÑŸƽ꘴l=Vûô!‡š;±WéOÆ|l£„ôÇïññddó±]j>¶=§•îçÞyÊH™5N%€©>e„cÓ}ÌÈWmÇ @ªº¢æݾÛ^W¡ÝËÑe«Ö—‹Îo=f_{n'i]{þ²©C0VXSÇÝÇqÅXÆWªŽï£[Fe©t/›S˜V¸-’‰¼ä Äo 3÷l»ÞIøË.[Ì7×BÖKÍ'K¼ØT´ñãò¶~zŸÏþ= ñêʃœ:Üv÷ÓSãå£r*>zŠºÈ|qÇÐ +dÉ7gˆt»ò͵)/YæF™f”iF™æð,sGgã%ܼÊ%(gÍàO|FÚ>a8òÂØþU¼&\“Û]ìXk×Û÷·ÁX6Ê™ÃXž'/VsÒ6ª?÷ iò,‰Æ)ß;VZÑF[:,mfNˆ…IˆeOq‡´mOeE÷vÅO6}fpïT<Ž WÌm}V—í­ôѦÈЛÀï¨ã¢åƒ Á¢ï7]áhÓp_9DÑ÷ËËwºñ‹JÇgÑÍ ùÆ„|Ã'äëȤw/ŦiNaýزõ&blDP-¾÷F£&1«“üø°`ÜúÇnýÚV~ ü’ÐØþFÀÂ`¼ýÑ°ô’˜ê-Íc[KsMÒ6æúñaÁÈ JŒÕqð QÒþ$½zpO­pÚÈ©ãÌ)â8ë®X£+‘ˆ®ä¼»\Ù+’²%½jkò-i®5Ç®™q×úE 8F|[VlÄÁi¹è´n®WïfâÃóå›JÐ\uÇ ¶Ž[1 ‹ùË/DêG—Àëû}#S÷åÜŸH°%¡-¿`ï0ÈøaŒVfìù³PP6‹ždäCÓ^l7Ïx\ØüY$6Ì ‰sÃöWÅæBõê<_­˜O% +uzÚÈ‚&–ibÉS«ƒO;Ú·§’5²Ñ:,¢žVZö +àLcØt^R;áâàŽRe~K¨ãÁ8z3u²ã}iíÉmÑã6:qòai}ËAÅNš‡¡Dnæಷ,ü4Î=/õ%yñ±ÊÛ/ò›_¿._]éþýOYùîO¿~x-þôúÇÿxý=˜ÿ´K’¸¸Z}U~qe$ñ *f -‡o „们Åô?‚å§ë²÷z2ÒÙ{3Dºm~\[åÛ6C%ZÚ|µ-m~#¬(z¾¡‹<Ûü²ê{·žkî+WÎ 1÷>éJe¿8~)YX¹†ß3îSEnÃ;Ïoo¤ úב߼‘a»Ípq`¹äî4Ü¿rÉ÷wéãbLè>!Ïß>‡’à ¼îë^g”#J³¯Ðãð†YIJÊê$¯ÈùÐ|Zª[R«#øNÔ¡0a¶díÌe—•ò¶•CÛƒ—¨ULÚþg{LÚ”ª}d¹×‘̽~Ñe¯ÏºVÃø€\O6=ÝÂÄC!vc'‰|äe;8wC'/C„<7õƒ\zœÊí€vw·ƒõ{ÿüïÎ7}ó§ðíüù‹¿þ°zûÛq|ùáÅ" Rÿëg_{þÒû«îvòN4’}„,ù5¿nºð^[™XÊ2š>Cìû‡zx¼™ÿF¡=¥ÂuÙÛÍÀ¿l7”˜‰Unlò;ò°Þ÷ö6Ø›'þ#t7 óÍ9ªöSKžxÃÝ ÚWóJé»]mñ‚½´±xªIÞc“ËߢSÜÖßÚd¡ß™iÎXßÐ{¸7‘.ŠþŒ7ªéáÔ~2Aî'þ²zdÛ_n°2Çù­¦OiTâL@|Ÿ8Oôù®åüŠÆE/z<‰ô¥P4l"ýDH@åÜ8^~IWWûÏ›y×Ï ç?»³è×%Ïš¿Ú2ìSt_í*÷Añ·žó^è¥&J6æùr¥k ~yݤp_%J¿!Å|w£v›Sâvû¨ÄùI¶Ö«÷³èK¾ðdÿ²›5 ]àò[÷ÙYÓ½¸îé)ÞëóLSáYD-CW¿5zíðÅ¢iäy­ø|IÍÁÅyí<|^°#S®Úõ¾•Ö®÷e4Þä9ü†¡YåüÙGÆ«g 8øÜK\¹…n†éfØq£›ác¬r3Lòpbٌ΄š3á°G…ãMáÏ mç…Ôq^ÈWÏp^ÀD}÷Íw_Ž=.þÐ}ÓÇC£#þF$à–²@NƒüãMe’¥Ò󔯞‡m~%ZpDÀ!JŽXÂÆIÀÜÔJ{}3fï¾s¤ËVaï€ò.#ðÀ7ßEß½ +µ—#‚;¯¡ÉÕZÖ»k¸±$Âì<,ô6«ÕÆ5yu“ù!Ы¿yŽÊ34)ûíb…‹ w¡xDà%Yè‹:Ù +›ñ挕¾º°V…Á†‹€l[¢«z€íY3o° ¼ÅmG´ÑvDÛKA[¥®¢êéäë¡x†‡g´®@ÚÝFø#«=Rßj©Ý[Öuenï7ý!–VöܼV±'W¤ƒ«š}™¢æS6#ŸþóŸâäînà€G¾‡ƒ‚Ï`v«‘Ï7ó:Ì%e+$¯qªbîù\¤ †±É8eÍ–×?‘«#4 ®ë:Ù8íƬÎ1Gg MØ.ý¯ÿÎPŽÿزo,…Í–»²žç¶´A2Sukf%¹lg1# GÀw¿xÿT…Šè%i:´—*ùJù¥JäTv•*1:Y©’1' 铓䈲E÷ð²ÃÁåú]"˜>IƒáÒ^+(!\#Œxp°SŒfî]IùìÂI\eÃ03‹·Z¦y9å±lÙ£yRÌôJÔÇÛ+÷.3’n ™ë¡‹›Q©øåJ¤£:c•wÓÓß7Úmhh¥/ÐÕTÚ¹Fʪö¼R2°~–Õ2xd¯†&.:µß³Òà…Á¬É^‚ºÈ”z>×E¤¬‰^è·¾2Ò«’J«ª‘¥VÝ¥è`[92–°%“dnC’j¹Ê~6*¥õØìÔѸu:»H­O2v·<¸>þ}ïö®5øÚ²Þ=¶¬×¾emk¡nW¯}»"5}Q½+;Ú‹»¬ÿS?5¨W÷ ’ù=5•­ºÄæ¬t“5«dÄä7”€ñŠÏRÏWƒ’J­«JÕ3"OLeÙ`ëuóö]w}˜ÌB¶BFcf¡Ï.0³®@¯‹ÓÞ§n«@G- B 3ú f\tS£‰zR49Äžj{û}ó2qÜÙŸ¯þê¼/ýòçâï/^üúwñMòÚýæ/_ý-yõ ‰¯ôªw4Ñ‘\!±3TíÊWê=íì¬zo=Ëý”qâUeÖÛõêfµ%Åd»šl¯É„µÉ\:-‡ZˬVªè°¼E¼¨x¸ +utïŠ +u•;ÚýKÓUžT~g¥à*Á.ÃéŽË”ΨãrµtÕê%N2B–‘$eòlŸ +jÒ$þ>ÞÓÝhÝÇq»!Švm®ÞI³úð~m~ì3 +]°ê}W³Ú-ÝížîÎÙjùjÿùjýÀýfLÁÆì´÷¹£± Ð(¾l¸öîõÑ×4-§WH·¯dÕ ê¿ö7²ÈW7„¢áÿÚ€øýŒÿÛ6‡õ›§,Üï<¿½]|¨géñ×m´©Ç”“gQ†Ö¾®-Q›ÆþX¨Ã᣺ø)B²V¢d²B‡DYÝYƒ¼ìÑdwf„›kìÜ|è9>5‘ÇT¯ +DýF ìªo?`4-×À'þð‡?|ûüå×?>ÿúË™²÷0ôylol®Ûû¶ØÜvT¸w0î¾sdàèÛCÏ…1vˆ@ZE»s hÛ´Guêr·¼Wƒ8†dŒúèŠúÐôkÁ=clt¯7òÖþŸÉWò'u­Æéó-ü9ùÏÿ+Ï_vX¸[ÏŸ¥’€mûv-u½³žÉídQä|xLi'¬ÒÚ!êRc(í]Ò¼ÖW|°bà^Zi°7“¨½O¨…i¿øòÕ‹a¸Á“p:¼×læHz´¤%oÏsnûƒ xÖÌ2Fû’6òáG^îx-"¡ØüO‡Ýy ^§:Þc{n”ó53>Óqµ™©›wFÒˆzãõÈaµRÕOW!tUŸ-¹ú€š»†ŠÛ =øo¶dm’·ò¢¯+Y½œì&&j|ò01±D‰q»yÆsdƒrd>ÛftIHÑ'u}ÎÄBùnËLÓÌâ­þÐwCuw,^#lda¥'ÝfóôýÌè'¾¦)uv퇾܊`ÛÅðmV7–ÓÎ;ŒáÛ·ï}6ªf3~·ŽÌŠ­;»ýp—jek<é¯ÑÒæ¹/Ç3¬Q×?tuî$od&±Ó·œpIÅê‘qäuŽ‚¶]ø—δ#¶\"Ø cq¿Z¬6¼þÐÓàžc\HFNîe±”né¤×Ga™{$I|Ü=Û îRÚopGrQXöžpØÝ<¥)XžÞ`J3g±š;·/¾úþ§2ÛÎf?ãÕõWeùúÛåOøíuðýç/¢?ÿðåóe~=ÿauóWÝjN‘L̲àñÔ¬­_+·°™{¾f3ÿd’}˜,Èhó¬6rèX"[Ùo!oaNös5}x#‹Óe‘@–í’C‡s[eÙ„k ñÏø½¢ÚºLƒÑ Ùß ¹‡_;8É‹5ùµQA÷Ù° +:[œˆéçàÿÉ…fŽ]ÿÿyî›F©¹¨Ü¼ —;C‹újgýÓ{=À߯eWƼ1o Á{4!xë‡$þ¬¨ø`Ô9¯î¸&Ä£M°é¯ª`ý|·½n ö²=»Ê©µ•ã7å}©’>ÞKy;BÞy#ä=ZÈãJ;äU€tw|éÇýü\ãe-J‡Ùqw·Ò ¥£Ø1uߨ™x%Òû''¨úvB;Ùµ#ÔŽPûh¡vŸõ¹*ýh±»¢†<ŸBž¶'/Е“fê$ Oͬw'N@áRžNJx>J÷ûDÙ‹m]k»+¢YÌoæ[E /ní(-JF\?úÚê]­Þ®ñrC»+BÕòö(›H«Es¢t=²KÈ=ZV¨gj†*±õƒ¥-¸—Ä·/ºwÇ£{3ºwg‰îÝ¿#Û:-Õð4qØ•¬€ÕÌQßeWGiËÈ '804„úߊ(¬ü©sŠFµ\¤ŒIŸÜ¦Z²%S€5Ýü|Õà zû®_ŠÓºéžeu,kž #Â~Ÿ{CG„} }¾îˆhc³æ °žãªK3ß|‚CØÔ¡íý|¨jÖA¹bTõÑðäé5Æ1Œq cÃÇPÅ1 çv=lÑ—N!šVë~Uaèdâíf‚±µ+—VËAbÓ^æyõ`%ö4|2˜>E{î@OÉ{¹eÔ9 ºÌèÐ-¾€ [¹Ý®îJîB+9#¢Œ’/(˜*œÇ£L©-F¤ßÛRŸ U8µ‹R~»K_§°Iv–öÄäÿ›ŒòïIäßÇ,âÚÜ´¡ʯ6nq¬Í—×d=¯çªv”ϼ#‹Ú„÷¹ïõM¶yÛϵ`¨sM18N¥A]é0lJˆK +• vmΧzåB³Ì!mWU,¤#…ž/QÚ¿_-W7@ºÐƒž¾ÛY’Y$NNÒÜ J’8i„‡øA/S7³HÅ÷M–Vùn™Íoqæ¦vF1 rïåßýõ‡ð/øÛ¢üêzûÕ«~ùÓûŸœ/>ýUø|•ÿùêåsïÅæÅË—_foß¹îßüº'l=Li™>ðZ<0‘h¾ÞÚX¬Bb|!2hÁgWË+Zjc»šàINÖ[Ø—“­ÚÄè¾× ¿ì@9tæp¿75}BûÏl*SýrŽ_oFú£†?•ÏÀ\õÊð±ú·ºJPTo«±µe÷NkýÞe×8@­q`ÑzÎh•!¶ceˆ††ïA+CQ¢KÿÄÜ«x~˜ŸŒBJf%óVi¹iLrüœù°\`ª +7´# [ +ßeÓqÑÝ^3=á¼_ÙÖfb#ÔŠ&nõš;ƉnuE3k«Ž³­,D¡så‘9sGcîh̹2}³–¶Mîךe³÷+_=Èì©¡%×ç¡Û×·¯¶}ÇÝzÔnU­=‚ü\'Nѧù©qáÑ­áKçì¤ÿe…Eíêжä–ïzÿtQך‡=åÕ”‚šë¸êÿÂÊ¿›Ùsvƒ:c(%êìÃ-¬ñ¬fj[¬ÓDä””áï{ÝK¾Öi…âÇaâ‘ìr¥¾nØf{¯ÂS•çé4KQ»Õà¹ÓŽK¶7ãÚåy –M±ÐÒ /wú§t§ú??UfàèÜn0bWŠ¿™¤kê\T5­3$4¼4w¨M©lݨo–Šd™)*ã-Àpá–¬€—ìé1yeD +”;iQDN€pà¤I8^Fâ¸ôp‘ÅAC¦;Ôc@ú÷Õ¤Xü·çÇo^|þá›o¯¿üê—Ïÿœ’¯nÞÿñåOÙ—¿x¿úÎíÂÉßýíç/–¿~»Œâo {~,R=¨3fJ]Ðá¿T7̼mU|xhC—V \ ÿðZ…Þ„‘ Õb/¯&ïæÛëɇÕn=¡YC5æ·äÍ°Zów½ëž5\Û:3¬ê<ÚæßßžÏsŠ dO=§ý˜°ÉlZÅQóÐe+t7ˆ[Áƒ%J?•õ>bu2¥/‡Å|/«Í~DæûƒK*„G›ï•³ãJŒÝIKI…c‚CÝA52}â$­‹zxj"לŸÙnñFHÛÕÀ²ªénµTâ¨LÒk›Ý_ÿ¥œÆ + À“RŽPûMá±X¯nkø2¯ÜiA²­C¯ÝOYÄ[Æ×^ZoÙEMËi´í–vy%»­ÝÕ]cŸá€sªgí³Æ«=Ž.ë0q½mmÝ„ Ë£MÕUK·&(¨Ü£¼ÉZ+Š^SíÛ¼†“ý 2”÷‚×7TÙÏ´Ó'K¥O}m*ټƬVª3fµ³Z]rV«†Ã”I:Ò ž!sê€ ¨åÇ Å‹›ÄFAìQƒ˜#­)’ï•2à‹µz¦‰%§²¤É Êr%ø­™H«¼]ï¤Ìû<ßJ}@j>VâÅF¶þùb•¿~·£®ˆ"ƒ +óÒœñÝ5!Aºk®õ)‹…e|&> ™Úþ…óT ùAöhÄãG<~,x¬e¸È°+Mg 7>™<±€7mŽá‘€£†ÖÜ’ ¿% ´—i¨<Ï *„kß1WóIÉgbĶÛFl{„ØÆ2ÊÈ23|µjc+éƒån»[«u8`®þù’½Å• áo¶{ÑƩף%žDãßæ3UHl¤/iøµ­þ¿~ùLFæS·:Bï½#ôþæ —î” +X'×d™W’°ÀôõúIÑÎy`XGáSPåØØ$}ãžóÆ[gb¿×qxÄᇠ‹x Z-=K7Ä5Äü¸zZ nF¿a¹ZÂcÓŒ~#²¸aF—¯žáÄ%ÖÐk#¬}|°&Ã?¬QÁw1ÏßpWLéC©g +QyPOD×ÌêLî Zç4çˆbÿ&];õ¾újFëÕ»À‹-žU ëeh¿Ü,ŽÀ¯¯É[€I%-G$ï°0’QvMjQ­¤©ÂmUé¢X ï ßÎÕ”3—\áz3•U$áòÕM&*+¡°¡:–7û¥‡Ðß8OÂ"‹ç[ofÄëN[#ÂDz¤­¡ÃCÙ<ÔhdÏK|C&«õ„ÐIŸ¨Ýºwb›fãÍ|/ +1 œç¦ûä¹á©Ú`™?ÜÈ°-í/‰jÆ!wLŽÓ79Žæ5.C­,áG +GWƒ€5·NµcÛÍæ0<6³y3Ýÿ#TÒÝUy[‚Š¶u,TŸ2nÂZ[´âÄzUåld81æÀ;8ˈé6¬ÌôRüÁ† +ƇBãå |uh Cù‹>ö{£­Í&)7g}ălx tH33ý‹žøò‘ù7–ú²¿ Ñ® »÷žO匵nwmNßN·nlÔsckþë‡losÒÇ&÷hö!ô™¥Ø¶æÛ{ÜQ#žåkªÛnßϹ¢†DWžÏc”†pTO\Gºnûc‡ãUÛw.ºÜK€®g똵÷R¡Fòï׶-Ó™ÿ—ýÜp»Æïì:ËêæQî®ÊÛg“@þ»±wÎú¬p’ò¦Ï 8ú^4u¸ý.äqþgšîåy>µÎ`p£€’¦¥,’ß0E\¾»­ÞÒiªu×qºŸOë]ƒ_ü‹š<²ÊkÍ7õÂW™åw&ÂÀìõ3*µ½û0ùÆ]1]²È ·î¾\Éü—ø-ž/èžÚV‰e½S‡!Š/Sȼa™+ÅÆ ëk3¼xÇR,×{ºº%ííÂÎþD¼*K6&Š¶ðÏôÖi_}«Š÷uÕ¡Úº¹^½ÉD©5£ÌŸæ샂D‡§'g“•o6Mãº/í–¤jˇq¨P`{ù…ølý(â¢P}ØÔËáÖùºÎôÞËÇen9’YLeRåXìÔOùût1çyÛˆR UV’–ßS—ŠCoØ#QHéó·²!ßšu¨C1*1“uÑj©£¸ÓˆCú<3Ó¬*Ã‹Ú +ek8ÞR›÷;>"JO4 €ÖFÛ|2alÔ'lÈ +‘6*W¤Ž3¿Ñû*Ž»tÈìÙï®W³ÍlIÞY³ŸÔY³§,mö”Ai¿‚ ŒK„\'Å~îA9IJøY–EbœÄhÊ7÷´J=å1cÊ©`*´2ÓJ]Go« ´§Jí©HB=­ ›OÊ”ÍßTò'SžF»~¥ì£DÚ¬7W³¤|õ·?þãûŸ¾É>ÿxþâëÍ«¿»éÛÝŸ®yóÃÍËïÍúÙ ²¿~3¹´§OÄ|ÐW38å”>åL[uÿo׫ßm&ò¾6ml÷MÕíÇ?Hûø +vÂf‚'ã&«r‚ ý"_Ú¦œ¬–샾bð6ÐÍ”³sÕTL+4U²kOzNŒ³ñ4»Q}]ÒŠ˜{~#yú¾n–ïÖ©’j[íK}ÈLSÆÚqecò•O0«¦çÝ®(Sž;S%õöԬ䴬±‘ŽROç]‘Âó -£_#¥wE˜4©·B^,­wÓš1Ö)ôª}ªg•f»(;;RNëÊ1¬g4´¾ª&éLQŸÝ¸©HQ;¥ù2[^ ­p§©°7*¿•è™7USËUÀ¡Üåý냡lÆþ‰zÊ+¢¦#«D½–ohÓP%™ªfN·Ó:¨+¯11¯"Lúråë®íNéÛ>­Ùà)ó¦Ö·¦æî­}@:|O×TqÒÖ>L×qªšÜ§ŠŸ±þ…ÚÓ¸¾Î7^-¾×3 |µ_Í»T£›"ÆëKÂw 0ЛUÄ hò•ÛL5î'ÊýÚYÜx(mÁ.â÷úEƒ¢èu”Æ®ã"ø¾÷ŠÎZ×¹>â‹i­õÕ©GZ]¦ªÙE#¡Ö£Æ*>Ôw=¾z3e¢j„×fOçÈj£DsÒ¥1Fû–´6h¥¡FÜ@y”›dêýÃðV³¿Ô0l^¾“§Lm˜ÑO™Ú4£Lñqs¸o>¤¡¦Â@º¼ÜTSïGf¬©^iLŒu +¹X£Úd*ª}¾àcð<ó‘™òmÅn3U 7SEhhâBÑ÷¡ +*nòÃõ‚Ó­!L SÕΣÉyõÇB“ù•3`}Vòò‚ÿòÕ …éƃVZ­×²2)$FÍAS;ÿ9Èšê)µ–·íŠ^ V¡Fƶ³©ÊE•ÙPÊ¤ê µQnÒƪš®êKŠæ¡ÖªÏ@H×NUá´ÒÊ«•²pÚ¡-œ*êBës•¾pZ•à“¬j§B8•ºÀ©RK©t!™q Ôëj†¦{u*¡|Ødoô¯´jTš+ÜPªm¾]©¨Êvׄ]vóy¥ó‚fx­2~³î¦ù^³gløÃôÔ¸Z …½ücEè¿)j¹»{2˜öxµ‘Ó,»Ø¡@öP’¡‡N–D®d~à$n€œ(KS\ÄI’¥qEQ—¯@¾qßÞüÇÍõú×ï~ˆ^;yáþ¸ø³ûbáü.¾|ûmâ|™½Ï²ü‹í?˜š +dÖeu- .9Aóg&õ¾=XœÖêdª(fÚcº´©V¾YÁv[“(sña‚™K(ßwÜÔCŸaÚ&ËËÇï©OjçËV;3õP;³Õ¿]¯@¼&*s6*ŸµsuT>?{Êg$Ùç=Úçf›¦ö¹Ùp$OxY‘Ðà>«šƒQ)01SŸ‘™¹”N0ñ|QƒISe—Šq;U7Ù^5›½•ç£ò|TžÊóQy>*Ï{ìèÆtÓ&ÿí_«ï ¥=§pP8—N7M×Îî(ÐÓjjͶ;ì%%Õm}Ñ¢bÚ‘çœ}ž3ï ìã5)¤€L"Jp °çj‚KZg§“k rÍ4¡ü/¼€Y è§õb÷SÑ7ÇbÓMÄt"½_1¶=5ö¢û2ÎSÛãò5ÖSQÝÏwécV÷›PjjûO¥Šø³ñMR×o]ÐZÇÁ^RÕSÕKŠº¿Zu^¥î%µ,zuÛ\tv˜P•AËB+/žÉ¨£³äÌ R)t¡9úìëk8&þÉwë5רý;Wž1úö´eõ´qÝõ3—Hçéi#6FACUÙòè!óßþ³/ˆ*CsZ×Ç[ÖÇíµ4¬OúhïF3U?3“÷T÷Lê³5Yµ±U3kG­ÃLXœñi7DÙÛè´yLXÙ¼8)n¯ñÖ ÒÅêê +¸ÇùRpŽ¿)ªmWä=ÉA˜ø—ßößþÛÿ€>-¹ÝÊù· ôÿËïãVê×Èþåwòô#›-å–hfO˜Éß}2©­_\ÆLXÕE¸?´=ä%F{ÒÖxÊ6Õ&Ù¿~w»žÓ ÊÉòÁFvõƒ»åü—¡Ï5éÓNÙ‚AžÑ‰œÍ˜0n›f}&Z>7ë&Bõnc˜7¨‡NΩ¾ ä|>'»m™Üd‹fº×>VĶtû>º”ý'Ò +¨@Øóe39Êò(ˆ‹<¹æddLc2æo0ç;à9/½hÏŒç&,úÀUùð8ñ`äöðäÈ@ÉâdûÔ›¯ççè¸næýÆÕ¹–‡ŒŽÒÉ•nIkdÝ¿žÜc}XNÃÔ•ÁÍ3£°BË«£›êƒD=O‘²7ßÍ°ö(N»ûdÒ¾K?[õ³}ºq r<ÿwz©´i}?´mqƒÃâæö¡æfÿŽ©OW6‹û7†}®—¶ãHZ„¾ æ£5©_N,víAô¾Rɉ»ÑƹÕýÈ«ì0B4T%û©MYuvîI2ó%ñ[•F¨S5§ {B ÖVÕ´˜›¾‰„xöœ§\Iò”6Ja’<Åæ·yÇa‡=C.J7I¡È ü,r2œÅNà¥Q\¢Äó Wí +{SÉïã»Ïžl>l¶äF´òtIíëü–Ìø…'ŸõÏ£Ì2œ–.&NŽ q‚,Nœ4IKá’Q\`ì¢ÎÄæXŽƒ/s²`Z£óŒÃ÷}Ø1iä`?ÆN€âÜIý,sÊ4Èý,ö3·,ºÆ$Öqà¢`‘¤bOŸ'R@Œ=9ÈM)E…±“z¸t"’æQž¥‰ÅKY‡ÄcAη2!R¹“åAêq™:IT`'òPœ–A–£ ìFhÝ"»åízu³ÚžiŸD>Lxî%N˜G@_qLœVÁ)’$N‚(ˆsÿÀÅi~f·»l1ß\Ÿg)É’2óa_ä(¼*“aêdnG$.ŒºñÊ·.ÆY—‡ÁÞÈß+`_ Xì—±“Ï Ó4 Ý2:l r)λ8áÀ€Ù/à¤pLB»1ràØ( ©œdy×BdÝÛkr³‚!<beE^yî;AX0ª$MyNà0‹’‡y÷&i¡®sî<€íSØèiQBÏIædY;1*‹Îö"BÝbjKÞRÅvž¿ùp¦‘ÄYâ»iḸ t$ôhϧŒ£( "„;Úë‘œu© +{ŽçÑtŒØMg¥“¦(wóÜ ó¢›ª<ë8 +² 炬<ƒÍNÂÒÉST8)]€,7ub$HË,ÆÝÕzžsk®ë"ßÅÁ.lo×+œ,"ȉ’(÷\ì–uŽ¢ÁcIà=÷@rŒ³(ƒMxìqäp¶Ç'. Ê´û´3‹»åIx«:¹ä À}½ž ¯YsTe–¥ež¹À/zÀ¢dÀm¥06‡”˜âÔ‹“¬sŸÞ3–©ÒÖ £·§,Œ8ë.s,ÛkrCžex½¿±u7P÷5ûÂSñoöÊ þÆ 1ýÿKå¬Ög·xóf#ã½Ôú$+(ŠÒñªi´þ¤åÁk²¸•åÓ¼ÐúȆPÅ€,HC+»Ù\½#¬ŒðüYDŸŠ­O W Öû¸ýcJ™¸¤}6²5ÁE¾ÞÝdì{i{³Ù/‹ùòªÊkè#ës¢î׌5„gQû¬ðÄì)Gl[ŒçËúslëƒÛÕj±91‘nÈ[²´Ðhª2jßØ 3ž¯?˜ô‡Ä©Û|Eè^¬›X_hЫ¹Dü¹ÆnatëÙ•”m#Zþ„J‘e¥ùS +1Æ– Ç2h1±l89VëóDëÌu$>p~e99.B8.pá$( Ç8ó“’$èÖ®éåTn'$°ŸaD‚˜V"óý(MÂ’2ëùtÊD(vK/ œ"É&pqàà Jœ"Ëq BZšÝ0¿çLvÝTPÂ6õ€Ó÷ ¤62ŽâÌq=XŽ2+P@ºg={"yJyZ×pþæ,½/‚œÄ>L}ú9lãÔÚéÑ+à(‹„=Soöþ–¬74ãù½§‰©Y×ñÖÚy7 ÝØs}À Ìu‚È +Ê2`€ÛO\Ï Jö©s£¡§›ëÕzÊÙ×{ä’Èςĉ½ˆPcrR”`§ Pˆü0 ÃnkŸz£÷´œÀY:xaêc8i#äÃq²-[8È'AŽÃ(w“n~'²tž•Xú,ó9FàÅÀ3”5eÛ‚<Œ@܈„ ‡usü­# Î1!/Ï¼Ø AðØÌa#ê™d”çw‹´èV¶Åm#¸=ˆÔ²opkBÀyF ~‚B¶ñ±¬ùì_02 [§ô2ª‡ŒKºw@¢vA$B °—3Òí·vL—OÙ¦zˆ1ïù˜”NS¦1,©¶Ò/œ¡ (Ò4ÎÃN}SçêÕ^0²dXBHìD)U€Çyîd) Ãv¤_–nÞ¹zA¸odTÁ“/N6¸›U‹Wžë’E®ë€¸RPy%p2D5=))ã8£†ŠNL÷m#£­ñ¶&WóÍ@†uÑ-£Ó¹X'afÀ6Ê6G¥ë$~A¡?-ò0Æ8êö0KmðÔ‹µpyž%æ*¨“kî•öáHʨÈü@2ëÔÌ™{L\pÜâü¬#ó"Ÿ Ú;QÐØØh˜£gAzµ³vŸm¨ï²­7ƒ¨Oû,À¹"R8p\GÔ»šŠ9츰Ä(HIxÝôصd§„Ž# ã(Ì“(qÊ"Á4Œ‚FµÁ_(pÓ(óˆyÝ°˜tŒìôìÈÞñÅ^Xº%‚Í%#`·J8²4„…+2ù‚_ÏÍö##ØO¼9Q–€`žK™ºEáD!†zEd~çÈ‚¾›mKðæ¼ø_äòü0á„ê{²™fNI¢øèÜo&«¥í<¬ÖÞ1‹2öÕ*AzO#ãQ™8aÒŒnàvj·öƒ%ÔÓ,^3R¢ˆøØÁ•W¹†]`’Q™º¾ïÆIÞÁ‘ZFŘ6$îo0cÎ7&RF¡#‡`j„…!:8gnh®›E%°•ÁÞpÀŽ1y…P"¦›&Ž ˜ŒpH¹€]ôKßËb`‡qw` oÚ]<ÛÑ>œ¸‹ )4 q€Ø¥ÆÁ,pröPZÄ4bå¸áÈS‹ª Î6?Â%ˆZ¹Cv{Jhø,0ÂÈÌˈ۩åÜ>û‡×Ô<Û j4OáÔ%Y pžƒ–P÷;—ø.I¸Š;¡Î·A]Mrg] (õËUòc8…‚¢ˆìF4P-É +—eÝѵ­´ÆÖç¼cÉB@57tʈƥ"àó’4N`@iê•9©e7‹Ðº@[ü~µ\ÝÐD­Ô"?РxD¿kKÀã(.¡ÞçÁ ×cNÒ)JÎS·Ì˜:EÁöq3¸ Û‰¤Ýμæ`, 3¿Tn0ôoQu¹mP¾ú~…4™õô0È5iB¢ ŠIÝŒ’ƒúŒ' €v‚r3d‹IêD ö‹Åyœv3Í®9ÎÀ<Ôòd™›‚pL +4—ÃÀ’ŽQ?K1*#ø'ëN`ç´¸ÖgH0ˆ"˜½ £)v +kbšw*J¢ˆ” Ýîy‘u…gz %@9"1 +0¥¾zÈ+à-©¿UyäR%w?fÀBlÂtÑ$8*ãÂRêÁ@b9@b΀Móº·u°ŒæìV¸ØwQ:‰çžIs`—ÃÂwŠ°Ëz@Xeæx~¦i\DyÑ-¿øæ0õAÙ?âÇY˜¡Ü IS Ê)žCˆù8 uÇRæn4ŽŠK9ÏJ\``ó‹£€†”#–•t¸LÝ<Ês¯Ûy¬1‚a=˜ØÉ +X´(“ý4 CèzFphÄÐÿ,ÎS89HQi–YçN@ñ³'¬§¬§ šÿþGyæ(ŸŠí »NÐ)ü°Ò$A…×É4¢Dïøp{ûzŽœfë‘C§ƒÈFK×É“2,¨_Ú“]é=ß^ïn²%žB/4ÑN(Qû$÷¨½’rM!Uµ y‰f®×)Ÿ£:…mãépéZ»Œ<·È"êXˆ"’B*a¤ ‘SwOä§ ãNãŠõ.`×Úã'y@R¥‡i”Wæ9IY¤N‰ÝÀ+°çåÝ,Jõ‹d'ís„]”G¦^§ÍÂà—d…S”iì"@χÌ2ËßxÒCϲ {plš‹Ò£ibü2r’ˆ Ä^€:1Ezièîf)îZ£¶|”ƒhŸÓ1–4Á,°0n–˜™4éÔô!8i¸†lÝ$pu÷ûaä”E,©£JæfYôpœDYB¼ÒíéXí³é‡èöº(g7øöv¾´³ºn£ £É­}ꊀ:R"/@JDA’ý m §¢ S8<Çù`ÿ%¹ã—yè¾ëÒ oŒnZqŠƒÀÝ1ªA×Ñ%DíE^‡ƒÄ„@ès‚$9=ƒc>BØ+üÂO÷ñWê(TÝÐ9ú_ú°ƒ.³A{¥[pâ{1ˆ~®ç(îVk#Kÿ‡Ú¼{;ŸD‰ÂIϱ?‰ ü¬ã&aî†Àrå{2#¯4XCt^ØŽoYq ›e$’ñaª B’5áô/ìè Æ8ÎapÝ)47/kƒ›t˜æm¸ûºOB€G`œ4¶#ȱ“F°‘A6M½8Çtié>O³?\÷iØf¾Û²ºÖC6‹Ü,a4 èô—iá$°“`b0uêÎrÒÝZ(@4“5¨·^eoW9Îv ³´øD…^À±ê$Í𔣅™¹1ŽÂÐíö¢à#ÛzZ·5xnW»FpÊzµÛ’ÍÓ?tÂøîÎèz–¹€‰TÄTŸ—ÄIÝXã,γ=¬¥ÞùÝöšv §Å‡è<%?»4Mp™ÒR/)ÎÊ#×£q ›fa’fžŸÀÿöQ9£m¾™éA4;ÓÚe%Šá Íê°'?œAÌE9Mq'I–îìTºüîz5ÛÌVËÅ|yâ~{Øe)LiJ šfDêœøN¹ µ•v›?\­ßåzµÜÅ3¶v:ÊäP:ÍR[pô¨„ŸeY0’$îæ·\Ëd/É»“v:L<R'Í$òåR(‡ëÓU†d½ÔºÃ°¨*@iZnö¤Ž¼2"Ê”º’\'Aàx‰ãÒà Ýò¾ EÍÀÙ-YÝ#µv>)ÔÏãÈ)Š”P û±¤‰WS7JPÙ^­Òù/µv9C„Ä4Ôº¤º" 8J£´tcáºÝ“½Èè2“ÿ7³5ÉOÞõ$)²0Hœœ¤°!KZƒð‰$ñò0u÷r¯J×5ùÿ¤ÏõJB"'÷he±Ò¥6@q×Ga™{$IºÓ8S¾DéøÕbµÙÛ}Ò>ÃÑX4pšÓœJ û€¤F Œeá§qîy©¿7 +¿IÚç “£Äõ@ªÁAHm¡`K"'BÀ·&…—ù‘‰fÒ=¤ã¢Ç æ7Æò¯>ÍvË¢ªE|ƒoÍþkmÙû¸yF³m1Ã2~æ‰ßT›øD¨©©pön¾½žmv77ŒL6Ïhv@Öö†¾Æ>CS¿ñ ]êßww' €‹¶¡!¯JÄ5kc*†È3Y‡W_ÔZÊÓo¹²¦+ Øؤÿ“tj-v°öaUŠ>ý×Ý>Ìæ§SrØÇ‘tvÎ&ÒƒsA¸A¯IIÖd™7Žû“·|íVL¥ògWÓ'¥ Ê—tQ½îw0iü b´B#y¿Í—›-†§ +s(¢`ŽXìÚT¢ ì{×ùÅzw‹Ó€Öð/ŠâÓ/ÙKâÿñšŽÆ¥ý“ÑùÃá?»Bš¿òÙKºô!»Cë»ÏóYŽók˜Š¬²Á­Åjõfw;ƒBN‹pÛ…ù€cæÃü>yËRµa˜]‰ý0Í…,äM.pF²NGµ¼|ëv±cžyÑ ÄâÐè§w zÏYí ®3†6oÈúŠbàUÃî³ÞUƒƒ=S°Ê›bpHýp‹fÅ£YõÛ|ìb-äØc:—b(Êõxô¿ ]“·ó ëñúôä §2åZBßZ^ås–Ÿtv…RsÖçzµ2ú"ñÉà×áMY†‰#(‹õêí¼àÄ^.Ewèâ.0ñá&+A‚üI}Ïë/rzX­ç°Œxñ‚¿ú²nëÓX×°Â:”ÞMƒ ¹ø>ÿÏsö,øv½Zü‘Gì^á-fûDû>y%_º«¨BÊ9ë›ùF®)¤ˆ¬'UæwΑqRÏ®ÖxI¸ðu9¿Ê®®§t1_¾Ù(äó«”~€eb¥—ŸUÄ.ÐPû›UO½½*µ½-® +²|)6½A«Ymi±iш'¶ §Yš «÷Eÿªûê-þÅƼ[˜Í‹]’z¬O>ûžÅã«ëõj»ž/¯^‹¡µékå#ßá5` %T ØzÎ %…ù˜ðújÇä)e¶]N£·b!ùus6ðQº”Êfƒâ÷©Öx¡Þ ÔEäNíÖscyùd\­W0(;àUfÍN1o´ÕV˜¿šG\r¿:>—|›M(µNÄé4 jã'ûn>cÔÈú‚å™rs´Vñ>@^ø[IFâ+ Ø•Ág¢uµ§‰ùŒd¶4…Ÿñg|gÏèÓy¶QÍï\Ÿ_àEÁwJò–ç*Ó«¬Üiø[qºó1<-}P¶Ì‰¸  Q›QóÖtŽ$³ÑãŠé§2g:T&%ïd“¯ç·|y!/vÆÿ²›‹‚t±Ø2:âñ*]”XäqÍ–¡*%ç7tUä¨OÙn”6Uµ8Ev`y¢ûñD]ôÛäDø½…?2wÕWôsrS} ° ß«Ÿ?˜]2˜k‹¦^ÕoÜ—Ýêâ•^Ž<ÑÅñDt±(=LmO4Ú9¤‘CÚÏ!ñüŒC²€äcd—Xyˆ ׶Tœ ¼È*«Jæ¦_”´i_ª/yü( ©9K#X¡wd~u½•¬G}òqnHÓÑDM%ü EÅl¾` ÿ0üIdçO.˜‘µcZõ¶©™©röHÅtTŸ;„Ñz?1¾Ï>ܦ¡ÀÈ2wd»ùBtžÆÃ5¿ð<õyý}‘AÑÞ¿…§”iˆÔŠÚk.©rä@ØüÄWì6P^÷ž Ô7¥Oñç¿`ñ·îN¯uŠ5­ëÌPJ'OZOø¸RÌM°÷)kôS€ãSÊZþô/¡ÓŸþ“ýºû´ž°”種¾A«õúŸ€‘É‚=6ë>ru#W×Cï×\³‘?R>® + ïæçh‹¤û‚¿¬qrìôká×P½L>Eº• ¥G1ø +× U¦œšgÑ-›•Ö.Ï—åj?;ccOerLµŠoT1kák¬Ï™ªW?—壣óBiæ~'¶(Vìôòê—ýG#›6åJè·tYík erBôxÞûAƒ1²±b½¾crfÒõ’‘†ê©û+›/ä5“Æü7|Êá’iØû-ɯ‰‚¿§ñyAÒòfÍï‰×U¶Ïd,hÿŽSÞVF Ôã +˜öRÖy ÄIšSoþyÎYz( .ïŸÚ@îäG V“rõö:xË=­°Üò8a˵ò¢ìßNíƒv,+ÊÏÚfYH…“èâËÖÀH^ìÌnøÊOŒÄ6ùæÀ>’•æ/ê¨{M6ÛRS(¥$6µ0áè¤L8]uÚ-3.Zq’žš ^5÷ ì7OÝ'˜Ïæn®ãÊžœOoÀËF;Àùr^£sEM”/?»lþ]Ï1¥|Àæäç+<ó÷ê1l³‚}Q}ë Wmñ¦8rÂÜ‘+Îèꄵ¸ÁÕqÇhl±»QÈŽ:csS.æd­0à>û¢8;wËÍüjIW “úÛÍüWÉ..iFŸ…p Eôéù/;2‘²_Ö¼'*éxŒ›"ðmíéz£4_áãžoÉ2%0 +¢Ì‡ +Ê»ñßßÐ×à—:›\‘”ÔûJýîš[Ý~+7,n¨IåÉ ­C8}E›Ëí(y³ØXO +ÑûßðRþž”Û,™ÁY}-™4èÃùê¾!ÐçCȢܴ±{Hôú“ép ³û'ª ÂÒÛÖ ° % . 1ž•žŽ°¸ ‘ag‘m·õ7¸‡EÁ8"B´5hóØÉ>â›nÐw¼cC€mg¶D³-WoµŒ5u^Æ’o€°Š™ÙC +v^V=ÐÐõLÁþ4á! K RªÚ\å*J­Cn¡À2I´êì–|Ô%K~7[ ÒYç²"èCºíÌÓí®LÙ¤ÉZ·ª¶ZÅu©Õ— JÆh¯ºk XYW(Æ~m£\Ž&öáEwQ¨°Ù æ‰C-@:Ôg"X—m¥I¿'ªKÎ9²æä0(¿[iMƒ,…Žl_e|ÐûPÂ9Ö…³¸˜ZB¥×¬6ìIÇ»3Á8’\ЗJ“‹†{Ü–Ô3Z­æYºlKe"°JÒ˜À÷Ùòñ‰ÊBa\^¹¬ÊBÒØkØPS"Ø-Bâ¹»ù¹®¶•µšNÒ,4„?O§µÚu³X¢Q¥%ÚKô ¶‹ôz9Á7Æl;3‚¨| ›*Aã¶BtÙÙ­ZÃ]ë)cu]|›ñbTm¸95§¯0}0Ý¿Âô3…i0°û–¬JØÎœãwNçé}gF04ü2Ò¢ÚGÿˆ=1{t, %â)ßdèÃö©õõÖ L5Èu ?Ëa¯¦r,O 14î s{ö?´øº%Ñ-)—¸qrÊ¿+Br›¯)(\÷9*Z¬&{.„¹Ìïv} Ê[1N7ˆ¡#Ìä(6Š A+‰ +‚Ý\ó'…´m¹">Û[ÅG뮇<µŒpqÂÄ à•Ñ±e‘?^L[^én¿•NìÑ;æìQ&^zp_/ð‚Qì½èÅpiõ8êOÇc4šL¥±”é02Ä?HÃÐ +ÝL†ëtƒfÖs†$À,;í +§¡þÒížFTföÆÛ.ÿ6ü!œ|ìovÿõí¿q6šøùwÿ¿¿ê¿þîEôï?¾ýöû±ì6¼Û Á&,S‹l“ñh8dð¾æ˜w.iÅœq÷qözû˜ë +l ¤Ikù‰.WLTH"Qå¬R0¢Êy•€D±+œ’ŠÐbQZ£E¹"éei£Îÿ>–7’d”pE]¿\$!5Æ¥²¨ò%îÀ›¥‹gÒ± 8D©’¡Œmë19]Rüó ÓÙj`¦“‡ ºÆìüêØ‘Ø=-f§ À“ÞŸxòÐ- ‚°,‘9å+8m!:]îGÿ¤‡u¢tÚÌF±;»Êí–aPM IRÌnšHÌš(´ŠØ×£«V,,•ÐÒ¥ˆœ0ol×Ê4â$ØWnÖ7<©*®|Uâx:šü)¿SŠQ®µ¢0hgèçeµEê´…SO\…ØCª{Fr,Óê2¬32sÒŠª†ÄléfÆ‘”Ø—Ü/iû^þA8ã^ƶÄy1/Ck²é¨-­ÙQéd+!r"iKë±*UœjWÜÒÛãÈ(žAÊ.ÇË*·Ô-‰ ú&ÖÜóŽF ¼JRò"=eJ / +$M~½¥ÅŸð‡,~wî¡)™dZ:(åM +­ü„ÀHz¨?`tI“VÛöüdÀÔæBâi@þN„™¤°þtÂ? ‡lν"ôŠ—’²dÙ ¸íè@D&àÓÿáêœhÀM² À@60+é-"¢b0±Ç#=³d£c¢Ú ¨1€cˆ…A€Ë"` L|-*åó–õ”üž»Û:Úe¤„ñv@Wvs- ;à¨;+z@eÕ^; è;Pàw àï@`™ pn<ÄG«txp¨~À@á€,¼/·M:ÓÃÓHö]˜4ÌNœ$s‚ Dû- ÖB€(ˆ€7Çà¼ÁlɤÛ…1L·J0Þ)âx?bh8ƒÏÀ2KÕRXLñúÓ‚;(³$ÞÅ*ÞñÉ5ÀŽCHÑrÈ̇0T.͘OÙ‰Á +Z(/¥ºJápšGÇ)êUO6ÑcµV¬T£Õ°, §$`=)‹ÅÚ, ;}v”i2 Gò´ß;^@?È­¬¹È`†h,ul@Ë'È’ɹ}cÒH°¼Á çö°¯tâ€-h/D&¨qóŠá±A/H4ÐSÃäý4઀¸Ñ²ž =ôÑ$®a¨Œ‡–»J9ïØ÷qëüÈ9GE8åñôTÌt4PòˆT?@òå¯ï‡UÐ7èªèk%‡è‹±“g!^2Kôü‡?Ó#A—º÷¤„÷'|Ü)¶›}«tµ¸° Rag{Nâ#VN@í¤SE\Ömîw/; T€WÄ´!fì1uZ>V&ÇÀJxóKºi®÷;øÅ]dkÒh¡è#®’Ä'íl²õü|"S–ä4ŠÀuÃýu_d»¢ã‹Ñ±Wi7ß6:BáK¶£:4EÏoèùñeA£Xad:áéÃÃlü@ð‘A¦ÀÉÆÐhȵðRǺú¨ I¨Y_{˜‰ÏîTòhë ¥{Ƿм*ióœD_U9;yZêÉ ð« š¯!š¦,w0‚Ü8¡Xçz.ɾ>Z95…º”k6¹î2(©»KIUæ„/)/}ï+¡a•% ШðÐlÓÚê!íU,{¢bYwO-–AÄ«‹eGÑîAs#ÑÇ.@( ™P¦LcB œÖHË9Ú ÅÔ C +DzœQÑ¢ö–·é‘2ÞC/_róž³äÖOŽ/¹yíJnG4hÙOŠ^ýlñÌ8Æ%öÙ¶Kö”8&¯Tɲ²qŒ8êh€d‹t6oÉ “-²]«˜ âè¹ÀVV:’Åâ’,«ñé©j s”]"”ÿoâyƒM¨›ór°ÁƒÜI'“M¶Ýujã†ÝFu«E¶&K-a–Í6³c‹@ì³ç)…I50‰T0aóe@Ià€ÈpŸ±š + +ü« î³Ç ¾ ƒ`…v çˆC„4Å¢íî©ìȪ:"<18:Á–ÌáepæÚtSœîϦ<7L‘Nœ:ɃÛà ^WÒ8©…6A'Óö[(ÏŸ âDa%s-Ý‹Sž‡³ÜOÁȾ’yæ0ÁŽ{´ Š(hÈ÷\è ²Z¿÷–Ñ‚zE‹fhWóÔÑBš‡óE ©‘çQPˆòý†ç†ò8×D ²OðÔí½´ !`˜ B>›W"àT³åã3‚v«|=(‘gÈ®}·ìj‰-@ôŸ—MÖ”Éoš8”P­œNë7óäp˜LÙæ³í\èÀ—ü¹#4¶ùüºC‰Õ~'E&[|®I'†Ö•êr)ðÜ åwàÐÕÊŸNÃõ¼îqPM‚lCÃEF¼E ×Q\|"ÝÙwÖót| j.?böTé|ýN²ñl‘ÎÁŠ}“¡Þ‘+ÑT«Ï¶¤{áê4Üá/;6m¶ÅP‰¯Tƒp0éÊ:$xS6éŒ;Xq0)`g;$ýb8¾g‘XõqÏ’ïüѯ´¿ù3ÆÀ¤Zä´600 lX?”Ðq &â;#ò0fŠþL +m¹[¹|v;ˆ(ÙLÚ«çnäÚ@eTpÅ©§€Sýj¡‡ž/NA‰,¢Î…œKÆL…(Z "Øjÿ`ƒ,P)¦“ÉI«,ˆ•Á«än`„ùoµ®Ä£ÀS\5ˆO@ƒØ? +BëoÍ;_/¢¡Äú—Ú6ê¡[_äËm§Ñ0º¼oã&ÁE¨zÙ­˜VâË®&±\x;7Þp$z€ÀEßjUˆå‘ÇI$ŽHâ¢ê¬ {“Á¸p)½ÌœÈeŸ‚“ 4bÿ$nÀ• ‡¶Aºw ]‚K*¶­’܃;.Âůí7ájb r./"‡My {ð}¸"tq#n‰™âk”j¶…ûõŸÈ͸4¸ÂÑÈë'éôE¯ß^Dc¿÷¢×ÍF/ÂQ?õ§ úo4UÆY¡á“0ùIj»!Wê'e—øŽ\!T`ô›˜·ä’醒·þ±z5~>ü£÷Ÿ?Çû›èûß~]ϧËß^ý6_ÿòM4›|ßûû¿ÿíÕBiJ“/Ý”+¦(–‰˜½<¬„¾ÕÛrÉðlâ¾\pþ$ݘ;àWæR"”îÌÕÅš?I·æZ ˜ß›;/Î%C)nΕ³ÆŒžÄݹb¼¡M|AI×窫“ÝœËW°É›ÞØðìoÍ…K¨/­4 +C’áz‡î!îеݤTñÂ4Ë•^œÉ…W‡¹+×r9—T£yAn¹ôÒ­¸å2(†Eæ%eøÂ0)_‰ûoÑ67§âK»ô6ˆ”Ko帛mÝ}‹©ÃreYZ®e7’± iåwY”T-Œ¶Ñ/$´]—Ë +ÁCò’’«y!.Ðc~Þ\‚«Ý¦øLo¿mé–·ëÅ·Ïîâ[@?zñ­Œ yåír—ŽÉnÇS|VLí–S<7Ùx¶ž²%²i /×óGx&çh(.x ] ö‹æì.a‚ád¶Á×ç^EÙ6DYA¡y¢,NU$ÊâDòÍè‘É|ÕEYUJKŒz¤ +*ÝÇÛÍ+I“%qô$GyÅ|‡ÐÇ&—Τީ›'¢jÂñ±%SÜú Ò‰$SÜI˜ô\C2Í)Ì.`vá”/ýú`zÁ/©G¯ uI°ÕÁ‘ô™‹¤‚`¯"éU$­#’Š5{Á")…ƒ©Ôqy=½3Y‘G±á€CØôŲhM‹©Œ ä(›^Ø)¨1hýÒ³ˆj(>çøLôÞb2½¢É‚8–à 錽éÂm÷jI‚œÍ¶¸OàCâH(ŠªÓì. +«+.õcÞJq¶+Ý÷«…&mí¢‡ãù +Ë3»kâßòp! 4ÿÖ‡* j<öí¥ç»HÃãÇ2ñ½–o‘º¤û?­b¸®Y8üÕŸ/jßý™¿TÕÅÕÒjIÚÚhûb˜tOÊ X³I%ƶ5+úm,\âsxÞ+ñG‰j¹ƒr+ÑŸ&VWbŠ¶Ù¶…è9gŠÛ¬>l fŠòÉX¯š"²d#5®eiš<—ä)­0) +=d÷†#u¾Üd÷ØÄõ%Pó Á—„¤·ÜËN¢óOœöU׿¤×°\ÉÐôዼò¥·î¤÷œmò!ò àŽr—huÇûêȦ÷˜sƒÐM.ËÚÐrµyšÒbBøqºccB¸ÃjÃDF4mlGÇã“”¶{Íýz¾J'§Et”ºrÖb¢4ÂVQù®û +i‡¤áaD‰UäÂÀ” =£üç$Aµ~jGìiâ¦w,À[þ‰ä®ÀU( õÇ'ÐržwpzÀú¤ƒÄªF‚Ô„$¤– +•ã©D:@Õ,07$¢dÇW! +å n#g¦rJîè5ÆÃK>w±Uˆ²²òÉáú¯ü½Æ·Rù %³‹áy7ÎPÜèsæôÝ‘éû*v\ÅŽb±#ô…Øaå…Ë ÀiŸ”RîË%Е´QEÒ"ó?êEr‹ +°¨äpü®w@6¶[Š¾¡Þ  ——?A³û½Oml¯'N¡,Q…²+²’Dæ•“È~”Ú{Ç®âØAÅ1ôŒx9E_e°« V"ìRÏ*ƒ]®¶ËP‡û9¼÷óÕýåFet0^§`$³]À_Z\"X°t÷ŽÌ¼Ñk϶¹³Æ‡zí“åàÆ@Êr‘XcΉ¸ÅL¯òηèÃûùßg»‡_é@ý@P…c‡áØv¢|óûþSh³(棽$òG ûø,X~íå‰Ĩɞ—ÜC7TV ]r _G)J~S¼ !"ÔEÄ2~Â=Äá"œCøJ‘ËåQ+1jK¸[ˆ¸í!ù„HÓ¡XõPKšÒƒŠg±n ñàEúQÕÈ›mÄ»í~*¿c®!fW¨cÈÍ!°Mñëok2†þˆ4%1ºrB$”*Ü&aÛ"ÔäK’”ÍœD©ŒÞtáJÂ~«¦÷}º?¤&¶ž„RQQ¡CH Þ<2ÿlëÊ5ªã`.^~ºàµÆ¥w|§G£µ¬l¾BŠ^!¢å±+ä¢ê/¯ºËw¥¢Æ@¨ÖóX«Òºh¾Z}ó•Ýžë’LºW~ÛØd©>:粚-Š«æ«ZQ^5]Ñ´°=ÕºÔYÒ–àÅOs“hMåVqÿÎÚ²Œ¥nf±½®(TÒR³j¬&¸j…™Òˆ!­ž¼§×·ùÒ(ÉV|Ÿ¬W¹H™m²‹ô™–).“dŸ±Neí¼whêB‚®znC$'9›B61Îàóö.ÇHœÑrÒø…cBa»{gŸ*/n³Ø­Š¸Í,fª‰Û]Vd#J„En[þåÃÑ\þM$ö;FÍÜeíÁ¼¬z E‡9&?Í„5E[i”«ðÄîy"h\¹”IÚ×/àº'Ø£™‡ùh{c‹¦¢{@I´R¢t¿Ù æñŒÎ‡tK»2¹­Ûÿl":[×n+¨J%C”“æ ÙbñÓ„*àÐgVqx“ Bæ¨ÄALÔx-$=£ºÕ4gsmª¯¬ áŠ/õC.P](õKë·•+Û¦ÙÊŸý;9ÞÓílù ùƒr^.]IĤÇ9 w +Ôƒ +9ê±.œÜÅY8p‘tOÂ~÷°Ú°'ýÎ ñL\jî€eñbáÔ‘ßrØõü;92l[úøIÏx%UU{…û'*#ì^©pX6 |ü5$bái+ŠÿýcIPˆ›^ºyX +Î7›Y6?vH-£ŒÀèûlÛy\í;éû¬³H'YÛXsÃÌÏô£(cqå«®va›͉qšßéñxMÍIì 7T„â%®™„a59»t¢WKºRÑ%éNBæûÔU'AÒ?’ê$ ÚÒœ:Qc~JÏt¯Év&òE6;a=×m +XdVÖ8‡º¦KR9ëªhÑ[ _Äu”ÓÉqù€½äfø)Ù@·£O3í¡[ЧшÙmIbß+ ð}¶||¢dèG¥±RÕ²Ic¯% UÎ#•·o!õseÿqNËC“·m÷{Ékµë4lŽ¿%«²³œ%›ˆQQ_w¦óô¾ƒÖÌlœ±u>°˜ä³-ܨÌcböèXL¤’Ü-.qh¤ê´ºŸ4ìÛ_’ÎÔ))‡ü8$ÅÊ8$]½‘ž—7’›Žj:&›Îå\‹Næ¹ ÍU}†®~BW?¡’~B¡•Ô/ÛG¨†PÿêtûÍ«{гpr—su ºº]]ƒÎÇ5¨ÂJ½º]Ìr¼º=_·  ++úêt® øø.A‡4¼ºÖõ§š¸}uû9ãu–?½ºý¸ýT‘m¯.?g@üI]õÐÕåçêòSÆå§ +"\Ý}®î>}¿, ]Ý}®î>gåîSê®®>çEAPZ¥wuõ¹ºú\]}ÚÑ•\Ý|ΓޑT%W7Ÿ«›Ïstó) •5Ãö³×ûL¯÷™žþê,qŸ©L”lõü©ÍûLÁüzŸéد?¡ ´¤ûL¾¯÷™^ïÒj|Ÿ© ”—k”Ž{q½ÏTH®÷™^ï3=¡,Q…²ƒÝg +Ùõ>Ó«8vÌûLæ®÷™^e°*2˜yŸ)ÇÅ ÀȘ’þQøÂ<†«Ý+†!º´*‚4m•Æt &‚˜¢Š4).þÊ¢ßÝ%_i&z³»îW6¥êìŽiÚµ³¦Ù]ŸÒ­òABÔ +’B°BïÔþ‘Žœ•~ê!~ìK(Ì!Ét­IA¡8©ÁIØÒÁ'4¯D¡ …ŸH«E*¾œìc´S*á*í\¨´ƒêüžãíUÒ¹J:%$ˆK:2ð]¬”Ãœ@ÚQöüå~VJ¤M¾ '_HäÝù%!/æ«ñ;IÐeeº™ØOå‹üÓÿJµ“ b Ð17†f»‡árE® –¤ Ñ=ÛÅp+±¦ó”tøË3sN&êr©!îôrÄ*G4‘z4¡E¯Í¬F +ÕT]t•~.Kú¶`‚èlÕèaW1è*ˆAa ‰A*0^¤84/:i»˜è!0 ƒÓb÷µyV.¤c©¸’9<ºQHȈ´Ô}¤Â¾jÜ5%~7ÏsW?A‰MvªåÊ#žzÈLmQ¦¾3ÓžHa½™ñ.%€ +¨Z·˜ ÝP.«ç•þö[šà²L|È€Ùp‘.ÑL)ði°ý¸þür™ý5ÀãáÙ{ŸR'Ž£RÜ@ÒCr2 ÐC{,É9f @D´O²–ÛÛ/©Äv‘ “ªt‹wŠxD äØ—êg6PÍLì²T-EÀ¯?=!„ û• .V!ŽO®oÌKm ¹©I“陧Ú@Ú©4_5>e'Æ'hD˼\pŠdp‚®ž,¨…O=<ÇDÐF±R!lZCœ²¬—’8õ\ä©n\ mú*Ú(Ód ŽDéd0ëfQ;”Pk/2˜'ê• ŽhcB29·o $Ðñ°ÜpVžžîBد䥇Ïcð8¸„2®Íå¡?2ÀiðTä ;­Ê`~"ZÁ••Òò Iƒ7'ÛvrC;ñ(»µ \ŽfI õSÂ^–ú~¥ÝiÐUÁÔJ1Îc©Êö[º8 lª çÏTi/ˆP%¡¶¶“cí>#¡–š`Ʊ ‰rZt(¬²:.æ ´j Ó±3¦ö0+9Ä€µ¹óì +Î&ʱ °e·œOK¼ó£n%ùNƒ$iÊÊï'YˆXm?éÕÞOq ÀïÝŸ;äDPÍOR\,m5Yg¹ê¬Sôw¼Z¬WKhù 䮩2”µå®Í¬= ž€üg@=¯E*±ù2Ð'ÈGÐø§W\AÕ¿ýúú0&fêsÔÜÎnÕ!ö m>Ïfø"ÝùjœÎ;«Mg“-V»ì‹Fpƒû&RŠuˆ' ú·Íì7¼hôœ²¦˜Di«‘˜´˜]ˆ˜TNñõ¼¥¥€]k~zi©¾öý(Òn>üñõßuÄœ5 þPM}¶m?‘€DÁ£!Ñ£é'gÞb‚™{.ðSq³vë1æ§1m8 +xõvñnŸ7l……-h¯²ß›-;£Ç]vª ž¿:øE„ˆÊÓ.zV«y–.¢—¹ÞdôR N5[>>#¤ê…MJž!ûÎÞB\æ¢ úÏË& Ê\M§m% ñÏÓiýfž|¿ Ëü [æçµ ;¼$À$ƒí—DºëÕíŠ:Ÿÿõo¾û¢“.'°“OAÍÖùüí¯¿}×p÷Þ Ô¨ Ÿ”Øì¨Øî¬:ˆ+ð[Â1Ž’«sÚÅpQh 7Ï݆?¤ƒFH-ÏCUûŽ£¾’yÞð1InÌB‹o±×f¾tUÖqe:}ÕÕÇâBÐ2ðB¯xÐ b¿Hóp¾x 5òÌñ .ót»£÷ÂœäѬ +Ÿnä ´Ë6C°˜KwG y’Râ vЈ'±”˜7ñÕ&ðÿâ~`wƒ¶X[)Â?±í©Âˆôïfeegéf ©SæÃÞ²9JôcºF',mÖŸäÐ#Š÷2J#¹G£§ïE¹Uܤ#‚úü¢‘¸Íݤ“‰½R¹¶¯'“ïq>\D6™áúÃ(/Ïw(ËDîVa>»ªß4Î6±$ó·$ÉI˜ï ö€”½ »yÿ€R#«ÑR̪ånž"º.f¿7ø=,´M‡¯áVœ´ê+ “öbJç#FƒúWù’àÑKê©û’Píö%q§~ù‡B̟̹L¼z%½äeI ·ù'¦Yœ4Vo³»Ž=—è8~óÕ±ƒåÅR”i™^`x˜Ù»Iì £Ä´Wzx1tŒúÝ ˆö²éãÉÄA5Ø$õ]b]4|m9亘&²‰u✄ÚkxxÃËl"ÓEšLk5b1KÔçê’ü\²\‡‡sžíô vÖŒßâ”,+Äޛ﷚s†`Ö,’u„/Ï/Ë6QÐ%t\p<ë†øk–zLÛL_ ²¾ +¦WÁ´¬`ÊGüNZÝ(–¢IÚ?ÇûÁÌ›%è,Üò9H¦—|³ÜòŠhË­gÜ>¢QYXR)!IÃHíGV^³»$y–ŸJÇ+B²'ñ I +ˆš9õ]%„«„P,!HQ õ…ŠC€“!U?•èÆ.¶Š²ÌmܱTdc…é*v&Ý(¾1,cz¬Ö™V¿NR(;Œä2þ½—š6Œ‰Ù©æÍïsøO!žÂPÉq˜Ó­íù!”¯|I.ÉAR€±«èÄâ¯ýœ°žI«ÆªÛŠãŠ¥ÈÊ.ª³:L,çCŠ6ŒÙŒÓåj9nû\y‚@r?Þí7ÙK¨ô%ؽüßO/™jÑÐÕ/Ê¢+’tOeKc|b.<Ú•ëHyFE<&¹Cæó*ó²½Â·à`Úí@Ë.V¼½>‚RÈJÃO&¶)–Ž‡ýY +«IÈ`?ËG+˜Öˆ™©Å.—›åb ü_k…ý )|ÿ‰YäJ¹ÌW2ǽ¡w€r÷ÐVbˆK¤¼†y) +'EìnE¹/‰¿Ìmve“]ÙbW\¼%Ùë*溪µ.–îŒ,¤ßª©.µÔµŒŸ°Òué +]¾läry„k ÐÒnËÌïÝYÜ?éŠ{͸ïgÉAÅ3X¥ð"ý¨ê·Äù¶xG|=ù;f lv…:Ýøúª^[“YôïDXX)*¬ª¯lW»Š;àŒµ,u:»‡ÙVp¾]ô,h_ÍÎõînJmNô‹õ¤]›E>t#mÄXPkºck +AÒÖ¯ üBUâcÐÖ“X rôjC ¡cQ·š½ütÁ(Å¢T—€©X)é"£X| )<†›šǵ jh 52Æq158_ Ü]7 QÆà8ÔGGi)7ÂEOÔ_ha{ªð¨ƒ1ÆZ(1OSªaq¦KàE_Á y +4ÌüÌb{\¤fÕX*ÄTâHËCz y¦oʤQBDCâG³ðÑ\ÓeÉlƒd-n4-S¸‘° ÑƵ¶GÁ'* \RãH ôÓÚ& %½h +QØùE| …Aiè²Ë÷ÃÒÕ½3〕rð¶"ãh 'Ûl²ß÷³Øü@è軄²Ž¿ª•C¢FkZœ]Ý’îòm¬eíï,樼üÑl1³x~Êjöj­æþE~ä Ÿ»ÖѬV1’Ð]0Ä á ë¡ÁíŸ-±Ë46¥«":DºdŸÊÂ[¨Ã²$½éRèº"Ž½ˆu9/(!çñò†0~üF{£dÒWnžÂûÚ ^åýFèj!±+Èž!ÈöýËÙCîa†¿•éö 6D/Þ<¬>,;²eçaõ>ƒOøËA£l]d='õêì–Ë (%±ú`iÌrCÌìÓ„¤ü³ÌâæóÁÍ <©pz~êô>•MùÚ87Ð쇒~ +ZyÛùz>Ç(¹%—prnázž¤óùv?~è¤ÛÎÍnµšoo¾è¤›¬³N7;fg¨ µá=P:y2%ÐpS +z*Ö4?$v>-œ2ƒ¡ˆÅ¢žì73±€LL£v%5ÎKʃ^ÔûJQ¸kƒeù–V·@Uꉱ`¯ò¶ÈQQ„^½AŒ?Ïþ…vWØ\úG³Òí–½¯èçh¾ÑŸÌ¢ àf«yËŠ–o“ÁÚWGŸ“lÀNMÙ7p&šÝ… ™\¬à"`¿[^\VÏPYk ÐõMɊݺ‡W]ˆhøº'†Qw~h`¿wX!˜ž7ÀM&S·¼X¯ÀX¬³[µ¨]PÅ_v¾SE. èÔJ6ÍêÎ>h*”JÚô\W¶Áös-˜žÑÒmË +Ó3 ÀàûlùøD­Ì ®™4ö6üAïXàî¶Ïîn~®»‡FeñHÝmcÇè®Õ®ÓHר¤ïøbÕ´ÉlµàÓŸ¶M'ŽÐ»t!/PÓ•¹œ s©½u¸2— g.8è]‹gÈR’˜«©áx\ÖÔhJŠ(°ÚvPr‚ÄGâ þÓæ /ü¦,D†ýæ +©5Úk.[PH]Í”ÁQ⨼ïúÙ˜)Rã Bü/˜d;ëùþ¡TmWö›ÝQs¨×ß2Û+²ÒdK Ü/a³Åz÷Ø!¹‰q3U<íVëÎ<{ŸÍY)³lËâ15$´h.¹ŽYè¢$W^V=äÑý•™äú4¥W÷´Nå*Îí‡4@ƒ÷J´êì0 ¢7fŠË2ð3!`ƒ-ºÃi¡‘ˆ!Ív³#{k„Òëvõ   ñËu^·«G߮ԥâÉ’UÙÙ‰ÎEãß»Rû**$Ñ-ìlÛAÿÈ:1{Ô6¦Â{¦Ty_g“ÛÀ0Áº>T& äB H-º¬HàêMÀhŠ—< ˜¤üÃ,q½%ül9v¸ãJap<àMe1ªšD‡Ëî$Êú“+\úJxŒ¸”C„Ö†lJ’êþSÙ(à}ÊÌ¥(àzñPna¸ï ïÌ$ÂzÛ"\]3£ØZ¿1$1Ó»¢~Ãʆú¶`.ÄËkxûsV=‡ðö‡<¬;¶6MkúywjhHÄfÝ¢‘r^c¾?͘ïåÃÔœIÌ÷C.Äöc¾WYŠùÓóÖ"S¬]Yä¹®³¤®9ý•GŠ¸{¿Ò z»·ÀTä–ïk­PEc}½ÅàŒV¦ïy¥—æõƒ3»ÆÀzÄXÎÌ#wkLÕEc’¬éB÷¹ÊBh#ª¬x”Žè.†Z9ÐzºÉ&_KUõFG@0½I²ºCúpñ‘Àüò’u Z¤Ú&Ü*eû|Ä\g¨àqˆ¦ž€mÑCºJZ©ÙÕþ–ÈX-ÈÆœúª-Ækÿ3_(É5Œ¿jǃ°¸Aì~Ëâ«j¤s8§’ †Ÿ Cáï öè­¨ËÎT&ÈÙè?sI ðzÇ—"røt! íáà:óõ~÷€Š™tF5QðÀ±Ec²6`ˆqhºBØ mŠÛßĦÒËpð¨ØNJ‡¼$ܯÃfèî‘Æ«ý|R~CM~ÍÝŽߌŒÑ†G¡üué Õô2Då¯Æ«Åš™ý(µÄ®õ¨°? Wël“îVLÝöêçŸÞ~ýú§7´L­†.ñ­¢g+fVEUdm1É@±»‘ ¯NçÀ’«Ásu"@´öË~4ŸmÀ"_Ø!Ÿ_âÿºC ÎîEurÀ“`MºÔÌ«Š¯ ß\°UŒÄ+â- à&Kwm¹j²²®®šÅgØ^ù[=5WM1ÊUø Yü=SO#&þªþšÀU“Žê±`ápò*lDQ&,ÖµDVùMÐØ@©CwѾמÆBÅ’ võ?? ¨EQ]P«ç~à¨?çìösñ¬%×óCU:¡¬pTÑbµká|äºOlcÝwë®ûë>ñÂ÷‰ ßû…,Æ \ƒ1ݬ |O}SŽ£l°TñK¿ó¨„tДv‡-ÆïF«"®<ÌoñÈGFÈzZ¸ÙøÝã]Ï]£ ¼±×]Ÿº¾ÁkÇ [­áÐ|«j«â‹®¶Ág‚®I{è*depõ%¯ky7}U¿S“¤îNµžúí ÁÏ{’™¹¢¡9»]kÔµíZÇ䘷ÃF9T3"×Í#[æÞWnô«²ø] ¸:H^­lž£•MÔ/Bbesè;8ÌAÎ5ûú6µ’ úòcúñk +|kÿ„Fsó~†1‘³Å”2Ö{9žIªzÔ¶ 0ß%zz¾¢f,ºYŸPS6¾ÀtÙü„Ò£Rª ŒÚ+£ªšgÕ@þfArÇ"ˆv‹EEØ1eÌZÍ(•–E3òXk?nŸM= z%»y‹Wf$ð©å*SHœUëÙÀt&"L³suê£N!øyÖîãx„’Wù\©<ºÈ¯*†½4N¿÷"žÉ‹h”¦/úݬÿ"é¥ádâwÇÝÓ¡â›wÿçõ/ÿñ_ÿ˜½þñÇÝò›Åtùñ]ÜûõÓÑõÆ}ó!þé¿~ùÇðóøãwWTt¢b(¡"Y]dI¬˜áh´p>17†ÕbÁîïl¥P^¢åJ+㢚YGF­h A<ÙÐVîYcLe9Ê6/%Qcì÷&½(}ÑÉRÙäEoÜM_$AÒK’l:ãS¡Æ»Ý«eÚÿ¸øûûü×ëߦ?¿úíñÛÿ|ŸM–ñ¯»å›èÕ¯‹ï^øiñþÿ_Q£X–’ÉŸ‹SPˆ]‰ÆÞ,b…‘B]”VwõV®ÉŠ²Á"$˜¦8mÞ¥WËÕµ%“®³1äÅe"ˆ}²ÊHäý¬ëÇ/â~’½ˆü`ò¢ŸMÑ®,‹F“qâùQ?;ˆü}ýówÃѶ÷Ívø×Ëmwl†HÞ|ñq¹ò^|óñÝïÙ:~ñãö¯_o¯ RyCÖë£ÈáîN 7Vä^ªšŽ`“tWzR®ŠÃJI6æ„vD `íTùOæmržÞùjõn¿J‘Óùx‘•òI¯(û¸^mð )]yp1 ŠÝ.në ½äì.$©²ùšZSQ û ÝçCB¿Àû5¼Dßt±Ñ˜8rn÷£Ål‡¡â¿xi‘!(œÐÁãmV觵«ôp€Å€]¥'M€ì°ÌGɸyJDmlÑw½Ü;ôªÝ'ͪ:Á¢`!•®ÆÇIz8£·ÓP<ÿz¼ˆ.hqÿ\d¹§ ŠvÝA'Ya`µ6h &ÿ"{I¶Ëá +’ªwÏõÔÄüî9ÈAîŸÃDɘq`^§__Çrk—Áu•{£¸,ˆðÛ?mÝW‘^ã÷°6gDf¢ê«„m`¥VS!R’ˆø!kËåÇng~$ØžJLYÿ8c‘ø§Du©/ĉlBŸ€/”€‘߯“éRyU¤¾À”ú4Ù†ÖAþ¸D¿ƒŠG¥nÌ-€›£V„ç½±Ï@v€Ûf¾ý¾#ÓñUt¸Š%D>âw^ ð°Íàj‚!„9¶Î‰‡<9¨äX%¡fá'‰ë”^jò•æE}Å.뽟 -“¹äN#=ôíÒƒ¬G҂౞™",¦òpæ’ˆDÂI$a‰,ˆ¤:!iE¿ÁO¿°Rj¡"½Q¼K±*zbw9š²GRdEª" N2ÔB°ÊIjÑdbQjI¦ßölß¡„’†K yä´ëÅòd8¢#ù>\³lIÒl‰žµ Ñ%V•V—+‘ʼ$u¾**)‰ô]Š­8ª\’¤Þ‡D«W”ÈMÔL¸F³Dv²îw/$÷ë¸_½D­‹2/É+´u…Þ3—Å 4Š…v•ÅŸ³,.-îK”Ť8Þï +q‰}Ë|‘*ÁœXÏÚåké¤ÿ@Çšòù¦!Èi$Ð*ë?ØCXdXn«›/eÚ¥Õj²§&eöDÛ¦–"GƤ©¸”)—[EÆŒseLV +¾Äi'ó¾“ø¡¤¨3Jd"›t>Z"µ|FZ"¹*öÍ DŽ¤OšLˆÍ›T'©Xìaéé¤ì/É–´ØÝÚAû"|óûþSÖâ‘ ¨&È"½½$òço(1—é ^dÌeœ.WËÙ˜pÇ8Ô… ˆÓòî¬ÞLÓqö’MÇK(‰XôÑ%’&^Íò$ÁT˜´±¶ gG­æR'íŠx„Šx³ Ÿ`Ë,§ìçVöËyÚžÌÏÅJL‚RÌÁ'öuùÙ÷s’÷"!ïIkï’>Õ¬RÊkˆ4`­ZðÇ„¼'œ¨ËnsB×).úØh³€ñ’Nú„½U\ô¥éÐ\ôê¢_nPñL,f²â›rU ®PÈ‹wÛýT~Wú–è¶#ÔŽUg¿qP_wØ‚ªñùÒ®ÙÍÇ\ߘ1@Ø^Ë.ãÉ™mÎåûVÂiJ›»6°¤øz?è}Uöþl[ø^ÂPÄK3ºæå NØ/ 9±9lʬÑ0ëµÉ÷Ôëµ¹ÿ|¥v‡ÄÔ˜ß~»$¡—mA£O…!Òò­"Š¦)’ÐÂöªcp£HAeÐäiÊ"ݸ42ôd§à\Bw£’~šUcY¨¡KÜ$§Çy—.’Ó.‡³d¶/W•°w eJq)hº‚Ùöš¬ˆ kdVô© I· ¨K¦Šæ‰ /Æ·0xîáÃ}¿| ßÖ‡۔„G<ôsªš³ââßd¿ïg±y= EïS (q*\³*Ž«ƒ›8k +j ‹&ÜÖæ­ËÑÈÀ¡ ŽKïÚ.[òòãòQ©x£<%¶edLµmw¦?›Èâ¨1?IK ª~ÿ([0X£-â‚yå.8®œ¯aœ† ÒD˾gÒ«Œègw‘g +^kò)} 8“ZzAšmj‰ü\4Û~ÿ8ºm÷°C·Q;þÎͮϡ(Ò¼NºX-ï9n;³%EÈ­$KjRìÕë)ºÉér ŠnvL]ZXýf–»ß£—àüöëkÆ~ }Ÿ—'XyQï+E,t×æ³m>ª¢ü¢J=qL®¥9EØ°s3Kçh%O:øäúG³vÞ9ûŠ~Žæ«Ñ +ìš<ø[VtîýXbôyp_é…}Ï´If¢Ñ%ïs¬¤¯sÀÈÚ\W@ò!‹Ýâçàé!·§¨7¿`ç3DÓn7ç«1¹o¾æà¹^yBg·jQÀÌ$-—‰$Љ•”Šé±8$¥n-sîÀ~j(ø®7œ- õ®÷0_o*¸ÞTp.7«%ËßVˇµ—éQ½•CŒAL\úÓÁœ•ƒgåÓ¸;¼:\.Æçì.ìP»ÿ©’7‡ÅÏBóæx“Uwè`2…äd`«IªBr,PAº¹Ù*ùç8i ¢Ü.ÇL¤’DH›ŒT–éPR2ƒì¢\.GŸÅ—eÊÐÌF=”w¯ä„Š?Š1v”[qI9‚ÿÙ„GRÿ„×…áëòலÒJùôR1›W‡á*[­üߎf-g¬Nõì kG¡ˆÚ§óágêñÛΩòé>®.¿_]]@jŠ´J°õ§âø Aä->¾` dŠŽj”ãyÐîE|w_K|—˜(b‚ç…6f©ØŒ½eeV‘»¦|h«Ê–=N#žcÈôWŠÀcËø†'´:¬úöÌŠ¯ª; +³’Ç)‹z¤Þ-wL úîZ·ßÒD=§ìÎÙ‹(Š>Ãcér{K‚³R—Í·¸±\¨¬²2½!xà²v½ù‡Bñ.×],5åfÌ‘ã`H 2A¾‰Gn_Z»ïWãtãô(ñÆ2Ž¹í2j4سµ¤’”csö{P‰ÕÒ²Ç0,µ¦3q~KÕá8}ö$©PM|yõ&f¬‚JP–…|»z×{ ŽÄP¥XðôÛ<ÍW r’›[B6¯G¿Ï&ì>Áó•D«x$#zøC}d¹uõI¾ú$WÃGá:äVóƒiw{†ZòÆÇâÆí}ó)íê’|–tuI66egë’¬lÀ`Uµmm\è>RB ÕÉQäQ]NUœ %¯äX—\˸©”òwŽ«úªîL¹±t]„´ÐL3¤þŽF`8_•Ë@K}t#R&ý©»Þ}ÂÝ‚ÉJwÖ¹)”±¡]TJæ’ ôöòÅÀ~RZ ôVæù\öÀ0ßjî¥ç¥pLøuG*ŸïiหeXÍÇP ¶•ÆP\#Ð@ß¼ÊKA _+»æëÍQ¹.Ì^£I\Qé ñG“8ä¡ÂyG“º –¬D‚¹àL‡÷·ËÎ&›S·èUgEÜíЗmm<®â$7£[¼ªU…¢4:­Ó ¶µ9Ž>ð§¯ÎiÙ÷J#´¨³MÌ…iÀjƒð ¦íY®yH‹Ð÷VYôµW¶:ƒÍ줱Õ+„²¢" +ñLrÃh¾µ­º¬Ù‚l°¬A `6^-H¼¬zR‡~RĤŽ')J„^ycU”F¹Ê–êÀ¶F¯D«În‰Ã]\X15[ ÒÐZH·yºÝuÀ–»¾æ:GcUiY«‹¨¾Îæeäl×}Ø«¹î¯QF´U}2r2ÒF”‘%Où(#eÀùÀæð’ïÁ1CŽ`8õ¬û ¡Gb)1™(|„ψù¸¾Dfw!á\³l +,šGê6ùÓI–È¥•p>U¶õÊÃ94Éqw«ê°js +¥Ä«]4 kb©HÔ ]—jD$±ø¼ŠÌî€$!m‡Äâ”*Šb^¡1Ì> O`scÅN¡"Û¯ÖêQYüfµÌš_©êB«ð¢Ük÷£ý´u;–’Oø=,)Ä$Êh! S¦“‰äÑã17_ÜwûM&BYð =zICÂØâŽøµ sh‹gÙvImÕb9¹÷bþj¢à°vÁ¬,ºYPƒ¤Du‹eýW㣠8Ë;@L»Óý3ŒÒ—[­NÌgìÞz rR F bYò—'eµßQþ––~yy›Gïƒõ‹g~{ûßâCY¤®$Ž;"qe^¬&{’9$/HÅL +ǪŸõö:°©¤ ‰hÐ!YK_õ00’°‹Rð0¬¬*²49ز„d‘Á@%Pzn8–겤.ÄëE¸WèóÄÑ–âß)*ÈV$gŒ§/†€ñ)#Ó«Øq;J„ÇH„Ø(çå y¹aØ]å%¼¨[ +¬fãÅ‚­AÛ,‡¯²¯n¾ +)Ø,üÆʪÂWû¦ŽJ/Ê- «&Å(Ó3‰j +#–h‰ç¨ŸWbü<¿¦³„R‹ÍLJ5è[Å\c‚‡Ýbnk»4¿Bæ_xÞOEš8}´QÖélžIj´qŠ@‹Të«i± + *}…“°<˜|ï±j ש¹~¥ßrc¸u­­²…p³‹e¤ ù¬@S—(š:h̶%5]DY˜ú‚Ãá¾üþýdÓ7K’Ó¼d1˜‰¥L…HÛ×ÒŠÙ4O¾Ð­³Õzžå)›ÈØ@¡CâÀ™.èaSÑrÀ5Ö­í9Zƒ¸ÛìrÁgG ¾RÉ 5XN5­O—蓉•†´tˆ´›éÈ ‘ ¨áЀ¡8jc3pë¡^ýˆ„ÒÌ1a@@'É@:~0#¢dE4 ùÉ?apÃËIÙÂ4Üš~ ‚| °…Ñ€°èµ¨p3£lg4 ÌÒHz‹ÖÉ@±5Èâ‘žY²Ñ1QíÔàÈ1ÄÂèÈeu4fG¾È”òy<5ü}"â†GÒpâ‚L÷>¼Ìúh žUt2chznÄËô£ú2U§ùž85Èï™m­£]FJHIõa7ÁÔ‚¦§±œ9™ê¼þv@!u `ê@Õ„ª2AÁÜÏŸ(&Tɦá@}Àm“É=éÈm“žôðüñzpCÉ˨T¦H&7œ ôÐK‘YЂ­€·†í—Tb»È…IUñ>å"þ§9LÛ€úü§»,UKÁÚÄëOOáXø¶’«Ç'×À71` … ÈÜd“¿a-ŠHÞÃ2Q½‘ùô¤aÃÔÌ´ÞXGwwŸíhY¯¤ˆ _!‚†â?ÊøQ¿Æmf)©|Êõä ŽQæ¶*U˜i‚2GÉnWÉ϶µ½'´¶:›lœÍÞƒ× +‚ƒÙ|ÛI—“pxŽ+H*?6´ºA±&Ê‘R Ñš^±ö²±6ê]±ö°X{”}vÐW°¯Í‹CܤWq¸²Ã_bPÔY£DÛF@‹gQqôc¯xú ú›ìjø\§ ´‘´Ž–«¾Å,µ©ì +4¶á0‹=ZVùÄ°¹×­„Í]›¥)+¯ed‘I5-£Ç4av-#™hl€DH€x!œ( Æ€ž„På½ù‰ËJg‹; uÍcÆà“Ö¦"% q:P4:·“es(WIïà ²™Ü*X\ZlQ&…?¬6íÊ\A0ï$9h¤úT&íÐ0xj! +üEîðyÃZSXc“¤@[çó‡tûM¾h&͹7x´6«]6Þe0B·Ô¨¦n­¬[ >h·`„3Zd»éBЊž B%^%„JT„Ós–'3Pþw¼‰ç (üXi{‚Rå-¡dä€ Ây˜èô#j.~bD¾ü*d´Ÿ¾:p‰®{ ñÎþµZ>‰}g¿»–¡€z…‚FPyÜÎäy¨ +Góõz%5òÌ¡ Ÿr üFÝqS× +y„ëîñH‹öpÆÆÉ3‹õ)#EèWAŠ@ +¹iP™‰³Ä +<‰?Àã¤xî€Éê 0æ¢'Ù¤ƒÚÊ;‘Y£ˆ< uPÌê~¶¼‚ʹ‚JRIü¸DPñ¨0JX"abÿzq?¦N=ˆ]3Nòú[~=6ÎÚ¡Ñ‘øPTô5·½«8åh¡9JýTƒ—xq5ËÏ@³C°R‡Ã"Ác©HÇ84.çE46&¦ Q¥~)ª„°'†X(ÿWº ›!«tÛè@\7zd/Œ(Bš{H›¹ýÔ“Ä6bôP^°D" Å;<Ä4:¤Mݪóáa6~ ã›Õ‡b£«åýöKb úá![v°ö ‡1ÖJaGÄl= wTRóúŒonŠ{&H_ëV‚züu‡Z¥8Oú(:`¿–W¨k¬3AÀ>UÉ2ŽœïÝñ«ü¤ëZõ–[ÚÒá§BÕl"7lÛIw;à¿èíŠó [Ш¨«4ŠUXØ:e…סfA©61‡´áf¤muºoÙžÍ6áEÅ„6f›!vkj¹_Œå™ÍùRX[/Pg/0:uRPÙ@„Ø€p›O©Áð“ÊÒJqevo]Hä𒔺Ý/€§´P.‹hEÊ%w6ÛŠ-¦½T/þ“• FÒ]´Òš ò¡Ëšð"1õüp.‡K†Ps†.½µ/Ï\Ë2PJzå›CTàe²«ôÓl-ÃØCH”+뺲®+뺲®+ë:ëŠóX—eW-Ô8Zt9í›@õ,ÒZP ,‰r5»ÐžÌÛפN%5¨ø£¯kZž»Â\Ä•™wÓi:dÃN03Ûúq«HƒBuGy˜40 Œ“yÎœÕ#¤£4-Wi{ʆէóÃ7ïÔ¾œ®Zª†cº²T—ËÉX4Žé-¶ËTÞºLbÇ© \Úv¡ºˆè[· Më MªTmP}Ò>\³N@Ó\ü¦‘! MéUY¼m§ §œšvD¥¦ê“’Ök„AGjdT:rþzÊå1_Ý[Ö‘%kMp *¼ÍÐiÇHrA¦Ó)¾3§e¨úÒ°Ùêka³Úœ­•§œm|qpÍÙ ‹"”Æx9¹Ï‰ªØw bÃWCßìÑ(A%lPH¿†J«‡Cîu«jÍîU3¬¹ŽØ©´±Vwq[ºÃÚκc%ÃÛÚ,Õ=óqÌWhX‰Ñù‚ú\ÓÄ›SÞ î9Í è<\rKEº%N¦xWö½Háâó“'›éihYtð¡YCqjgñŸ9ÈÀƒýri£\æ J3)³ËÖlÅIó‰¥3Í[Õ8•SÆÌÏlØuU1°T+ªb˜êË5×›áz6Ñ»±¥K.:Ÿ˜‘7Í¿Ü/†Kð$À‘ÞòO,Ÿ Ò6ËYYì}¨ÖH.a6Ù6Ÿ8L‰HH†ÝJ@΃Hj"­VÅ\» ŠMµýaWA•ãKv#°ƒ¢/ãü0§ãVÀu!€ÙQFFÝðWþ#´ê¸³D;ï0µ&»8_Z´r†<%Š,ÆÖ435Þ’‹2mÖz†ÍÃÕfíj³v6k>øLU4Zû’ +,4âûå{pã]¡ºeÿ<Ên‹n'%¿yׯvo‡µ{“8ÁìßúZéWSëÃ4÷€‰|®4§b¢yÊUæqi± Qölu†à4”îÔc,î% Ôq‰ÚyD]°qqL™¼Q¬°qql˜+Êç)W{ì!,ac{}KɃ^äe ™\ñ±) ‡J©bOnè$ætœÆćíø³ín¸ØÏÁbq¾º¯†TuöBòLw|-dYžQT쪠jl·Ðw–tˆ}0É®†ç„ê Cg¶ŠfQUcöAPwƒ¼¯Å™îŒá‘ÂùvD µäxäKÓû ôå)éihôKÏ°Û›.Ÿ)¸JŽàMߔ؎ÒÆrlê”-laQ\dp‚ÒK£ÈÁ½Ö9¤2´éÂ8hÛ-ˆƒ¶¬……pQZåÒ À¡Zm…ã×Ó´–?lkg^ÝÊSÎnÙXmÈvm=hKŠ;ËèÕg­eÁí¸ñÓÔQ£4ã]Gnk«ä²C%T_K0:‹l»µÛK´‚‚p!‡­>›¸P'œBKý[y³ šâîÝÜÃmÖ(IB/O¼PUÏ9¢TWÐ=çñ:%éçÙ½×’tøÖO#[Ù`¶‰§œÐ¼Ó³‡9gxš¹e¬ó9\«r°°#[³€¶Õ|[á•/KêZK9ÈaZ`o°ó í8ÓYR)^0¡'T喘§ªÀ­£¼­7‘‡TAÖŸÀ‹U<ÖR:Vž´ÃkëOÝ…ëë+OfkÑSkOÙE¨kªkÎÉqè֟¹§£ÒjEU]Ž9¸þªsÙZ«f«‚-Æ%ª\<û6aìÆIÔŒ{¥ÔSe76Ïu„NEÕE¯U¼‹ÖòÔÒðT^ò'×îèªâ³ÐìLgó¬œ.‡Œ:Øå£,ÃEºLïyƒ|ÍHyHÔ“tŠ"ÚYú‘¢F,;gHƒ«¦/îËñ ØëλeØ +âi¸¦¿µH‡’5ýæLù¾2¿,yÚ9$ól1Ëñ'-?.=’J«»§õšÏ \‡ÛÊTCr¶#ÝÞÈõQ]}Oã™Âà:•Fær´Žá(p#.ÏœÛt vsìžÊ±E¾GŽÇ8>æ@æ ÈåGñL½GuØK¹Žsì7³–Æ~3kŽþÖ€”.‚Žµ‘% P†^w8Û¡å“oó‚PÜÓlìÙr?œÏ–â¾ +'öˆfŒü¥¶züØšt…Äç“Ì{ßVÝ¥:'š³g.¸´ÞZ9©ˆz `ÒknÊ8uûN4› +‹ZcvfaøË CÙh”µÆãÂüWË—ÓÚ*]¼ Ýi™qC{îѼýåF‹m¨N:8×)b¯íŽèëi¹?<»l³L±çê)÷‡'Dz¢¢Lr»Á:“u‘&ƒeæ®Ðb°â Ô`°æ2»íY©CŸë‘¹Æ£öQ8ùPVKØTBQ ýöëk¦‚|º*(OJ𢞺H}¤h¨$™ßvÓID/û@É;`ÖR¥ŽK*!¬pMx|bGÀnÿM¶™¥s´¼'<ó0&4+½êÃ>>¾)oä´ÅAÓ±eE×ñÛ1çŸê%”I¦xiÿ­’lŠÑ&Ê;TŒ´2”ŽÓä¼Ô¦6¨üúü…ÝuºAZ6ô†”úäv›l2ÛŽWï­Öf 6TØ>}á"åV‡7*HÿTÈ)W­í’¦ÎU· —ê:ÊÙ(ºÍIøÍîZ@Rj¥m€W~p(¢]"Ñ·BXLðРÂr¡ êö²À_¯OŠCtbá½çþx@Í8Y%<^„Uâ5/Íš—¢æ˜&³xºÜÜ»,{^³¥DÚSíÚÞe3Š2ã.õ}8Ý E ÝrÁ­ÒÖ›ÕbµË,Wû’jñ‹ÙøÝ#»™M¹Ùˆ ©‚^Ì WCËIúŒíšå‹teãCˆY/‹OŠ`*P”ôA*äÚ/5bš'M~¥ØšlKX•‡$–s)û-­“‚H§‹®f×wãjÖ%¾ÇjìØïcíGE×ûX O/®÷±6ñŠ*ר¨[ù:Öë]ª‡½Kõùw¨´Ôí~±ÀbvîWd0®w²–¹“Õ(%½~«ÄõyQÅ÷†£ÎþÊ–0 ùì}veTWFueTWFueTÇfTq£²Üyü+ÔøWt9üëxû:·…ZÀŒŠ]š“ãxÕIµ—ñªËÝï_†Ek¦È<ΙbÚµ¡tÃsžäïÃÙíF†„Ó½J8W ç*á\%œN$nÎU.Dê#Âý;¥sš MÖ&ë¤óùêª Í%n™à»Ž×ùŸ±¾ìøèïx¾‚Æ|÷¢„óÇ/¾ìèýjV~ûòõú ·K“¹BghÇÜQ‡‘H˜¥•¶C.Á9¢^ùMò•‹\¹È•‹\¹È•‹s‘SlwÆ\Ž¸)*ˆÄXyûZ#ºIùí«(\Ù¾ò£ôúÛ×3 +Ê☩².:¦ìÎ_uæâÂœÑsEÞ |2Ã,ÞŸ³+©²Aõ@Fçî ‡^æ餢n„Ièã¢}:ìŠN‰Ûðà(¿¨ —FXî@– |¨‚íØ4ãÜ (ë5»Õƒ2%J(pÖ;ú ]F$EÆý¢â@V¤©Å½å°nv4qh"\¨t9Ñ*å|JPIä®B:Ÿ"ÇS7d›7‰oÉ™;’ÊRõ¦ºã‹ó¾Å ] +Ðx:6Ýz#Ž9³Å¹ á6O<-5âÈ êŠc]c*ÀºöÜ\dXm×d¡v»æJRlÍâöÄÖêjƒ]ê“®Ê} cã­(ŒYüŒâx”h¹j¸‘žôâ]÷$ê”»x7žžAÿH~9") <ý¹ÊðžæÞÇà¾o¨˜}8ížWS1{À&ÕSÌ¡Au³‡nÖˆxû°ÚìÆû*—;£–±\b§ZîJgU–·ê •ïsö¤º.õg>=&Δ½è6gÌŸF`œ1r‹Y6"¶¨ýŽpdõù¹ÈË#sf©(LG¹¹:è™yå9º¨#漹ɹ²Í•Q¹^ )ð®†ÛQmòÏëFÈ¢æíÞfηè8öB ¡—K;_¥Hh.Ö¡Üò¼#µ‡Eˆx*y¨ŽKÙ¹÷*Vçb©ýÍž<ÅG¹zåG×$Î)@r:ÿ¾Äꄘ\êe‰»ôãj¹Z<wÙfQáÖD0Qòª#”·ßaÑÒ 7œH«ÅTÜu³ÂÊl»‰ë5V¼žnWgóMhJ«w·ÉŒÒ¥+ö$š"E‚z_âü‰èµâPV~ɳãÖ>QÂ'k³lñÈÇÞÂ~ùfdÙ›½uXž;æLÏ(S®º,H÷rs]”‰{Ët‘³aUî~<Âò4qÌ}e7øÈ[^nÁsº¢hÊjšjŽÆ…©›ŠG‹‹²uÊ”=Ƥ҇­Ýçã1UJ?Ðý;*`xwœd– +CÃæóß㪟j.¬‹ÒAMNÙÚzâG”ŸÄàÆ©7±µ•‹²ôÎ…‰ ø.ÇF¡êâ`Œÿ Šôš«äm;ÁìåítÝÓw®›Þ0p,ší|‹FßnP°vÜ&.ï[5qi[[æ´÷¹V/ïE‚zè}Æãr*¡&_ëR®z9D‚e}³a‹_NÇ"ÜšSÏ°Äžz_ɤ!¤5UU©ìkÙ3¸á.%í0ô!\h±uêpÓl®…t<ζ6¾ç´Í™kÐR^¥ýºû²Ž?Š”¢lYÝÃÞuØ +®q» E‡«Ï΋]êÐÊ!nv‘‰¥ÄÍ.ǺÒFHÕèæÂTBŽÁ™-gî#jAGgVeLîV5:ùâ*R´l÷È–=Õæá¢T*®YÝÏœªÉŠãfwÎð¾Hgó–°õJkkÚñ>QñžU(ïmÉ»£Žbžþ­:˜Õл%,œQ¡ŸÛñeV1݃¥]”¥…kT6Ù4Ûl²É0,f˶…ØǺªh”O6fm‹R=y´.~œ6«yæ^r>Û—âdyº¡0²Ä@†¼4€#.à°ÒÖÃkÀÊ3 Xé3Så ++¿¤„¤F|¿|PåAi)Ð@ȺÓ-Zš”üæ]¿½<ìĨ­‰ï²æ1þ ¤ìc‘Çöý¨<Ï|¥Ž< Y]m° Ç8@¤¬!xji$à Ž–XQêƒóºsB +”P±h~ôòt«6?GhÓ§ÄÒõl¼Ûol÷3Q}@åW9yÁ]u¼Ìeå²®Ô]/¨{6ÔL\ù†º„âë%w'¾ä.`8JQïün¹Kô^Ô5w=c|/èž;£ñçxÑ]_oäÝt÷d®L.€ž=?aÑ•vµ4¼Ó.·ô#\jç‡sÌSåíʹ¤‚k—|H×®êzµÝ ÷k4R™>žÙÇÙdCúÙªà 0½ÐãqÍ¡Täp“Óùx?‡ßãÕr:»ßoH4cºZ'Ù:C+m9že[îòÕ{ËìÃV)Š +­h€1‘àþ\QbË2† ûõp²_ÏAÐ͆$ Ù ’ÕoÏIÆš¸¥@mz3#²²ääQÌL à +­Õ©^rCê¹–8›Çú’d:šÍa$Ù.eëBÎÝ•"çÌà<ø¬LØb€÷8Ì2db=k62[øp„¦áC +ˆ·‘s÷isÍÜÛô=jì-áÅ­ãõfµÎ6»GÂE‚ˆJ¼DÒñNÉœN&¨oÛ-dÚd“")æÉ»[ Y˜*\PL¢QŒß)Mf[Àµ!þ²Ec°{.fÛ-8ƒd„-?zB"Ð ª 5Ö ¨ w¨rˆ¡QŒ +¡M\¦-Ÿƒï÷”Ë o¶»G”U%‘òËôjÆ‚¯ 4Q`…€eÆ×$†¥<6—0€O;•âfôAªAqj 0i\/Þ^Ïçhélͨ1 'XÛÖR-3‹Išå¨ç÷¼^7IÖƒüÁéò"ýÈAÁV§ä? ɹ)ÞO_Ñ—(¿ô²!EmVH*ÞÜ®S´T6«ÕΖ»Ïxâ¦øˆœ³eø°÷~µy”ôñøƒì}KHGº…Š¾áÐÙåøÀÁ‘pF†x`+LòqÐøBæu‡%<ø™­[z€€6‚;Ú`Ÿ¶x‹ö„[¾¶Æïð›è+)ä"zŽ±Lzóû~6~µã— Í–Ì€ßtI¶ñfE­GD.4‹ã‡Ù2rûæþWôP + ‘²ÿm¼ß€¯õ_ðKŸŽjº#EBL·ë‡5~ MÄ¡‰€®t¿[¡_ƒ´ŒSFd ¶zg¤y=¡aÜ +¿KH²âΣaÐ%¯6ÛííÇž,Å”“5Æ9 JÚ·ˆ:öhÁlßmo ¡Ú"Pù‘çEEýÓ!ú•D˜¡o!¦<ø`‚ƒ_>&4øåaƒaébº‚_ &(øZ‚ñ Ù$„xàŸ˜ ü ½ÀÏ€ +dH B¨âú@¿|BØF O;t‡Ì7ú)ÆŠ<…è—OfÇ?Âó…§OMBqº>kNìó|^(îP²^†Ž0¦#&tÐÂ.Ç°G9ì³qÇ¥·EK¸Ûæ`2°T_ì º{n(¤ï“!0¢ rÈqn†aöLuzlݪ Ç)j2Hç íœ);x™X~ )É>—§…e ÖK4-ãÒJy3c´·é eï3œ–ìJÃûD‹¥§Ñ‚!vJìû&[Ï9Ô†¾ø²Ì>0 b‹#:à(_B& ä´$‚+9(û‚GÜ\ó Ã-pïVÕ^aè–ºŽw•†”™H•vƒú…¹ ‡uøJøÎ-ݱ|Ün9ÈC·ùWe÷K|Õ T!Á{ÔSÜ«|š–ˆŽ´ÁXÝs^fWô;Žy&}öégàw·é?‘l€fø~“‘–3öw»›­·Ãt>çŒ +ég 0ë”AÞÂ>bž"éÏÜ$£cH&[ƒ +†45ô)*Èß©&~(‰X7xj:¬ƒ"±‹P¾@<_´Ï!»_Š XZð å+äL±µtºÚä„lp¹ðq+o Ã.]Iâ39’à²VØ£)Ävø†À¸^Ûi¤>"fAd[6ÖëPyOGEQ ¦yÈæk#Mh)+I¤4‘¥=Mü•"êb®mÑÖ~O¨ED½ÛÈóñë®þ:į{úëè†ðg:ä5“öbì2ò÷²ÐG©A؆}ìSÙ¦†%MDã€ý@ +ÙšNV–óUŠBŒ—4—[oÑk´'ÔSqïߦ¨uD¦dö¾ÿ†Ð1#o²¼Ð@ò®kGãEV7~@ßú”yÒoXdÅ+øöaGÄÅÄs&ùç– ^â«™Œè¶é†ÈK¤èGJ:ŸO´o7ÓÝê]F*Ðø²]K棉Ƙդ:cNh¥Tb¾ÝîG= +ÏeҥȆ¹Î&»GðCxJÒûJ‚_ÅjO¶A}2üø5±êFŒÝS^lìúlG@K µ.câ5š‡Þ-tC:âÛXôˆ$ÑWÂÚ‚¶K|dË‹0Q8 ŸËPÕMä̈-e;©a]¹½ø#~Ý“ÛKòp†àÁ-¦dL4Ó ñT¼r{ž=üCS`ˆ¢{m*¥rw*©¬vï{0Ư#Ú™“(BH/¦Ø%§Ð©—XJQè²×US°YêéYR¨eôÕ–È3ÉÒô½¼¥"UØPÞ—ðsø.3¯¥B†ä¶·_/¾ˆÿ#|ñí.GóÿóÛ_ß|ükðÍ«^ÿõßÞÏ~ùmýÍÛï~ÿöÿóÛÿœÜÿ­ûÏßÿÙÛ÷ñ߉{ãùæý÷Á›ÙC’<~ÝTy$·D¤C*8„m‡ÿÜßgÐûmÐöÈ2LhÅ­f½Q?i©‡‹Õd?ÏK²l¾Â˜˜~(M¹z+Ûb»*˜³ñj“½$¥l_’ï/áû–¿Ô2êÏ·³åtuû¸˜Ó£uìÑ¼Þ ؉8>Ý)ò:û‰V'éˆ^§³ŒœOJKpC¸ép¬UJ>Ñ?J>ŸgŽöów¼ÝIÏ^i·žÃx¡Öáó:xñ%‡…g’«…#4HïÑNã>Åš7r¡6–~ª…Är!‚`ŒA%i-U3ëÏ*ÑøFeCª®BôâÕ¬’akö6hª[÷â°ÏˆšQV*êÓzøœ¥ke›óÚ âÕÏC"án‘̪›’D{T +„r)±4ïk… ÷ðŸAo£t;ƒ÷`¥7ñYúi)DM>8¥†_ä‘~³;êAŸ–QV*BKc”MEûÑ ðb2|’™}\¯¶hÄɾ~Këb™øfPî4€ðØ&¢X€ÀþyÉ“ˆ_ê + p1–F0:,hªýµÑÿÙýp=£Ö»íÐü‡ÚÂHdGÛq¬(‘ñ±¯Ï/N›1GÎ*±„R+8©èË´¨jµ>£qCö jÙð…ük¶LÖ:bb0ÆXN¡>©¥õ´Òxg{ÝÜ"µEX^™Ól$‘Ð>¿GEÕSÌw}0˜0=©ž§câ¡‹ Òwù· ]ø›¼ô-“jk¼4f&1&rÁbŒºÖV:‹WÆÄ10+«¡HqÀü:‘É2zq¼Ù/F‚†ŒEhí³äµ¾4ÐÒð‘Ö…WM¢x;ƒ`_¤Q.Û yŒÕg“LP×Þ­WÌùÆ þYúiâ8ÿ4$FMj ·–áxmtƒØ`TcÔ"ôÓ˜‡ñx\QkõgQÕ)‘Ž FÅ>ò³¼‘±.ó2´fÊu¦€¶}´E:aà/ä_s2à­zHV­<‹ôS)X6 + Ž—Z¡øûk <³EÉëÝ#1Dj¶¦·Ñ^„óƒ:0hcžs+V*3+è©ÜèéøY\ Eó•A¶bCx¸¶+…¡÷ü‡:#]‘sHÌeÛƒF‘OJl)ÌÒ`{ yß̵ÃÒŽÓÍŽZŒ›u ØØHÅÓZ Ü5„*ãwX7Kƒ»Pšð¥‘ÕØXñ« >a$nÎ’qª—j&ƒ§óÏëô‘-1g©4ÝK=£}ÝI ¼ž‚ÎáhQ ,½QûàŸõfFè2w|pª—j¦œñÙ lããCÒ½Ô3º[ '‹Å­Å©^ª™ì0Ÿw a&FœÎQš—r÷êà¨Õ-¿2ÕµhÊÞžT¾ÆeµÊV£ðYËKCʆD•U]R&ù·Ú£P)«2†pØH–ª¡:*ªH.ÀõÞBTKÄÓ彪!::ê•óiº*“p[U™äý£*rxÆ¡P)Çótû0äÖ•´Í¾gˆ0¤\Òè2Å”J¤6г5Poš¡1.hYA›Ü­ñykdëÝñP•wûeÊU@Ñw‹AÒ¿Ç–9N +³¶Aä±¼rÕ4ƒ“é]µš¤<–W®)gÁ ±«M¹#Ág ÓT’OÁHŸÃ¢¾ùÈkÏéü`/šp—ÝSç ç&Ú^µšÑõÞEç+p=µø,ý4µº°{ú}Ÿís´ºvS3êÏúÑ +@(· cYÄ/cëÖ‚[6i<#~Kÿ˜è„_ù1_Ü5rª$©ò`r$é#óÂ䜔«@ë½½D×{W79½w“¡ôàê&ñ¼ÕÖCq7IÖnê%ºÞ›¸LÒ}˜ý+݈1ïÆF[¬ÕJÙ,¯ ‹Ò­´TJi1”lê“®EE X[?ØGþÃÐw°à \ù€§]Çh5‰öhë1ùìxy£Œ>‹Œê“~¶2a¿ ËÙ +þBþ54‘ø­FƒÆé+)@k™–Ñxa´íÌÉ +ø%ù×Ðzã·ÃÉ~±Î6ÊÀ©™µfé™Ì7ŽŠ¨¾¢ZER&óIW8Å}¶¤v÷êÉ€4<öhœ”ªŸeex¿[P²­;zAŽ×ŽÄnì²U¡-·@Jf¾1Ø3I!ïâŠgEšë.n2Û®çéãð}º™¥²eH)–mËm}©ŠC0L›ý:i$‹9 €NHJI'ˆ¾¯ïVí-)S^¹TG˜<.ÓÅlŒý"ˆöÎ*©›É,¯ h&:},5Qpâzo(ˆ²t3ep¬cQ6 Dhƒ¹qñ<”TôÝXÕ$½MŒïétä—t®/f{~Çk]”Ìt>@üm&ÜãP,U}"­æ.cûHbJ]aËMFÚ½}˜­ÅÒŽçÌ•M—\ŠË.‘Ä0ñeY6ûÉðA¶âé•£#[vû[ƒ‘ë=ÇS`L –ÈxaXº»” n%†ÞýÉ5‚"i­T³ÛßÖT’Ý^)HÎ)$ìôDÕ“öJ Êf^Ë+µû±¨‘FÒÕº¬¤s4À(ÊýÅDU+§”Å1— &ÜÈø#0Þ"LœaÿÔ²'ªŽ\ï IN—}D©ðjªBa"«ùƵªp +ŒÄuO•V•šÝþÖEÈ8ZötR‰¥¼–WÊÁ:¬Ñ10 +’›ÞlVàÉ»y?“!¨\£¼–W†˜K’pj/%ˆŠ<ÒO•‹Âºþˆæf&.H¤ÇT–óHS7j¯³¸´2i Ý¥- Ké¹\%8?èT¬²n ñò¯A*46“N¯u9™¡¶ØÌjyePŠª3¬!ÝIX·ÁâÓPØbtuõl~ù4§ùÆW¤nÅCAe„­ê/ qš&x˜m –ÃFâ*†+@N…ZvÇk]Eão̬š(ö‘ÿ0Gìƒ[qÄË°5šfTŸt³x®Êbð?–Ƀ˜ê*584ÎNZ¥§7^èznüï$Ì>«%‹"MÂŽ¥ÂrLߥ"åW*Í"›å˲o"Ÿèƒ_Ñ.²ŠÃ–›÷—%–[`‰£pâ궵lšÇòʘ©ÕfQñD‚g¿ ,^mö ;Ãò¯I–ð¶SpF‹”ŒÆ •«ÞÝÜW ÙV¢WðŸžò!•Î'¥f ÷ðŸ>œÜá g·zÂñ_úBë=ëB†øC#‰Ã–¬åcmC3«Ã¥æÒÍÍÅfÝðfq•. +6Ê”ãçYÎóé7ö×@ñ.Û,%­b¹£:-£þ¬“0/d%aü…ük0aƒIƒVƒ “Ôæ9Í7†,ART“:E駱ŧœ¥èõÔÍ–slCw$l„jöl¦°¼26‰f!‘vs ×zd/ÇõÞÕ?œËù[«&ÖLfy¥2- +6âF%8:Xp!Á¢rœ-¦óùêøQ?¤›t ¡†É.µ…lTO³Ü: ½ËY€kbùñËÈÐüÁ—Öî+ÙÔ'ÅßQß¹Ð8‚/ø]È”-Ñ !“œYÌ€/±ÌzÑ04lUy™¤—¹ä5„už:›g Å"Ø0uµBÉéxmL,OæDCWuj-¶)…pVVÆ ð?ÆŽµGÏÝ”€RmËm}ipGœ¨@VÆ­¶T(¶ö¬ËÊäÔÂ*+Ó òÇ•Ék÷žÃz"e’BÒ·!^q«5ö Ú ÊI·$(¿q TT£­¤¢ï†þNNÏÎo2«þ®°9<»ý­!à0¸x:ÇÂ+?Ö#•ùÆn#ÅÜá@«d¸u43+¢]Í/¬è»Î#pòj»žEüÒÅVüê¬#ˆ¶ñô¯‘uvÏl̬äûk8ÈÐ÷Ãñø¬_¢—nH§.,'9BÖ;\¢ØR‰Œ]‰Z-&89µ–©&+Ÿé˜D¬§2ß6,…æ½ÒÓe~6Gö!”ýX,ï ”æi„½G"¹·±Ïh¬¥:¨I‡ölhbµïÃtò²M˜Z³L%<“óƒa*êJw@ì×ÿ“¶j'…|6PXOÎ[Ö+9,F…öf˜µcY§Â7gÍfËF’^æÓ£¬Â†ØÆ2€p÷a3Ûå¥@E-ÇñÚ°Ö”Ë^AÁÄÀƒs¬7Ù:ÝdˆC|½©¶°õ"œŒ 4K¸ý=ÇW+¿ò‡ÙýÃðCºÓ±˜iÈÀ,7Ž"sü¨MªüdH¯tŸ¸ >lEéÕ–ÛúR­—›†ñëc°hƒ/×£ iºWå5ÀZLîGÄ%/1Ú¨ÎÈ“§ë­6‹ÔT"‰Ú~Þ‹»ù³ëvãOóû˜«Œª»¬â•Û´Ø¾ 8…‹ i”LóJ«FÖbr?ºVæ&û}?Ûd5W¦’ÛúÒظåU'×bŽy/¾XPWeçõe³%«íÉi`ÝÐ8¾)†ŠQþ¨íºû¾£@­ýö\ïu͉™oÑLÀüÁ˜$c]·gW[ªç3^"¡žƒÿp´æøR0‹±¨To~î‚φ|c$ÇÁy¬[ð}'¹¯ Ë,#™¬ï3Œ#ÊTÏUYÎ/ú†'ä«W_X®:Å<[eþE68(KJR>ã…"Aöbë2ó§*YmïŒ=šHãÖ¤åVG²ölì•WÓé8]¾O¥Å¨äj¿Ûgp­.5¯ö¨k V¸›[«‚~c ÓJúžq&#ü…Ž>¬<ÒÜœÜyß Ìfi9—«V©Ç,¸§,Ͷ¾UpOçÚ|eìMÐ\ŽVûåÁªUâNëºT—²ÌUˆóƒqp(H +3£§‚âÄO£ÕµÀ(D2øw¹(…¨Gt‰N£†ÿ€ùÁuG«©jŸ'òH? hBÛÀtæ®g‘ª0N;U™mït¶WîYÙ>|Àÿèø/éãÝ/Ù6šEü2–äúa½_Îrb±lgäF­î.çTÌ©›§÷ kÜeɃH|‘~¢«H®œè*ÇŽsÅŠÃä+¾aeËrB4måäq[nëK}:¥«ÉU³J]ü«øeðþÅ-ˆbÔV«Yuƒ™ÍdŠG×d¡÷ðŸá¡€ÞaôŸÏÆر ‡·ëtœ‘h:&B1¤9îŒ9ŸŒå IñP«õvõ1UëÕrØÞ¤Bü‡†ä>¥Ê[7[nëK㜆&ÂaVsøÍnÌÛØR‹¢ ¤¢ïߥ[N|ªë»·}Ž¶èy-¯rk Ô˜5vyƒÈÉh/7äE†_žÈxa(&ôT„˳ëLŒ:ôÞ¹ Ìý¨ó¦ Tƒ7mȈ™fðÎB“wð â—!¾ó/9â{^ÑT¤Óž¦¯F«Ýv÷‘ôX:ßàÄ/ƒ1‘kIÝr¿ +E¦!¹ßŒå¤·úY=»ÏÌrVO>Ñ?ß%¯‡éz¦ó]ñEúih£Å§ád„Z‹0_¡ôꓱW¾'Ù4ÝÏ©ÿO\·^ŒãµAbR2N½öÚÉjéõg¬´ït»~ù*´>ÒW‡Ð“°ƒ,Éb@¶˜+¬UË_ôÝ Ìô»¸)§W­),kÎ'ã¤PO +n›BªÔsÙßÖ—W¯粿-¬QÀd• JZßz0) h¯ŠvΪeݪyÝ_ m-M‰£À’;°Rfòoz hT±×Q€ë½!ÒtÙb”M&Ù„Ûºš`NíJnëKÃ|‚%‚îÉí'¦·b^¥<«ùÆÕMPEŽeµc¥n*¹­/]x©M­)ÜåÔj™Uׄ'ùÁbùGßõ¨n¶Ê“¥ý6×]ÃÍæ¹kqÕ-ðÑ%@<ábçÙDV^”E¬Ùíoƒ?zq<"£*’ªoh€.±-)wðWXVq +C±±Õ.÷1îVËm‰R©Q~OÜ(‹‡Ã˜`ú‘ÿ0E3®U±jÞ$¥‹øiŠ/ü“ 5£—.õ–Y6ú»K—“tƒU#›TD!øRÀ—ì+ÿav¢ow³ñÖÞaþYúi’;ÿ¤q<ýEyZ·mEØßrøã–x2äpü‰þ±äC„æÊ4HïYÔUÍ4` ÷ÜeBu^Î%×U‚óƒêCÃï:ΖR¸¨wÈŽ6­­ó£R*‘ê…×È¢"W·D—z•ÃŒ2å•K¥`o+=ˆ/˜€ÚM/*¾V&uú¥:ÆûT¡õ¢˦s­ÆÚ†hÛûMUyW Φ€A’æVÓ~™y-¯ ך!ý?³±¤•4Uún/'ÿ«¡v¡©9g/'ëˆLòoÏÉ;ƒ¯ó-‹u‡ò$¿‚YUV¢ ¥yJFõÉØ)0·»ýÈMˆEÕ©E¸ÞJw)̇ Bqõœ[Þéʺ]6ÏÖø¢yË¡ ÿ*~¤£)Y ]¥æb$«P]S8‹ÁßT/ÞR«Bͧ=êzYu­zYø€ÿ1$ÄÝC¶È´†•âÌZFýÙØ ’ïp£½¼ñ)ŃŒ¬æC{HRl÷÷÷è«bR`ÄgË©SÏïxmȲ$‡Ÿ +*Õb/yõ.[V-™ç‘~ê¶#`%Ò®l3DkëŨòemÎ;ÚB¥ð¡‡G»JOH6õ)· +¿^¾ú”[EP¯Š@}Ê­"¬WE¨>0'…-'µç.s–̶wÆZNÃ"J<Ð;¡Ëm¡]%8?¢£œpØ€XœåDG{~Çk׊ÆÉH,ÐÊ+ZÊky•;Ñf»‡a÷c½‰¦™mïŒýÐ~3'.÷Cj>íQ—$@D´Jiðÿc°,U¹NàìZ»´|Æ Ãu'#icµ®ÖৄÍB%¡8qUì,¦à³9-\1Ò5€ÅÕ5G}4˜*þŒÈÝsïªDdÓŸíÕ¨Rnéj$ùV{Ö…[^¶BŠd·xti.ã:]”·Ý"bä4ß8jÃ}ȉé®MÊi¾1Æ]J€àºìc㬠òéÏÆ´ôh-ݱÃàŠ•ñÌÖ—ZJ‰dcUP+W#YÞéG’Ä ’}ä? Y}p¯6^†­¹,@¥òdˆ…²=ëÓI{7IJ ÿÖ%áð…a$+‚¼¥ ÈÇÅ‚¿/RZOÚeŠ/ÒOc}ˆO)q[‚bhZóÁÒ¥Ùò~¶$æDz™½šØòÊPøšI$.R4 ¼"uúeºÞKmúôÙ—þâ‹?¿ø ¡µý<ÝlÒÇÏÿÜé|&nÀý¬ó?ÿÒù ŽU3”}‚}}I;×›Ù{#Þe$.&ÁLù_áÏÛåwÿýë?úÁü÷ÇaúCüõf¢?¯7ÿØýã‡×o§»~ÿ?þÙKÿ¾ð⟌âç?¼úú·¿þ×·ß÷&³ÿü÷~ÿû/Û½ß"¶Òd¼Ã»¸Ò[|“"ä`ƒ›-Ä Ü|õ +“ÁÍ·8ÈÂ`ð +-ÉÁà;¦ÞÜI/¾û#zýd›Á Øå …ã€àÅ{‚@Ûð‹0@oðrljÀ‚UOþåsƳ¢äY}ŸµöVTÐ…wûÔ1 ê6Z?-z@–òàfµ™!òaŒ´ƒQÜoÑãìΧߡ-èE +èNûÈ[}|à Ïé>ƒÁ \:Õï >ìþ(z5ÉÈ&.æ\!zûö!ëP[ÐN:YÌ–3‰HqÄ:d¾:½Î‡t‹Rog÷ËlÒA·3žgé²ý¶_vˆ½Dß_†Ó夳M—Û`µ3E%-wݪ“-Öévö/ÜàŽÕ€“b‹¾Nºë@,€[>?HØí€"@y8"Ÿòí…&*­Â5I¾å3¼NÇï+âÃýŠM|¤§ |ø{·Á­gÎsˆ?} èàf>mÒÍ,ÛâYöïþ˜Ýy„™S*{y?_Òù‹íîv‡(Ù'Œ RÖ,4./© fNC WÕlŒÊÙ¥#^iÎx+½èã|‹õj‰¤'ñ:‚^̶ã—JA·$ïµùÓ§OX\¦ÕþÛÝîg9uÉTJ_EðQe†0u~²Úˆ’ØZJ&¿õêߨþv2Kç«ûê=Ž’’-!#òö’7΋ 6tzx’OxË%O.ñ…SKJ†No¤ÕF#P<Á ¹ør¤AÊòû¼,=UþlM!½•Ó}‚Æà†ŸH$.7¡ÇËÐñŽYð1Û>dhÒ´–ôÈ´¿ä3-ùsѹûD29A#ùû0à0·8Ûpäü«xÄȹÞp«nñò—MöBy‰axƒ²Ž7ûÅH¼ûF{™!Îý·Ë&âå_µ—nÙ|ÍþʺxfDÝ] Wâ™pD8—\­ù‹_Ћ{AúIF«’à¤~A2ù%$8ËÂÁÐt¶ÙîÄëïá±C?JTCyø0›L0KÑÂd3n+°D­Ù³»ÀÞÒOdšˆ(&Øã‡Ù½`êb¶Š»1«Æiš¾Ÿ1’ >ŒVÏJT©°ƒ;Þ¯gc( v#:áÑW| J)ÞÿCË‹òÞO”2?θó°ÚåŠ-<ÕízyÏÙÌúa͹Q|ßöùÓnc$ý‹ºa?Œƒ¤Ï¤ÙI]CŠÈ‹ŠZ}XÒ…&Zã¸ýnGÿÜJó‘U3}4§O(Ë2zÄÍ@òÏ'2sô i]ÀV(oÃoYTR›Î¥­Ñ<ËÉÀ@_áëvµ!¤ø"è±f’»NÆ—:#òsg™l³I™Iw!ˆ;ú£ªÕ¡P4Ó^žèQNÒÂì„€#Ö§»}:Wº‚ß¼€Ø=%C§dˆå¦á¢n©é(§åÄHâLýIª€s­Y™€ÍMòêÐ{fq°{Øjé"?·P3 ê¤h¨©A^uè Ì‘¼e–ƒ¼sÝü‘P’Êw(L±±Y­G{´-«Á™ðØb¥“(Eú)š)¦WìNKÚxTݘ2Ò¨N[½ ,Ñ;ïéØji®F’8¿¾‚xkhUf! +ñKÚhƒØsMþį‹sð'ó^9“?%TºMšçñ}“êñêP—5JßÌddhàtT4ŽEëðnOTÈ‹Íé*_m‰H“J,ïw+x„‘{î$`Ï­åãpK=%²‡–j±¼ a@×F#Ý•åd +Ì*`ۗΖÙæ¹ÑÈÓwVT”UÚ¾‰<ó,ÝLgj|w5Ž,’n„§d¨Es“ÆM›lR)sÖ„3³8û­Z- ¾Á‡ó¹È¦<|»2ÝD+AM8›85) ÷D#hí²%ÐeŦ¨”r+d"¼Ú 3©!¼LfÓiu‘ÊW×Ç-”bê×ÔÆHi„TËãžÕüÆþ…·ˆj‰õ…à©ßíé;_+۱ы֒uµR¬2|ì(Ë%Á{æàaÕEÙkÇǧ*ÂäܶŠ”©Ì5°«AñØ‹ßµC~þ–:, êõG©:ûÕÁ'¶Æä"\›WÕYØL*t:Øó‰5n›«=-'ìGç'áiçÐdè i¤É˜š ÑS3hm¦úŠ“%w›.‡êMrA4’ –";‘‘¬—l}ÉŘnVe ÷"Í]b¥ð’û,9´º¦s“2LvÀìÙ wè+\U8𓧒Î yÇ,ÓHýé³/s¨éö¹>îËFô‡l*ÛŸÒã –x›¡FP[(äW0®U’ÒCÖ›Ù"Ý<) o!o;â-nnšÞ¿aïEz| µZí¤š¿m·j$\ÊøùÓÛær³|ÍD±Šq®iÖIÌ^u£Ýžßóz×eäa1§Ø°6Ó¾}âŽnÑ‹@‰±ÌÑìMÄšÝê®ù„Ü9 s)ïÀæ|ÔŸgé|ün¶• +€TP•¼¥ìõ av³we»éÓåón½š-wRØ”*ÝÃ6W5Ƙ6¶v‚n>Ôœ&Ó`ªFA–åÀ³ì"˜²c¥b9æ5h-•¹¯g&‹¯lvŸe¯`N§ò`ž’kø€Ðsy B{ÏWô† á‡‡Ùøa8ÛBä0üºì\$jñ¹È…eKé:KžO%ú깊afÇ$î‘fá²r¸"i[²ô¨W®tFØq¬dÙ¡6K8.Õv‡§‰J¥ŠêU‰‚"¯DÛý¨Ò†þÊñPà©RžV@Å`‡ˆaQ©jl/Å{xÈÆïF«Y f#SiQß oéâô6Ÿ"S(-Ƕ»Trg)âÑtOöÍìþô­ß$/ãRÜ™ò¿!Á:E­éü¿:Ÿý¸BÓóÙ_ÈGc¦_¾/F½yì’~þ†Ös»x%ýò +¾5æóHz~ñ–gÂ^È$ýžz'ãlI¶#žõb‹ø&•Ô^)¥½©;¸Ú·¢­<”*çÞð¤³,EG›8'¥MÂu’ÜØÑš¦"Ž)²IW¥'³zQ‹¾ç³C7g)½…ÇÒ—x $ÿ“­Ò%þoß#ü‹˜°{I_ ø¹R=ÎJ_"ÜŸüÜ;¦Ã—r1ƒ¥i¤ÂxšŽ”¦˜ÅÒD¶‚^H‰êsYJËÖ +$Ç ú¬¶t%ù-Ý +¾E_;ìk‡|íp$©Êé¡¢³ÐŽš¶ +;gtå,ZƒG¦ª¨ßðg’S‹‘ÇéW9·òÕÁ{é‡ßЇÎ[­±Å,—&ú$"uŠD:®[¸.ý"eWÝùN<~£>º\9UN­†ë0Ý<ã;æØùœ Ö¼H»6쓾¬:%ö#ìŠÙcòCmWLR?ýSÆ“¦,犩}mWÌ®UN’¦òñSzèëÎtž}„û¾DËKx–%è_¤ËGÊ¿Hô‘´#nžü²³XоÀŠÍ±x@’˜ËMÏɇ$)Z‰IÂ_T‘ƒCô¿–ä`ì‹üÉ•ƒ*yµ¹¨-óö¬Ä)mPª†ñ»1–h¥¸#$~Œ'µ,0É«7o°:k‹ö_)\ÐzÛù!K7ËÎÃêøC8Ú!íè¤(…\à ‡éëü[Úy@(ñ?‘À²Û­·w/_~øðEK[mîɨ¿¸ß#Vÿ²7¸ù èŠ"P ý;|ú·—é_ŽòÄ\IlB%Ôk‚‚2'¥%¤óÙ¿d5±5Ž “k´dÖI¿–óÒ¦Ù»"»’†Ùâ(…(‘ö¤X^z¼"?^³éô+ßdHµ2ëj-s,2K)ŠìK\;= ;¯&(W”K9é¸P‘S‘¢Ž*rò=¡†,Ö’Q•êÙYÈE”·P*®}¯”Rÿ _.¦ýƒ|¹ô¶òÇš \t2«ˆ­ÆžÄ7„õ[ŒÈ±Åè7Ýb씳ö1Üõ€ìdùg¹§Ö-¨IÕÚԾɤ„ÏÉ’qm2ˆ×u¶ÒºûtÓ^x||!¼Ñ?·Â?¶ä†=ùpœ´ËCúÊ A”;ZòÚÞ©×ñ° Zm}¼,6Ë”^uQº ¹e•H¢^fæµIýàL=n2°‚xŸ±W¿*¤ZSB«¤­0¼J›o‰¦Z[WMJ(³|L—C$J´××ïås6ÌZDÎ'ûUÁfóÉð½Â£[zTx6íQ¿¶­Ò ¾T-dwÁÐRðò¯qõÅ* +/Ký–NmŸ%·íq¿x®I!ÐIàSR»òŽ^ÆH”WpþWãÚ0Ýf “Œ1.U›e´Ä2Bˆ4¬"žc`xJmnl%Øß]×Ǭ]'q0Õ®‹Œ|§e¼0î½qÈg¸óú-Gô+ÒGF×{ƒ*síÔUšc`„ÕT&!·Èü¯Æ=Š.‹7;”ñÄjƒ\…8?Ø¡ŒÜTƒ2žM{4¯ú–¹»RYO¿ã¬P`Ð*5^Û¹†‘ ¬„ªq k9ŸÌûÕå¤P'íÊ@xþƼYNAÔ$õ$4’×öθbŒ`1–I±„Zzl©w’ôÛ¼m˜šv1û-© 麂7ø%¥Wš½Œ¼oêL{Ž¶äî¹ÉDW£µ²e×È¢îmz¥z¤lñÉBé N…’+gPû¢ÎN®‰,Ù¤Uœ“ÜK'4.Ó´Y¸Ze{åzoÚ<[fëËœ;ZÝ‚tG+«OÍ¥?K7Ç /]ý +sùêNZeNyß >£[äâë× ç×oTj©)MpšÒ«Åàef ¡_Hç„ßUh[AEßÕ†u‹ $£WDmwqåR[7«a:^xúÀ-Ù†ºO«Ð«Ù!8Çp­Öˆ8ñI«Ïöä +l»E4q8 +­‹ÍBÊ × Óœj’qº\-!z=ÛÌR»¹V”WŽÆîÅc߈æÆÌèIëT“¨ÍöQ§0JYÙ}Þ·Æ5qàJ0V4Ý JáÕí4Ë&´C>Ë`ù¨f’FÁ̤‘:®Rmô¶ð[{‚ +ÔLæ`‹ˆv‘ê„FˆÑNµÏBÒ=Ïó*s^]x6-—#êAµ„Un.¬†ÍªãzÀÃŽÓ׸Ð)º +lÏ☙ÀóÚš¢¹¤ca€ŽÃ~7í-F^eúÊš-Ðì,K Ö#YBöU/–¸´¨ñOÞ¾rÈ!#ª´oËvã‡á4ÍÁÑËŽŸ¾Rê"äŽ5ý}ß ÖMÏ£Ö›Õ?Qâ!zHõÜH†óÄT€Ó'VžBs6Scsø¯™¸ójC08È⬤+¶agÏ!4i»ñáXv/ ‚°‡–µí‡QAÂÕ‚¸y_}²¿&®3³åx¾ŸP?j)­VµÛ¹†~`Í1ú†?awLE­,\'_í·£CJÌœ ‰RXu.”Þa§YªÓå/^ýÇwâqþ›KO¯øSW8òçWÒsÂÝs„7ðÇt‰&Xu,MǪc){öé]ìT )æò×ù^õM:ÂT‰üÅ·ò è »Ó˜–„¾§Øßö‡ÕýýŒ:'’› ežpÂy½$îoI¢võ|ÅaÇ1¢4ôßÇ]Gf >üé{þÔ»wóøsç·×|qP§dòðýLrlá—ïRŸXñèöt…~Q þ«ô -Īþôz!/UPFð‡ØöE5”â¥ãQ +i*y&Ú}xÆI¤ÞÓKOÉÃOìRÒ>þYzÆ$¥M)ŽýȧT›ÊèŽÄ]à¿°å¦MQøÂ+<Ñø¬a´±å¿ýö{á˃÷£ÂI<⛋Ä“äÅùÎR‘_MÇûÍl÷Ø!¤8ô—"á0—„C' ‡‡'áð$¶OÂa wûaDaßIÂI/œLÆaèu»£‘?íŽÇÝ~œx݉ïG~ey$ìý¤ç”„IU•9%“° ¯m‹ça’´»ÅcÓaì3ù6®i×†× +™ þ¿‡½”-˜d©»²¤ØÆŽfÅ\aÑ&L–e! 6ª“'}3gÙˉ=›´“Ó_b³?¶SvqÚ&Îݹ˜š”Ñßß°ß`ï«lÞèU¶n”ò¥{óò,é¦>²-­oØèïWì76aÄ›3úôJ<%Ì]ÀZ̺Mã¥àM/…?AKÅÍŽ¾=£Mf›3úø­ôˆZ7f¬}[†ßCƦ 75*gKF»+†—¤ímÞpÑß߽߳;¾£lFIhþüžþLîèŒ>|ÏP"Ø|ÑŸ¥?QëéÆ‹>ýU<¡¶àMýýz!@1ýùý ¶…Æf‹}0¶Z´6ºÑ¢Ot›E †Mýùý‰ÒÐ }úY<9¨S~i–­-6Vôç/ô'JÏ7U¬H±¥",6TôÚNÑQ&›)úð†? c)úøFzL˜­(ËÇzÂD’>¾•±åÇýù–þ3w²Q¢OoÅä@û$–ƒþL˜µ9Ãu»DSÃf‰þüþ¤Vš[úûoìwïŽo’è#Û"±’²A¢¥2×Dhº9"{£OŸªÈ:|j˜¾Ogs8Ý2qÃ0¤Â`oSHü’Çü}‰?3=ÌSSò½™²u¥@Ñéz¦lŸä|~W•Mí‚æûûõµ†%ÙÚ„CªÙ·bݸڄÀ&[FYàÙÞÁÚ÷ˆI÷ºI¼nÏe“¨nÅ®{½ë^ïÄ{=A/AY‚ r 2p¤…œÚ&Èà´OA!Aö|/‰£ÀMá$é÷Ñ.³›¤IßKzÙt%é(òâ>"Ô( ûž×ú%È\hôËR¢ŸK‰¾“ýÃS¢ +JôÛ§D¿$[çAc:‰Ó‘ßOúÝ,ó¦ÝI·‡rDã,M»“É4Ió(±×õ½8:%ze)ÑË¥DÏI‰Þá)Ñ;%zíS¢WH‰q’xI%Æow'Át’„ao’xiœ…¾ŸŽÇ~¯7Šr1±—tá‚%ÒŒé§ìCG +³Œi´ÏHèÅfLywlÒi¢Ñ)MlÒª^œJ¯æ×#Ь’ û¸Û¤¼]¬uÉ:©IÖj÷MÒNüZ¤ Å’wõ¢0î9É;K²î¤?%I:éio2é†^6õ²¨v»²½†hà îž+yûUÈÛ.èŹÉûÂA òn&?4 ï\¢>yËaЋ½ž½Çh[5§Þ$O»Q0íw»}$¦v§“8I§aî«Gý8<'òÆ“Áe»4tRxO§p–ܤqK™*™[œŽÒ±E!oG]ZïÕ¥um,ÔÖ£v\p!½èHfqÒû(ë÷34F;6/îuGñxùQÚOG}´Xâ$—ÞÑ18ˆÜܽ»+½ÛEK™¹ô~jÁ…Ñ{3Ñ¥½ç / è½X|ñÑ^®—#÷»axãþÈKºá8˺A4ʦY¿›H¡b—‹Þƒ$ +ÏßÝŒ•Þí2Œ¥Ì\z?µ$Ãè½™,ÓˆÞó¥™ô^,ÏxA˜» ãi6é‡ÝÞ¸ ›ÑÑ4 +¼°ßŸÆHŒÐ:È£÷nì‡ÉYíFLyþ­ïV™„2¥Ó”&‘+©ä­}jBØ–¥!úè7S›„uˆVí™EoÒ«N¯Pf‰e·×Ï!Õq"pöÓnêuãé´ï…ñÈ÷ãiù]/ÍUœ$±E!Õ +§>¾±©„&ϧW;Ù5yì«©Éc_F“ ßˆ$kiò”~µ£ÉCE–Ø v“ÐM²îØóül<šÄ㠛Ž°;îw³È›¦ã|Y!‰z²ƒ_ÿ¶W–{¹”ØsR¢ÐZ¦ÄÞ)(±×>%öÊpqpYuR"á(ô{qßKƒ`L¦ÝÔï§]o2õ’I® BFAp©µ%vËRb7—»NJìž»§ Änû”Ø-¢Ä¤×‹ã<õ¯ßÏú±×íg±7™„c¿Á¨ïgSw½0W_x~’'gÒIY‚Lr 2qdrx‚LNAIû™dqŽÔ8 §“I0íIÒ{ã`ÜM§cQ$ÚídÉÄÏ#ȸ÷z'3Aðoã²”çRbì¤Äø𔟂ãö)1.¤Än¯åmµ‡öÒ°ëûŠã8Œú/c‘pŽò)1‰ÂŠ‹%-Vý\‹Ußi±ê·`±ZH‰'°XõÛ·Xõ‹-V°ËÊSröÆ^w„ùt:ñ‚"¿l„h·I:šF“\¥ÂÄnxX;Á\J,i±êçZ¬úN‹U¿‹ÕBJ<Ū߾Ū_l±š$^àG=ÏI‰Ó ˦QàƒÔK|„Aì÷§=ôÚëG£nîÆ%Žƒ^÷°˜XF\,i±êçZ¬úN‹U¿‹ÕB‚<Ū߾Ū_l± +j—(Ì;ÿñ§“éh‚²‡ørùÓ MüÑ4é#Bî{^>A†QŸKZ¬ú¹«¾ÓbÕoÁbµO`±ê·o±ê[¬&Aˆ/ÏN0èOÃI7@’ß8A›cð%ée^šxÝÞ¨—æZšÄˆÄ“Š‹%-Vý\‹Ußi±ê·`±ZH‰'°XõÛ·Xõ‹-VÑ&Äïù}7“N“Ñ4‡I2JûY#D›ê íyYÐE;—|L £ûí˜ôùåLúâ~¿›ä‡ÝIæ‚^Ö÷㬗Äiöâžß§½ ñÂ<òF¥£Ö9‘7µ¾ð«™8ùeLœü"'¿5§f”~B'Ë´còá—5qŠ{I¿ß‹ÜpŽÈºóh´‹¤”ø°Ç¦9›=¯ì)•—{Jå9O©¼N© +)ñ§T^û§T^ñ)BK?Ê;ûašôG޴ߟt½l&F‰ß=µ)‰Wö°ÊË=¬òœ‡U^ ‡U…Ùè°*ê» R"–ðÍùb¹Ú±™j“ Oªâ(êõ‹vSã$žÆA·—öºÞ¨7Ò ?ž¤Ñ”“ÄÏ;3EÔ|:\,yDååQyÎ#*¯…#ªB2l´—/I†Á¡É°xó =Gž|"ÉpÜ ÆÉÄO“I€äÊqè|:BÛš|P ¼¨w÷êrdXò|ÊË=ŸòœçS^ çS…dx‚ó)¯ýó)¯ø|*B[ß$OPŒ‚p<Ž&Am¡“dâõûý^’Ž½^M“t”gÎä£-{Ò?ÙI©Wö|ÊË=ŸòœçS^ çS…”x‚ó)¯ýó)¯ø|*êö#?O¡ÙÆY?£iÚ’QÜó ²obÒþ4ÏÄQbÐkp>åÝý!Δ<| A7!EgJ"±ëLINaž)É_@g¹gJÍö,µÎ”Œî·s¦D‹-&ÉÈóH2 Ó¨öú½~â.â½Iu'AEáxä™ÁûI¯ÛoŽ’tÇu°¤}+¢ç&É#lIJd3»$™Ë¡6In`¢$ à ‡_O²É$é¡­KØ÷¼`’ ­K¥Þ8özqwóŽ9ý‘p\ß}ÒB’%cE‰Äù$éŠ%=5Iž V”Ñý6I²p3ƒ6ÕI/ïä=I¦0ë©Æ^ä{)Ú%Oô7ždq: +ót^ñmÿ°½VR¦«’æ#"q>»ÌG䯧&â˜Ýo“ˆ‹÷Aà^”k§×ëEþÔ A¦½xì{1’F{½,N¼8‹²8W£~Ï÷NYç!C€ÏX'!÷uBæé-Çæ–RµsskŠÓÑ4̧Ժd£]Ê'k},”Õ£lRr!q‡]$7äɱMFaOz~&~2õ½ ½nôâ4ÿ\2 +Ólòu"‹+·ý˜ÒVj>qáزq7;ÜlFܹgœˆ»ð¨mÑØšCÜÝ ›u=/KGÝ8ìÇ D«Ç“Ñ$ðlœOÜìîë t’qëì¤jWØJÍ'ÕS«8©6Ó$4#Õ|eBR-Ô'„½¸æy1§ã¬7š¦~7êõ¦é´ç àõxÚŸŽz^ç…¯ö¢~ˆ »mRuëì¤jW+ØJÍ'ÕS«8©6Ó04#Õ|%CR-Ô3„=¿呪Ÿõº—$ãq2š‚§i€Ïù½^O§~”§õ¢ž{õÍí¤êÖ5ØIÕam)5ŸTO­và¤ÚLóÐŒTó•MHµPÿ¢-˜÷rÎô»8ìØ$ zá¨ëý( Ò´;b/˲¼#,DàŸ4ÖÒê$ãÖ(ØIÕa¿l)5ŸTO­\à¤ÚL¿ÐŒTóU MHµPËݸõ|'©B܉¥S?MúÁÄ‹û½Iš]?ŒÒ(òr”1 êŸû;HÕ«Hªö£T[©ù¤z´£Õ"RmvÛŒTsÏa‘jáqlè…vÇMªc´yê’(èvQ²^ÚŸL»~Ê®!kî^ÐO’nsÀWH¦ï¤ÔžRí±-eZ}BÔ§#SæÒ,\c-ŸË´ã .¢Ñ €9Š›F}¯ïƒku0îvûS°ŸïMÁ(Å‹’~<ÊòâNxÂÛàT%—n5µÇ³”™K£Í#ãµC£ÍÂç5¢ÑÜ zMh´0”^õºq˜C££nèù^o:šLÂp:Íb$˜ö£¬M‚~˜¹üô€&ú \IÑ\;¥R‹ûÀJ¢Ž3³Ì\=õ #ÑfH4ÿ| ‰A„öûž[ƒ: +¢®—…YŽ¢iš¤“qæAt0A¿£‘Ã(“hœ½^}›h;‰ºõ§Vu¨OÍ2sIôÔÊSF¢Ít§H4_uÚ€D 5§>""ļÝ$š$ãi¯ßÏ¢8M‚î¨;öGY:MüÉxÅB¢qÜšoñUjqëM­$êP›šeæ’è©•¦ŒD›éL‘h¾Ê´‰jLý¸Ûï†9$Úëg£(Çýn6ê{\,v{aŠ~MCW|L¢Q­“hɘròBuÆŒPœžDO3Â2í’h¡¦ÔÃ~ä'nY4öCoEÙ${£pšv=D³È4ëN²iè0$$ÚóX•S¸âidV2>„œ¼¶ñ!Ô§§íć° A»´]¨Zõ æ¸dÓ, »ãn4Ê&½Àë¡Õ0ûišu“,vY!bÚ ï°×~CN-é|ýc±x:‘Š 2µ•«ª=ÉéHú ·¤&±&^]b5†ÁB®q=r¥E¬ï‡}´;rlgi0AøÛí&ˆp½0ôã¬ïu»Á¤Ÿi$Á"¡Á•M.‚uî»\ëØyÙÊ- ØSï¾Á6Ú5%ØüX#‚-܃y]DVyÛŸtýt:™xã,†ˆªÓiùVÌßd)‰rh°Î&« žbeö±],ÜEy~Ðóhp:™zøê‡nœu»hÃ?š„qšøY’“žËUFµ×í£²OaL­Û…ßJµùÛ'%QÕÖÙ>Õ¢ÚSìÌ>¶KµEû£°ß ¢~CµáÈO»Q6¢`äGñ¸›&ÓÔ›fI/\fÒ˜j(¾µã|Ú#·ó¾•v'f™¹4XÇê¤ 6³*iDƒùF% h°È¦$DlíaâÄIƒ£ß$Jº£,uÑv}œ„ãÞÔó‚n²Àzvô½~Ü <£AO&÷=ž] : JŒ5´|?ý%Œþš™‹t_¾µHP›úŠŒEÂ~J/vßx§Óú?º;í…ѨߟDŸa¯€øâ {Ø;îȸ +¹/ü´‘¬ýÖO³Ä<’­sh’mv h’ͽ ´É]ö½°ú9$ûp÷g/DÛl°VB{Ÿnw:‚£PhÉ¢”a·þnç!ž²rßj#d‡¡”Qb!×1“ªCÈÍÌ šr¾T}B.2‚ +{Ý°(ÕMÈ šöÆÚ2õ#?ùÓ$œÆ£)qLüå|·ÖI÷ ÎÕí²;‚³æTF‰y„\ǘª!73–jBÈù¶Rõ ¹ÈT*ìÅ~†9„œLGI6ꇽ ÷§Ýx<£iLÑþô&¹„1[Öãîýâq‡y¶‘«ãDÊ(1\ëœEÕ!×fþÑMÈ5ÿ˜©>¹1…pÛ£—G®Ñ(ûi’Žz½4é±çià…àõ¦QîözaÒoyÃåŽìl#>Çé’QbñÕ9WªC|ͬöš_þ‘Q}â+:/BèõÑ~?Ç/Î)»éØG¬ÈCLQ_˜N¦8ÀU6qDO#Äç¡mÙa/(±±wÂøK ¨ûèÉFÈŽ“'£Äð¹'äfÇNM9ÿÔ©>!:ž‡˜¾[u:îö’¶_=˜2ͦñd2é¦ý(™Œ§žëzSLÈI„þÿ L¿BvŸ_ÙÙq|e”˜GÈ>¼â„Üììª !ç]Õ'䢓«öûqâFdÏŸÆq¯¨v‚$Ýqw’Äc/ò¦QÒ‰+ú9&äÐï' .ŒVb†·_L²÷NÒS®‰Ä®°kr +™îböµÅ©¶%ðU#ª±êQMî·‹ÚüêÔF‹-:&íùÝ~7ÉqËÆ~ûÓq˜aäE£q¤(W:E¼?ìåF–úåÆÎy„<‚*ÔR[KmA ÔfAÈ3 ¶à0Ô”£¶nÑ3ß°'Eádê!æœM²Q/IÂx<"VÅI½Ì Õôúq¯ÁÅ +µùU¨Í/¤6?—Úü¨Ír,Ôæ†Úü’Ô†ö-½°Ÿ³µAPæ'Ó îÇÓI’x‘M¦I˜„ýi˜9l7 µ%qäõ[Â6¯ +µy…ÔæåR›×µÆ&= µy‡¡6¯µ^+7Ã¥ñ¸ŸŽ½Q4õ'}/ÍâxLÃ^}r(ém!ýÀ?§«>ÙdžnwÃiFäz”I=¯‡`ø«O7_þ¹Óù,û¸žm²Ï:ÿó/Ïð×~xÁg_þù‹/þüâ/ÙÇl¼ßeŸñÕŸÿüßÆ«å<"¦ÙŽ²Eúù/þ2Þ Š³·éhž}þÙ"[îñdQâ]¶Ü}öe'ÝlÒÇÏ¡®é,›O¶¸.þ½žMôWè%ôŸ´j›mféü³/Ù—åj×Yîçsüõí¯¿}Ç¿ÀœS+Üï :ݨ:Äjµúæéò~¼š°‹¾ÒEüÏgëÍl‘n;ï²GÛ¤‹„ûåì÷}éLê0HjˆÉh8„1ߧó½­[x„-·¿Ï‡ÐËtŒVßp›íHó÷»io1Š0Ý×¥õ!Bœ´‚ŸÁ²yJÔ~lêƒrg»r]tÔôý×?¼±WıV×$ÛŽ7³5Ë‘jĤâGã­R+¦õá~3;L/½È@-\á1§’Ô¸Âsi¬[¥ÎÑ|5*U¡X¦£Ù½V’ɲÍ2ׄWE»ÙòQ«i“MfÛñ + ƒ‡¯ëC6»ص\‚qbüÖér’Õ…Ó +}Ê–úG¨hn@l:¹£b–÷­÷Ê:U“lšîç»a)^P™êYZþ¹“È8CD…„ì²Èd½oü^òŠmƒ"æM}£¿MÊÁfåAñ/ÅËÐãO_^jÚm²¬Œ ÔŸ+VÄtúÈÏO¿M9äžÁ‡òHÔ´Æ‚¡ß¬Ðôøâ©BºZä@,Õš¡µv!d¿É—?òûÖ|z‹å»VºYVJo¥²ñ<ÝæOÝÉ~mðգɪëÍêýlR ?6„ØsÌöq»Ë­Jd@Þ¢ž;ZáëEêc¶ÁÓ¡™ÜB  û%ÙbnL¦Xñrððn‘4›O_<Ý€O²5]0'©~íŸ`®‹E£upžÍ +ϳYÑy6+>Ïf%çÙ¬îy6«wžÍêŸe³¦¨˜a±´ØÒþ¢X-‚·¹"iÞÁŒu7j;tÉÕ¦]¦­÷bóÏ»¶ö¥ßÒÆÁ¬øI¿¥-ZÅâwWúÝ“~÷múÒÄ!‘µˆP®¹\:ão‚½Õ4tãý¥x㋶¡6lñ!˜e õ’¥´Tº¤ùÂÕ&}3\ܸª£Ùr›mv²®΢‰Žès¡4RG\.#By õ®[;dn¾É"ØuNwªø§¼›Ä/ÈÆ?Ù–Œ.6ºmÂOlÓBÊòQ&&X“®1?DDeBRŸOÿôoHÿFôoLÿ&ôo—þíÑ¿d ÈÀDm‘YÇÏh:YÌ–ÒTH›&†t'w+%•´LŸ3åÈcÓÌ|æ˜AÒ4lÎ#fSÊ ´Ÿý|õîn¾Åf%ƒW«M6x³ÛÌ–÷o7ér;OaRì7LãéæÝ~}s‚ñFÿîæOÿ÷Ÿ¶8¶ûˆîn¾†&ÏÐ;œ—ZÓ@²ts¿_ì1K#l­_(íp $“ÚȘ ¯77_ɤ‡ßËÿMþç‡ÙòÝ·”=)jŒ²Ç—·ÝÊn›Mª¶f¯•=)e…ò6ñ³¾FÔF3Œ¯‘ï}غùLP½\+¿ùc¤>Æêc¢>vÕÇžúØW56nÎÃ÷(>ð®Ñâ +Ü‹KØßè‹Ìµœì N-ç^÷îæis› ®Õ&¾GRQ­“Î"]¦÷Y‡ŽómƒF_¼ð½6 ÈE‚àY A˜ƒˆîöc0 lŒZI—€èù kõÙâA Y!ÛtFóÕøÝöK Ø= ?Ùnü¬@¢wxŒŸFDVŒØ=d‹l‹–ê}|0J¹l€uòõz¡–,Çç‹*êM6G›c,/ T˜ÎîœuÈ ?+0HªƒW ¢g± «É~ŽÖñ1¢h`s pÜÝ|÷1ÜÉÙ"A€œ@¶ dYuèPwv«N†ßÙÎvYgº_b]Z:ŸíŸB‡GˆøY D’¯\˜Òãñ†º^Ì% „bÝf¿­«ôÚ•ÆCò{ȾÔ4—–±Ýe_ÂZô}Я#é˜ôàl¹tÒòÞÎwæ«1.£³švökpÏ&ìLŽ…þ‡ÕHöØAÜIÇãl»Í&W¦^Qº@Jï‰@Jß +)³EzŸ w«ÕüÝl×:¨ä•~°‚Äÿ×ЇíÃÙK7BÀò°Zm³Î‡‡Ùø¡3“› Ê‚=ú4›vWûÎCúý^nwé-Óio:g©ŸÕ¦!89ÀôŸÀ0é/oÛ°Í6ïgˆ‰µ¼sP‹½dA«ëïÙ¨ÃZ~ÝD´¹‰¯´…`tûÔö¾Ýo³Ý!˜Û¶"··­1cUº +¿ˆ%‰Xè¯oÞth4,i¡ž·÷ýXßGàC©Ö/ñÛå~1Ê6°·@ŸÛÎ=Àü|‰72”ˆ¼Ùv¶«ý|Ò¡ò`„·/w–j³}9ÝÏç/À{ñ9ÉÇ tbë÷ò¨ÀjÐe’½Ïæ«õ¢Ð€±²l`”|X䣢DÃψÂDÆ!ÀØfHCŽ÷ÏjCq +-([aON„±[9¯.ÒË!ÏpÁâ¦J140 $§Š‹À´.­£ó5wJP£Þ¦ï$9f5Îgˬƒ¨®³_CXO¢ ]aqEš–Î.ݾ»ÂË¡% ^ز¼||±[H")Úƒ×}µMRYxq×p肤“_DÎX"e—4NÇ°©Ã@2Bÿ|˜MvXù¹˜ý —ü¬päŠOG¢§‚#vãÊùêþQÜ0Ä^úEà*êÒzbٸ٬6­ª@Û•O|]Ù‚Ýzž>‚^·½³È¶ÛôžJ)°šFÑ›ÎザN +ñS•b‹Ìá&»Ç‡w-kMÔb/T´J¥­ÆËjîÑ¢<[\‰û2®°禛_bÛ9•MˆÆv'êyV¨RTšªTTžŒJÅnÜɨ¯¢¬bƒ óPÈ^ö%€Šß•@…5ÿl% +mÖes¢˜EÛœì_«eö,MÃO²ÛÑ„­¼Ë‡Í:Ñ øšnˆu»Ûñj>'±}ÚÀ‘\˜€„ò-ê_ŽÒ™3”H°EºXèÀ›¥ÒÝÐó²K}Q#¦MÛ€òTLS}»mªf~ñZÞåÈ…^‚øÜ·™’¡´w¶ø‘(øvÈÉÛNö5äm'[ÞñϳÕÉžâ踯‚ÊSÙç0/S¤ylS¤B/S)çí4)z’Qº–0MIŠEÁ-“ñæ™ÁFÍMCØ`U>9Ø°ÍbsÙ’Èëh®«ÚΘ`78q×p HÒÉ7æê<[\é‚3¾‹4æäËN¶HgóN:AKe‹e;_ݧË/;4ÚsgŠÀe×°G_Š³+ü|U¨xÿ£c[«—:vYàcí¥ÔKØIlÎ8þFäÅì™îw+qŠƒÏÚ\í`"x*0‘ó“À~Öòv†x <¿m³MÜfšž³iZ¤Õ¤Rèð÷ØÑKýxNQË„¤év&T1ãÉlgì®Ê +ÿ°Ú¼›ÎWZµØK€TÒßi£Ï7 4(34XÀ`6ØÏ 'Z14«†ÑE »ùª²œKÝ.P$.î®_\6rþÃJvè?;´P BXÓŽYßòn<'ð8‰?QøÈ3SÝdëÕfW ¾(J9—€Ør ·ùlá!D›¿Í²:¸_2g;Iµù¬´­`BÁí$Ï#„h`73ÝîÒݾH—iYêæq‰TÐ% D{ƒÛL[‹ pƒ[:`M%mµ4ÖÒÚoî@´w€E(ñß3$åt¶r»;é)„Û`õægÛÎj‘àÈ·Ú«Ò­Ò¯Ô1þåÓÍEâE+ÊÍ|BÇ‹à‰F< 4£RÐQ<À·‰6ØxØ‹ºÔHÀÏvµžŸ¯3Pô˜Xi™ŽÇ«ýò<£B°™8…Fï‰â€f Jm¿¥5<%Uhh·È Krki—×ãk²¾Îß¾Â÷ÿ{fA!ƒ„dÂ5[Žçû (%ȵ¿_b8v•*zø}?Ûd¸wt{‚ 5˜çí3B›DÑÑ&x*AKCÏ 7eXŠ¡æ½UÎ_ö€ðô,D<r¬æL‰ö&‹Ùv {>µ7(z*B<=!!4‹P¼ í—'æl?.ïžÄÙ‚º¸ØÄgÍwµkÌCm]=›eX–Õ|u–@Ù…uÐõ¤jU2†Î ~™ïïgK2ˆ?¬ÐÏpïØPžE† ƒ§NÐV9R³Í›Îæ;¾Ç„¹y?ËÊYØØÎÄEå9å^cuÑÛì#;ƒ&žÄÙd¶;çøO]çu¼ÐÚú/ùu=ì˜}¶íÙÊp¤íåyûI“¦¶1'Ø«jÇìáF«ÄQ“ Œ‰¬³›­·Ãtn‰• !j¶KX1¯V‹5\r­¿8¹Ô¹Æ¼3Xc:ÔEöLظf G•8ËÕ$î×YùÀ&ˆl÷ãâöEXXðE,ÈPÃA'Θ…CÇMÆ‚¯,ˆÖˆ)šÖ(g‘™^‡ Æ‡Ý”­Ÿ#ï^òØägǹóÏ´BTÂË7³3nÍ@æ ÁÄdXEdÉtá“ìéò¹8ž}ÆkKç؉º¸ž ÇÖ Í(crKÇ;|¼Û6ÓÎ-û"Ö$åÛ¨„÷-ßßËm?G†Lx¦¸Q'_¾½ÙtµâÛ. 84ƒ3:g$GÆìÈx @Ðå8pq|ù¼—˜Îš{ê{&¬Y3åÍWãw”ÓÒ}V3~ì,ðÖ¬¶o ÔlÔ¨s7ïÂ+aÍõá!Ýuðp…wº^géfÛ™-óð-Z´#x/np"á^·gk/Žûu¦ÐR{?Ñè~‘f¶5^-€td{¬¼c6÷ùš¥ K“ÖŒSÌ8SY®;øa¶Ýñ3?‡Ž8 *üÀ_œ E™Î;¿ï³ýYFÀ -½H T ˆž¨ iÖ[| gî :ýܲ/Lˆz€,ÂóVëG ·ò’5ø[¡Â_M9È\Qä â[mOE4cµuº{¨eÛc5; ½Ìðïn~ûõ‡N:Ÿ¥Û3F pyÇÇûínµè &o;»U'ûˆð=I8K”€†µu.Vjhž£žJx¾H3$Kþvû¸—‡Â‹/)z7qå‚ö?lVKáÿ|±¢{wózAœÞaËòÿdÆ¢Cç²?ôÙÉnwŒ.Мώš9Ý>@Ö¶·$Ö2/<Àà µý|å ) ò–Þ˲ÜwæˆTÎR’À´Éºž6TÛo<Ñ€|‘Ý q—~\-W‹ÇáûÕ8íçéæ±m¤(SÅ%*é-íÊÙbGÒ±´o„”É>¤ïá.ièÅü±3ʲ%¾†þ<Ž0¥@«á%ô¸\ѽ/:êÚ`Ìœ†ËØ@î—“æ`a+ï0qÛˆ ‹^½]­a7GéŽÐšMíŽbà&èÆG/:ŸG^´ýâl­6„&Ç8ŒÕÁäò.í`Ò³I:#V;œdËYÖ–XŠ»$(I”~th?. +IbŽ$ÖN„W 9<Ñ(‘fZŠ=ù†ûÙ-=ý.¸ÛN]…HN‘±™A›ƒo©3#´ü|·1‰%Û•r¸^>}ŸÎæPŠá‹‰µ#gëÂè,€¤j´Q] òDíHcÍŽ”jAÉŸ!¨/ð²ÏÕ²…¡d-,þ"Æ£Ú‡óF—@ ¤6ïy£¯¨ÑòÑmjÄO6ìV¥t]ã66ÜÅ_ +lÀø_lLy£¯°qdØx*÷<Ç6ÈÜ!"Û€”y•>×ÍÕˆT©ábÐ:ә϶­:­¶k9†^üLϿ༖šé¬–t>ïI!¦õW,9°$ÖÌÇžŒZ±„˜c7ôòRÂ8½Dñ„r„>tÖg}¶û²Rd+5+=è |Ó˜­Ó&ž#šÈTu†á˜Ù®ÇOÅ5ÖlQÉIŠÀB 1zO1ì/Ãî½m'Sk™—€1Q´/:„ºÙÂì_9+¥a°ÎÖehb{©‡ŽšCüD­%ÍZ’ÍÙ-=®ñ•‡ÛÈ=1È/ó@§êøm‡6ý|Ñ¢§š)Ðö’ÊŒ+àù¸ÆOØʺ`ĪH 04;Éí|º™ÜN7ˆUŠ²ÿo8Ë_.@Ù¹Z´z{úqŽèéºÒma=ƒ„D¿Â§cÄPA“N‘t7í-F^è÷p8ZMË,ñÑ~9™tö{"Ë„ò!ˆù˜ds$KÔ]ì°ÙÍ–Žê=­~êdUnÚ»Mö~¶E³{êv8òÓåó§ XJNdÍØe+¶cDõjUm÷ Ǻ•¹øž£6>Ò]ãyVk•': 6_¥€X‰4(¡j1P;¡Ž~¨²å¯UUþz.À¯˜¸2‚+#¸2‚†cÁ.ø¡Z’ƒ6ÅM¢O@„D>§ÖKW0¼‚á Ž…´¢Š7¼GoQ:/%ÒVfc_WØ(ãÀÕÜm×ë{A”Sñ‡Ù„¦<™žLn΃ÐÙž¦=OšGVX|ötGã·»ô~{e·Wv{e·-Á+,¨sà¶Ï_ GÛšìðèJbë¶q€w˜mê9¬l¿äÊž¦óù(¿;â0ù¥‡éþL§Qä|q¸¹ã‚ɻߤ˧ytŽ]bòpã¨)Ĺ6 ò¹5¯ü!1]$ +]º™J-$ž-·Ùf§B/]È• ¸X›ÔšÂà'ÖZ©­2Kϲ2Y.2ö̬DÙÏ´ÆHHÄa†eËû3”Üh£’U'rV†A^øCmkº¡À6?çÀ÷fµÒNýG6Z9¶IG¶(¬§j)Á|¸IΉ'óÇ FóÓ ¼æÙU^Ó¢—£Ô´Þ¬«ò71E³ñ»üû6ªá–0?¦Ó)ÐÚÃéR·Yª§¬|Xj¹7îo%! Â’(¨ÀZLïÏ0¯g¢2ÛH(QßÔ°6c©k›”i`ø¼üÛ8šÝ ìKN^çBg_l£„>°‹ˆ™-hA?Âb]^‰_–h$=Ç:˜¥^²4Ì +9¨eb¾ä(Ryà<ìKñÂá_ºrÉíè0/f)Ë!Udb]E±âú/P6*mËuŽZ<ÒjG:ªTU3ñ¨‚äÐH>ªPÏU@R°÷ý… H5%–ñº§ÄåŽà©sF'–ûl‘!N°X5y­õy_K6¼%pma^Kš›‹—›2àb_.™š¢B}•ÀyÒòÁE™Þã¡ÌÕeÇàrzõWk‰«µD¹±¸ºìœØeÇ4­¸,sòââêÎseÍÚqeOÀç¢Áõà yuõ¹å(¯®>WWŸ‚JŸ­«ÏEóÏ +ëñTÞ?:/¾º]Yñ•_Ý€t¢º\ì=CÏ ß÷Ù¾Ô†g¶ËETs„ãB{ô +º´ "éjÆh¾5VÛf׳MËæåU c÷涠(U¯s œ«DLW¼Ivó?çtÐ]¨8xQÁy,‘uZ°áhli_Ü4bË!¸ùlÙd×Õ¼-ÓYÝÍNËÓÎááj¹_Œ•¯SˆÝjo· æQTÐÙÖßl¡çÃ…F<®NéV¶:Í})>Y­tÕ1k@˜çE »Ó²LÙ/£í˜pñBCø§Öf‡[™Å?Þo ¸ø_¤ +ñ‡—æcÁ‰ØJb®d‚¶?{uúw7oÓÕòqðjµX¯–(ãàW”d¶¼Ç³›»^×»û#½ëC¬íèîÚƒƒa{w7¢2’ÿaµÝÁOH{×»»¡´'ÇÊîn6Ùï{Äxm +ïÞÝ Ùîa5¡yÿý»·ÿ¿_~~óöæ«Oø«^GþöQcÔìÙ /ŽìŒ¯Ý@orpg¥?¯hzÜ”rH¢¬Ogs±EwÌî¤Ë1-Ýg¥ÃØ«ßoIxTò{”õD:Žy\&a—µLmqˆÇŒ5õŽ^·x2Ûábå ¢ˆÆü¾êÜ6rtÐ&”"°áû”$7ç×'D2\o²éì£Và N´Éî³,Èüÿõÿ¼°dƒ¿ü·ÿ‹™gåíVï²¥<ˆRÏQ+àäŸu]¯ð\ŒÖZP-kÈO´FüNÔH÷é{µ4hh•ñªÉ•ù3Y˜ö@C€á’õÓ'0Þ` xñâ†|…‡„y`ÑFôÑ’°aêt)dÀ”++|•û=ˆ0Ä ¾Â4 ðC|Kh(‰ž ”ÐF’ñBL2¿S‹ ÐÜÈ_oé + ý¤" Qr4Á'ÁØÓppѧ‡ÚÃöÁe¹Bu¼;ºÐÅ-w´‡"q(‚ª.WCÒÁŸç+°\å Ë2»ÂÊV®°r…•ö`e4_!‚ W²¥“‰/øËK~ŸòKœ½¼üƒ\Ó9œM>½ü_þõ)ŒÜýw §B¯.Pù1= REˆ ªõ¤®Ý +j¬ÇÝ.’Æ0’$pp-¨Åìûàxñõd"^ÝÝÄ»éì5nÓ¢=ãöºRö}>úÆÓwp ù{@ÜÐu¶Y̶[ºJA•€‡e¶E3F +Ùžy¡ÕCz1 žË§¿¡¿ò$œ''ÝÀŠÀ¼†lÑ°Öbµ£t» +íøÝ%î^»Ÿÿ¯_þ·ä/ÿŸÿçåÿ÷ü/>ÿ_wøn{ùÅÿr³±ˆu°‡;=ƒ32•Ìî²I±ð"T;©”¤í'HåfOEcjc\&±MæzNîôrñù¿[8_’Ëù’Šœo’-V%Y$­Ïïpî^ä%6†ÆΕÛ_ŠË.r9™ÅAµŒmѨ4cüS€Oénˆ€v9î³ÝOiŽ^“0ßP /%[eqq<­‘ åy¢‘=çÑÙ¡-½<Ž£±qD>p…ܯ›»$íì/örØ^É›s3¹ }&çdKEµñ%ÇP‹õó!ÓÆ„â\&WeB³ízž>–ãC•YÆsºµYŽßµjƒ‚À5~¸Lq™ËœfŽžé ×ê† 5gè AF}¹î–Ήs\òn)ìEÕôUùkÅdA×Íè¶òšq$W×ǹc¤q˜ Äa= &eÉm NýmÎÝⶠì[õ`9Û¹ý—ØOs+pá€~Ý +ÔÙ +ÀZ¨±À«÷ⶤ³µ @}ð­À|6Ú@Ÿ²¼§nÂ~Hm*¦ì'1¡[Ó§õ¢ê3!\›Î‡¾á<@eE¨Š_æé¸è åʉ®œè˜œ( +›q¢Ð+ZŽfæ2#²Ï“•éòù±¤Ð¹à}@®4NwÙýjó8L÷»*½ùGåN8í –ö¥5­…'fü¨¾Ò*±+­€åÔ_Š ù.3€<&ôŠV÷µT›ÌŒÔVœBSu5÷¾\Täõ+j¤J,“  6@ˆÛž±‰zª°MGRR…îB‰aÓ 9È…ä ,$éê@† ŽýhGcbߤäÀë)w“à6ÍJê[fõ|ë~Àgèli£@iÌʶV£÷ +O&ÌËqlÝõùr¬\ú}¶ûšTð–l3NòÉA÷ 0àf;ÍD 7ç™wP?£å…F„츳e` ‰°|G§-Ú nòíz³úø8\mf÷³e:n³ÍûÙ8# €ç¹c :w Ê•ofCãwó74.ÚÉq§,°0‹ÁøE™YÅ1ö0ÎÎåïhô^æî^ò°Mž™8y& EqWKÇ9 +Ä7x9yd)vØÞn$žâ +¹Öç{^3¾gjÀÐbWñ~»[-®öÆWÜo×Q¾ªÇG d™MFö¦nò{Ô‡Ùÿo30Q—w¡{"’ù%dÞÁl‚ľ¤>Gݪ.#Ž&UÀk*sNéÌL¾|kƒâ#ñ%ï.x_pF|#¨Ä7h}fRÒÉ'ÊX¢žÝ‘ÅÉXê­{Ë IsBÂfzQ 8Tv‰‹%n6Vo´[w¡W/Ãw‡c-á—žq2k†6ìoûÖ0S’½œVs)Æezà_¸ l¥ÐÔ‹Ö%o/¾†Æ¨û +ôâ´£-bhŠYfª3´¾ÂÐÆRc¯û‰g¾Ÿh`‘kY=¹ZŽv,rfœ“E®eŒZWá( ª‚÷·  gøƒþøôROiÁx–é¿«‰9¬×GõØïYÅpØÑ8›ZÐÑ +üZP î·`8•ãe§©´b©ÂƒA:F.&~Ø[ 1…–ˆäúWmREó·›)!Z:$RÌ'I¨Ö4ù§av%•Ü>S1•RÅ”Ô ]ø´h¡oâJ˜Ë>{ìkŒ†uêÓ»Úg ‡så@4¨ûPZ_&&Oê Ppðžæ= 8à šþþ Z_0ü(ÁÄÅ}°á=[…c”°eHN$”îæ‡'Qú{.­a›ÌÎÍë|©‚Gt;³Ì>°‹s·C|?Ä ÝºØÝöå&[Â*.Êçf~åŠháX;¬6^‘'¹¨-¥¤Ëà¶T傤1?e˜¦ë'Ô’HCªna"i +ÂUÙÖÕàö‚w)qÅ쪋Âb¸Ë,£¨Œ&G •šzò£ñª#ÛúÁ9Gzrùà¡ÇxçÞÎ@²O¨ëùc¹Œ”´…}LØ·FÕÚÆFwùj* Ðdø—ëìAAÛoßÏà€„B.V“ý<N²5Çr<ËßYâj”Ÿç·á³ì?øÿùl>üÂÍÇ™òG¸[_Âã%§ÿ:›‹ÐØ\@ydguƒ}è;(04ÚUˆN¶µ¥P†íHû‰DÙOpÀ<ØnBe:oÁ__þ!iu>±'ôƒ\ + e¢‡õl’ë¢ùßáÿ4D¯‰®Ãƒú¡õV”XÍŠ})¯i³±©^ÕÄ}¶ûÈüEŒP½`;‹„Ž´PA­!ðæOù[ À:bu+ñµÄ¯¾Õ¡Íûšr. -aLËjëåq–¦L¾·]Ý?ò¸ !¢så‚}ÏîúÁ¹‰¡ŸæüGèJd{a‹ÍaNéHc社—(¬”€0T©eÌUDz¯¼Óã¯Eã¼hÅqcO> cëcMywíò¤Éüoš4M'—OpH•câm!›xqzlS6 "§px;Å|Ü" ´aRÈz;™M§n“ ”âå${ŸÍWë9g$ñìS@”—ÛÇåø%ðòíj¿gŒ¿Ó+¬ñS)²õè"ÔwxMBëù`·+Ÿ®¶0¥g gEкU)ÞÈB4†’ÔˆBÆT—‚ 7ôïa³ZÎþ•u”ž_í>®Õžg?u dü•–Çq4ú×~uè +4cÀ²«X¾ÔãöÑ‹ãÜa`]Sñ@Ÿ¢‚½}ÍIÉ½ä  +™´ja¸]`c¿°1ê^.£îUcÔTÌãÛžól©,0ƒa¿?‹—Ëõ[tqP›±÷b«ÂÜ÷êLjaºrü+ÇŠ¿ß·kB>ð”ËÖˆŒäÀåå€Äk¶j"Aä× d8 6@ìQÑ ´'4D—#44RRÈF5 ÎÆa“GZ$âVÕqà”R vRS¾kYú¹"K¿¼È’}\¯6»ádõa9_¥.Ïô¦O÷óùKRÖ £¬ú’ˆ»Ø6¼ÖðoBªÐ®­Í÷«Š´Òïp*\ნõ*J\E =D{¯š9V ÇrûlRE +Êð‚Øm5ïÓéÄÛ˜—Cé¶)ÿj[à=í²œ9ݨ·Š +CjWù½+\dÁQÂÄà,p’My +áÖ“wôpå.§:GÑZR2µO“ùx=¬%Kà‘º<§§©ÝÓ3˜c`¾²E¤5w]?U¼!ùikAµ™Ù}jMé*×ÕçéÖÔ^=kÙQ(Å—"ÓÓÑØ\GGЅ̹¢ s‘¬*ÿ"—Ú)(_jníë•Ÿ]ùYÍ“Ø à2xƒd…NK!Z÷ k®g‡êկĕ™Uꌮ_•:$ô«‡?–UDZ «2_Dz f¨ðh¶,ÝKhBŒ‡:›-ÚÙh’rÚÚhÊ¥ÓF“´ëÍ׋¼æëEí&iò•1_ó™m4ÅZje£IŠ;æF“uà7šbìµÑ¤|¥•fk¼ÅR^+Ü%Š­Ü¥:¿¯Ê_\FQNÕ £Ì‡®ÜæÊmŽÅm*Þ)ÖhmYøM¯Æ¾®9ǩ߉3à9MfàP\LEj2›æì¥Å¨ž‰Õ 4,oÂ\s‹âå24¸ÆæÄÇâ–—Õ™ÆÕróÊ9DZ_SìáYmYbãDxE“žUz¡=«ëB|.wéx‡i½Ôe24CÕ«cŒlm~سʱÝh/5 <ÊÛ®…С…áwÖ aøÝ\$agZkgi!˜EYÛªÐ[buDl1^3†SNz½æz3 cJµ®H.XèÎØ;9ùšÞœÛ¤Óóœ2Ãv0F³!z¶ÞüHS䇼‘’RÇYï pU¥x…5<ô+Q€-<´-§ÔL³áÕ«•¿zƒÆ…ú7·ºIÒ™yña6É4®u>QiZf4Ï îÚr0ˆ«qmµZìX°5œ¦¸+5 Ç0®õ[Cl?±ý’ˆ:¿Gr ÙiEl’⥖ÂÝ–Ä-9öúÖƒŸ Rk)@wùÓD]µIÉ­ÉO°Y}U9T£ãr;<6ê5|ñ3Á¤¢¿Œ›Ü-we DTR7¹×QýɃ»Ç¥õ0ÓÑ|uK m’-g™KŸ²É@§¿}IÒ¾ÐÒ:õ)9ÙÜ×Þ`p •”Yö|5–|ùîOÜ€î7yíhà‰0¸‘p|Hó€ŠÌ¤x~»Zw>£ðG»×É6›Õf; ”9Ay@ÍP= Íx²4=øvôÃJqtÜ­Öèͨ$Joy˧TdÜ(Í0 (H>¡| cù€J£Y¾îБ(0,ç£ÄÐ|@á|Àð| × ÑÒåaÀà¢M0ÃõÝ= ýÈ@R 4Ìè ?PQ Áþ ÷ øòtèaÿ€ÿ@Cÿÿþ00Yë¦6!™ª`ƒ1m}2„˜+qƒeƒÇœ),a`(H˜) ®0ÙB…uë{,+FcAP\S¢æ XnÄ3ÄÒ”¸ÆÀÅ6˜o $Iaû>ÑAå\d °‘ÄGœ‘ dN2YÉ@ð’ÌLÀMpúl¨ü¤Â€B"Æ]"ÂðMƒĽ`mçfQÀXpÚ—zÚBÆbÉÖÊÉld5$•QÖÔ;Ž¤Û /À§±ßÂÏWó,Ý^X GA4¦ßöÏ3ŒÚcÔ­* ³ <£úm$ª¦…q‚ëaíõ°ö€zqçº*Ћ[óµ£w4é¬ôâÎak]/N@={ŸI˜çA:NùòügX!Þ_¾zmÇ= »ŽÁÔ;Pá4Öà$Ò±^û ©×ùm¶Kgó­ûp +ût…ö«§­°'vìæ}BŲ¥àrÇØ:—’ÌãÀ ædâHè´bæ6çÆ÷ ±ËœÚOâ.çdνcÄÐVÕL^uîA×Â=â\î—æ«÷`‘}(Ã@*ò‹"åRi6á'öËy=û¨5ã +^®ÀFÏ4âAÔðëUò¿²‡jù»/)Ì[/=ìâMDygŽ$ÂwÝœ3:­_øD°LY†ËXì—Eê~Hü%~¡&.„b[¾ÖþIàPø÷M=—ÚÆÿ.ÓøC¹Tn‡tð$*Òy¹WÿUçr¿zÕyI¹¥kUú'6¥?d ;ú—ÐXï_¦ç¥ø/7ªÓüo³t3~(`1Z¢BÖ"§oAÌwXT¾Á˜IÅ¥ä|祀I9_e¦´*^DÚÕY?lÒ-¿…–Y¢¬ÝŽMnkÞ¶R×ÝÂu·Pi·Ðó*îò–e»X¶ $C“ý‚»§ß0ä Pë;† ÑÁjsK®žÛ#dJ—[8·Ê&C0ÙIúr7¬ÎÓåýß þ(3}²¢óZþgÿ§ +Ô¾‘%ð=ëoÞnmôªÛpFE—ˆIcßá?2·¸Ïv¿É3ôStèçÛrΖ/Öótœu F4Tˆ=P¨Àñ;øÆýÍ`h?ßþúÛw§a×ëaΑ‚R‰z·ÆZN6·*Ùœ'ó½Ø)nç +OÔ*†ät–,W:\‡,]ŽLvÞèÄ™¢Wã fñkÖXå¥ÜÚâ“™¤y0Cs„F3T¦¡»b´ mÝB«^h5jšN¬ŸàLÇ- É4f}Ž½ +Z=sßZã‹~ YÃ"`5gÂ,€øáÇíÖ%R‰ pàßÀæÓ]yIʬ‹Lõ/§ íN,Àª‹ÝH’ã[9Å!RßÿÙ~EÂa>å·Ú( ¾Ýo³s’güPk`\£¥}–®…ÆeörÏOôˆƒC ä8ÚKÒ.5Àõ©Bh0 +¤ÚŽaƒaëb¾%†«¯¹Ž‘l5–{GïYÓÆoZÛÐÏp8™¥²}…ÆqÈ×—8)Úíâ—e˜’ϸ =jÀoìöÒÝÚäò†Jˆ¬®Ác°Q6a4¯¡†oqm¦Z±šßÖpqP7¤ÿä&SufŒ'a ³1XG”áˆÉpš#[t_#òìwªòþH4‘cbY£¤ì[•57«øý1¸Ü¯û?gsùŽ6p­ZGn†8ÓÆršÛ‹SðrRÖ`88[›ü¦×uºå4¸&»IòØͨ·é‘°]Є+£¹2š g4öåo +ƒdeXùLhá3x-Ÿ+›qv﹌oÇÉ1LØ·£ù +Q9ñÄØZçx¹H—xË€Ÿ>½$T‰©óß•„ª­~د8¶º,¤T¥Gåy“5Ž$z'°uçó-ñ?ÅéªÛw•ø‘¸Êv$‡ Ó°ò¤€ó$šæ)ò)’±¶¾Ýlž÷IP–c Õ/Xêv÷­¡#Œn‡_ðC=²¾KÜG–wnÄ@¢éÙœ42ùWŒ•¢\>©NA«Ê?wL´ÐÚÒÊMsþ$óMlÌQ›kÖg—í¹öF}«1§äڛ߇† 2 '79dL#5Ü£”b’ö]çÄûõ$ÝUÞ·]YäSg‘—¶ó+r:>{ j°ÇCîf—«q<[f—ãqú…ÌÎÂåš;Ë\nH×ðí8]®–³qjÞ‡IZô‡’<—½‘ :«mŒê‡ýȪzÅ­µqÌ)l|«G‰ H¶£VÆzpµ$Á 8wdßn¾ &ÿã4üËÅÜZós-.Œ®>QõäsbSQ×~‹µbH M¼_%l%ÛùRHŽÀÈZç ˆú´dÚäBl +› ½ù¼HêL¡™…etÚd>n›¾ÇNiJÕÎcÀm¬\;–¿¥Âÿ¾ Ù«ï¨,ÙÛˆ†õí÷”%Nv®4¤wŠs"XÓ¥ ö [Þ¸¿¡'¸W@6úC4™›ŠI€åWûínµ {®Î|6Ú¤›GÊw°élâdR@¡“Ùv=Oi²ä;^}< àË`‰¶«”µÐ0ØÀZìÔ¯¦ 5˜!j]Ü°Ç:‘nî÷Ð8Ä1Öb‰ÎMíäz¾Gû2: <؉#¥hUO›Ò_pú@s Øe‡—°µM†¨p¹E`²EļÅ\ÝËãöV6+Tgd¿Ø‚Ú“ƒh‹<1²óD÷ÅlPÃrh—·cÃÿXMîd+ÝÆÓ¬2ž=wã\/ïüͺ})qàFY·ýd­gsyô];­VNÒÌm’ãD-ajílÆD-×ÍØu3vá›±8´û–·¾‹üÂÍØIOÌÊlßòÎÍÎhûæærpr¢1 CkÁ@ÞÆÐÜcWUáUUxåNWîtU^U…ÍT…4ÆÊŒÄC0ý€KìðTc†¡¼´?½ÔŠ­§R6)rqBÅÔ߉uû‘uÝG†Uû^ŠóE5d"ù³©’ƒw}ïˆidz­rê=Óø)ª1¢Åq«;¢@‘™p¤ÏSK¢ÂF{¦ïĽ©¨a„‰Ñ5Œ¬Þ$ƒ£véÖ¬‹4š€…}Ævö9d fWÒ· J\8¾v±Yô…ýìVÍs¹ +È®ã²ÁKDeg·àPpìL¹×-­¥4ÌL”Vr“L²n0ùšnh’éÑüfå¡Ír¦µUÕkàÞ•‚ýP¾i+º¹bE·¦XǤá1äK(£±ä ",=\„Þ³Æk‹ÊNnN=-.ðsë^˜óž’‡§ðŽxrQ6¯Þ%6£IÅ ýÕ—‘EmÚ+Ïq1 Î몶÷Xwüºíþ«ðQ¬" ¿¨î@W~/V×·ÎÁO­çö¡öÛÁZÙ‡¶rvÛ¸NýÄ7ƒÂªÎ^{ÛÉgÁ3/Lyû<¶”gååwÝRÖÞR:OC¹¥¬ê+XšI¶%èÎ÷õX“Èjýš¸ïKw¶Ùal k…ÅÔ´ÈîÏÏÅð"ØúsÞ"_»O1¾OÊî½–ÙýAÃtº™wÞaðe2ïÄ}pÅy·…i·æÓ ~ŽŸ#Mñòú#—÷²Äí™.E5 'Žàj4­Â¦9fà!êô|”ˉD +èI •Ïo_‘Wò¹-MõK¶Y¤àgð–Üæ×+½Ž $gÓÌ.„íòWõ¦×MnŸ%B?þ¾“zqöPÏDZl‚j’Ò¸‰ÁRž*ÙùÊaÞøÖ=A<{xéØÆ*kr]JYûÆŽB¯4€µ¸ƒ¥”+¸ª\Ï’Ä~Á¬ ÒÍe gäÄ¡±œ< èy°œÇ®ÌrŽàºÁ Ï­¡4áÒ–ã:"e <'ì[ÏA¯çhdMŽƒ²|Gs[ùM,s +º¬ÃQ´†W†se8gÇp¼øð 'Îg8°¢°›˜FX¾2ÅKPëc°šª¾¬…ìdˆ>×öÔÐÊsxg4ˆ?t­w½Å6n™Î•bcç銡²³Æ>°¿”Œ:iчö¾¨hF#ԌΓ6@•OžÈãêqqp‹$,`&Áب5¤ŠvÆšØmK5è‘øí9;S˜ƒ•Ï„KZþQ™u~Žñ:òŠ™FŽŸDÛçcÄÈ!LÊY³°†b³\ôuR‡Ok™Ûð‡ˆ|{ˆ6›‘ŠÑŒ +‡k¶Ýc¢RÓ--Qå° ¹¨ˆ18Av< àFk ³ä&Ògoz¦ŒôYìO/Ño#´_ˆäŽ­Vz¹[6«ö«"ŒÜMª•lÞéÝ3Êä¡Ü2D­àÀGóÕ`K3êÖæE~Ïz C3¦õ8÷Ä3pÕ²¿{¥r‹²ž«Þ3Äò°_1NfÑr1<ÈEð¸ß”#¡u× ÖE#u`õ_uŸ¹|Yu9c÷ÔöýsI`Eôú¿V.¢ëkŽC8¿åTrÝf\·OåìâuyçåÅvÕå9¹ÑÑtyå]ÓrYUÜÞvql=y‹lA¿s»Óåv5nÈŽVíf&-°ÛSº]Ùí•ÝÈê¤Û;5»õ›°Ûƒz‘Ù˜gžµý%0ϼÛðJìžl6ú-ª—»tLxe®›NüD¤/ð#9Z sÝûÿ·÷î=näXžèÿû)„Æ5Ù´oÉôÜêzÌnUwm¹ª¸0 (Sa[]ÊT®vy¼þî—x‡¯ ã¡Lb±=åƒd0Èßï¼x˜´ˆ”„æ§Ïw…e¤@+î¦ÜçÍè“€½éEZò7½ÈŸø›^äO¯wçœÖ´éÀŽ$W_‹¹í5‰ Jzê *9mzñ”Ïý‚×vE5 ”ý¬² Éú„µýPË"h’žjÊDu¿Ã1÷.:÷¾Š—Ò`œæ2 zû«Àf€YÞþb™ÅÄyEž3S½˜Gw^UûØÖyÅã!à¼" f¸ç×™w^yçÕ@Î+ ì Wä÷Î+ XOÁyU­TÎ+¢ÍœW”@j+#uwr^qýí¼R¿œ çUkíûp^ƒL&‰“[Á'nz†Ö4Mç•Z)X’X,0t^q0"Òa¦è¼jh=œWüª¬ ™;¯@6ê×yŵ‰óJBU6ìÐy•‚÷›Î+Éë¸p^5¹­feÔ{Å©GNÈw‚=ùzòÆ•Õ'ùŠ\YZä;)â5P©¹+‹ç•!\Yè|œÐ`ndËô¾.7²b„óÙp¹¼JíÕßȦÙK¸ï_tgY/‚¬¦å#È›(qû¢xWyE{ºšñ¯h{Í¢´>5c¬6‘·ßœÕ^D +Ms´4¨Íf?Çßœw÷ÆhÅ£u]¡î©¾Â(ïg×Õ„“°t/¢é1ëØ"Z¬²|‹~Ÿm¶3<ÜŒ6ìàe\q|yÚ¡NéK]Ž›Õ¼«Ñ»å&¡a­:Ý#$Œ4?û†åiv—¦…»Ô1ÇOÐYê9Þs¼ÇÇ‹iq|–¹äø^=©cKkÊ]c#2v¼êÀØU;+ËPÅz»;=î7ò°eç…ãñ7ÅÎÊÇ·;uQD>\$ °ˆ|ØÕmÖA‹çaýØÚÊ}O˜ïWµC5ž13À[—tpØæk¼e +ú®jI]·Í +¶æŽÛ0e\ëzµ„˾0vin¼Æ» åË ¯¢Œ-ëJæv8Ç´š#øÄÓw>Gn¥ Jˆ–Þi<NPªúªNʈ^ê,\M®ðî*v\x—ö›±- !,èomî•šE®²p/"t±ediÆÌ/Z4<žÀUä^E®ùgžâB̵W²2"é~+Ð羂nÜ9]ƒ`ºžÅ2/ry‘Ë‹\^äªD®å + Ul¼«¾eSÕëQ B8Äß•4Fóï£6jÅöF-håv-Xø {þ¢¾…?ËíæÖa&¾×BÂLŸN3HD¸ã‹xÚ¢ëhqk%•XÙ,®@¬"ø^ÿ + ^Õ}Ñk7©ÙÔ§Óì¸\Ý"vVÎʱ9¹¬œW-Þy1n:b\šŒ¶;KìJðMØ(öÜ’xkä’VÚ»œÝù‰5ú–.}¾·²kIW ˆêœ5\,ÁH¡Ì8øõZ®¼dã%/Ù*go³ŠjË/€ Økò¨ n…bøü1?<îó9‚¦Ãñ¹¡ùL»“Jt‹ºS%(¹Å­‚w²é\»5 ýÜ–€VýÝ©ww9ˆlJ‚.W! +y±ç +Äž85,PjŽ5m9&J•B½h†”EKMç;PùÒHd¼Â ^M¤¼š8àU•]EcÞ¬&:½6í$içøŸ U7æ»¼æ” žY=³zfu jÛ¾¶ÅÀçrÎu8·“9ÀA‰4ss€ ŠW~§ÑÀ’–؈] µº—h`¼/7Û­¬˜im(¦wƒŸ¢3FM¶óÏ<å9J]:äûTò~i¾ Ù +—"ùãOÐ7h=ÚZ_o·”L ؃™:,K€®óãýît*Ë 5¯Gú:zù4ª—¦í%É9Éù¾ˆhž3?±:¦xÑ9>f÷ØDk™ªš]€ ,0ëC•0N­(ºxÓÄLÏü̆¢çÖzÈéÙha”Ü }Œ¡¸ºù!â¥A‰ùî°ßçwøDt f;nFƒv.3„pùð(ÑZõnT»ßÑæ0¯4iuú}I±§Q96é5S¯'«I’UÁ77„¶Zƒ£iábEYXgµg8Y6U4XÌîÇRÜ-p›ïó¢–w¬ŒéjöûeN;¶ÔÏ*—0Û›‹ŠÔA‚÷RÈCÝá½-Õ¶PÛêŽyJAÿú–ü¤­¼ÅõˆŒiU>¦Wß¼ú¦dDlûæ±zt’l˜Òz¨Õ ÜlJ «rÀ„¹Œµõ7&N«9Ã*H‹ž^‹xôy*<³‡×RQXÙpQ废ðkVUYSýkÐ ôO ûÝ‹Žä‡¦ÚL㙚}¡ö‹Z0ì™Eƒ|‡~èô/Û'}OúO”ô#•½v`ÒÏà¼I¿WË­ˆÂ5]«×BázÎU-ÂÀ†‹#¥«(®t°vvIßÏíbÄ_Ê6!ui‘`QZ€Îê}¸à,ÝÐfìøöZL¯Í}@!ÙaX,òl§lÜ›µâµ ¯ªÕÓ`®ª…œ±šo¬[j‚âÚ€$‘T’ôÀ wÔØ}¡>Æ?áï¨M+¤¬:$£¦Pd˜ü]øã©Àê± ¦›7M\ÆP{–µáÃ:6»»ý/ZS…ñÃtq墓äÃfÿ0{£Z2À"£ªƒÛ— 5ÿÌ󸑹DgýÚ—ÿ“E÷ËÿiÉVK(HÃÕ‚h [:1ï6–¤¼ å)/H‘Ëþ™"¦èˆÔ7±›º["VÀÅâìe,ÒŒ“¡®ü·ÖOnˆjŠtvwüÅ"슿‘Îr‹9½ìŸ”A•€°E»è +€¸çú¦?'Õ… ÊørÕˆ?³e”;YÇZåQY-ÀÐH°ˆŠÖë]±ù+« +_÷!®áŒ¸VŒÔ» &dìØâߊ +vlS/´B›Φ#œ¥‚0×F¹WvO Â_áÚf,<ÂW +§Yj +-GWOc²¬öru*È˯›FAÞ檠ŠzúRÛT¯.¾RXÑ·Jiñ¹• J´6n«©Ä°OOãci¾òD­L^^ñòŠ—Wž°¼²\Ä:•uÙ=mjFê,ɬR’Ìs5A‚S‡bºjÁ©óV¬² +dúË C{üîøêò¦ ®ðç²,t45ê »7Á±QSÆë]±)ÛÅŸ¾^ܱ¸ÇrâµÊn+d*¶©—©¼ãî*e-MÛ»×$*¾î½‘mˆƒZ*QÕÓ˜¶mˆ[®N¶!~Ý4lCÍ/4®m&íámC¼cb#Ò zK¼FnmDpl’ÀFÔå•'j#ò‚Œd¼ óœM£»×Ô"ŽÐhd$âîd$jT$vn"Z¦`âòXUñz¯+¶Å´Bu/^·ˆ¤ð0#IO|n@rªe¦›¼Øt•bSª*„ËŸX0 +U@(°üÄpñ5A±ÌS?U£OcäÅ’š{Úd S˜v¦Þ¡ =Dòзî¨8ÚÊ°¯ˆ[³ÎÎÖ–6}׉Zt¼ðá…/|<-á#S•ˆå󩽦£X²‚óôê‹%ÏÔPÓ’‚dFšŽRP§1˜y2<‰Ä¡3\mt}+Yµú.¶q½[Q¤ÈBpJµýaÝ^ýŠÍ;øö>·Kzñ˜ñ·÷¹ñÆò—‰ªDK…ð /‰yIì*%±t¥J)¬â»Ê´Š·CÒ×ö•5Ñ‹XÀ|*Q¬‰Ë¦¢ç}nã¼u¤pECZK+’}L·ÒR)˜jfVKãÊM6¦NâƒåI¹xíÄGqÚ=ñQ–÷[B5#­ÆDmS^rò’“—œž§ä´LaZ¨$[ãð£Î2U€ª¡êz ]bÎ.6I"ÃIC”:Ëp¶ÛÊiº£8KwP¨’Ž€Èw½&;º  V˜ÃîàºÑÚÆ.ç+‰,ꦔDàM¶(SUÀ¾~Ûú#þ-¹«ú»S!î²o‘ õ@®BÂñÒÌH3q K3⊘&àÔÄdK4Ë;°)Š©?ÉÊbF’0“m0e"eÊÄSª ò)w0o(:tYk+.‚¾ÜD­ž+=Wz®t:»PøLôU{;å®P+@ª‹Êî V–¹ÊnIÚʯ0P‰,&jVEe¡÷5KÊn¶Û.u-‰²Žž­j=ÄbQÒ²êÒ%—G o""d5_P‹ÍÅ¥,<ï”ß ÍähW}½ÝÎp+B–h9Öùñ~w: ò—’‹×#} +}zje,8×)cÉ4Ÿ ?±2–DŠc©—Ýc-e¨bqXÀÇPt"Œ0#g¡NË +k˜–Å̆"åÖz(HÙda”´ }Œ¡*WBÑ"^”˜ïû}~‡ODj¶cáf¤eç‚“Aƒùõ#¨B{ݨv¿£Ía^iÒ,êô%ÅžFåؤײž¬&IV± ï–ØЪt3+|¥ #+«î ²±âÊ{‹ÙÀýXŠû±îoó}ŽOiG¬ ¬jöûeN;¶ÔÏê Lo.jY_f„ÂÈ:¼·¥Új[Ý1O)è_ß’Ÿ´•·¸‘7 JÆôê›Wß”ŒˆÍÜ#VN’ ÓU¦PÝÀÍ3e ב&Ì%œšÒߘةæ ë‹äôZÄ c[h ÖçðZ*ªï.ª\÷~ÍÁJïjª Ú‚^‚Ï äcGŸ{qÀ‘àÐT›€QK©Ùj¿¨%ñ ]¯ß¡z ýËãvãIß“þ%ýHe¯˜ô³Ì%é÷j¹Q¸Ìv{….3àÆf Þ§ —†%PLzÉ— ¥?™èö¦5âɧùgv]”~i-ÚbúŸ,:-‡AF]­ÌJ›/–x‰Ä µÜW¯;_ã¦Ø +¿R^ƒáL†½ &¨½ HI%@ôx,ä +š“›oêo~ÿe)Ðôóµ +UY¦åEÈ[{P ͸©rWÖ°¨r_*nž†¶#ÔÎÑ^l¹Nß°ºÀÕ}pÁ>ø‚]ÍôÃËïˆuØŒ.Å7$å7P¿7‘An%èV:}ÛŒ™Å¹×L,º p1˜{ …Ú9Y -ÉMVo‚rŸ·4êà’!Ôª³¾Ûì÷·›»ßKKa)•Töæ{ÔÙ›ïñ¿¾!`ûþÛ«Wïòó¯¬ehLI°4yIÐK‚^t~ïvYMS„Ãû“{½þÉu2ÃWC®³»ñ/–ëdþŸ–\‡Ä¡\ÖMÕ’IÎÙ͉@w:Žh"C‹tób\÷&:®c76º¼M±2üÔæ‹cg¤Ãí¶üÆM‘¯7­1Jî²Õ£Ü³ +¼¤ÆIjøÖ ôÁIf ‰­ùˆ—ÜÆÜÄæöbÇ»"é Ÿ‚aíxÅѱ0äáèÍ +Ľ%ïÚ%>©%ò+‰*ƒ™òRÜwóÊO2ÕqiOn}°QT~×0îv.ÐïšA‘ñÝ_~`/+áN2·ku¯Š„Y©ßúc(,Y|So`{†âØ i‰¢L{“› a‚z°,¤ ¯,x5àÖí­çç½¥kü²ŸX!]i}S¿º»ÊmÚLq\:XRV% f8ãÅ'}ƒYùÁ‰Ôä²ä^¸Ò¾¥Öáu¯Ü§9€ÜÕ³3ÓË]^îz^rW¶Š&*w­àlåŽÄ®§ëš„„(iò+¢¤uø ƒ”PH¤'gwú@éÉÜáØÅþÒÁ½(–3a—¢•ù)oýgPÔ˜Åëâ@´’†€\3 ç9,ºîÛgø$¢«ö>/iš~Âe¢Š÷Ï8çôqeœò.@ožjw-ŒÌSb÷^_ö©‡óæî¼FqÂÈÝÅÍGz`D Ú#~Ø¥›¥W7_†n Ü|]_~`76k5¾õµúûdâT½úJqŠmêíK>üjÂv'M»£ˆT,¶™úû8\¤"U=?ïïë(P±Ÿ¸›@ÅS JwWìï“‹#ËS&v+c‰åÒï×ÔøýŒ_÷Êý~C +bãÜfô‚˜Äž¨ ¦éA[‰€Nä°'îlHU€W!Uup +¤…€¼8ÕÅhn¡éä  =8S8vv~ýQnÄ#à.aÔðrCŒt›pš’ÒU{½ätõžÁQÌX"Ï 3–÷ zC–®gP!G fÉzÀµ?ÍÝ>O•¸A:q{á G?`°ÊPÌ‚>[§·ØˆþøP×õz"n¿})qU]‘šiçÍL^Xš°™i©JµÀïz3!)„ü',~ L (!qÀ‡åŸj^ÞÇg<8ÿYb‘Æw” +Eú{h0Ï^¦bÖñüzŒ4¤o{2“ +Aî¼xÁj–#M¡fïy弾ŨQœv^ŒòbÔ“£²l191j¹ìIŒz².º–P$sÏ]§P$uÌ)Í #¸åÈ™ûâ ­$Üp"i±'ܶAÆÊnï=Šÿ­»tÜ 9ŸîwGÛÔ¤ïa»zig¢¶hzÏ*íËpä½jÞtTJIPp±BÌvtÞüqx8Ü#žEÑÅ¥V<_IU‡w›ÛË~süôe~ø€Á=ÿèÎÑÖöEkÓ-^EÝó¦£}ˆU+(<ÍẠìŠÃùÒ¹]ñ´œrxÀ·Å›ZôŸð&&/tMØÄ´ +T7óDGÁ0²Iÿ¡M\¢¹ žXÊ&Z [M}¢î<|šïê æI´ T¡OZŸ]ûÔeºÛ4 ÛPÕ–; ®Ÿ$èg`ör §8ÜZ†°Ô¢5xÝo ¥Kp±Wî>NôçöŸý¼è÷,D?u4ûø¢œœ¡/ÑoR.H¡ gw_P"ÈI¯ >A bANÇ>È!#Ùà:¸.]Øšº845„aعie…KÀà¯üåpeFqyºÈ”Å ¹Fºnx ¢™w^½¨6M×è*˜¾¸&ˆ ëËRwíþSo«ÒV—‚húâÉ`ƺ ‚rm/+}fþ˜÷ùÁÖႈÝÒ‡*ïTà5 ÂîWÓôš&µW¼Ã{ìEÄ_®%|UŸ¼7” Z²XP®Ø-P^„™¨µ)MRP|©§¬+Ÿ¤z˜Ë(›fJŽ)„Ѥ„úëQ½âs¹ *—„K%šÄ ñ´õYηÊ8cBiÖœÝ ë´ûź4˸$A'®¾roؤ߯̓¾'ý¾H?Y-§@úijMúb¶‡w ] …§âka‘®j¹XR)u§6Ô­ëY1ÒGµý&*aö”XiÞKXó^uÓ¼G¾úeÁÅÊë_¸ž\|-~ÏÍö)¤Éj +üœÅöJ¹Ò ÔÊÇõ\ §KÕrÍ—Õëå +¹;ì÷ù>=b?æ‡c5S5eCí+fÎ:órE¼ÂZËæÖbܰɸûm.f²·©&]^¢*Ïè£J E”·²£Gfä¢ÛŸ¨elM!¯˜ý|÷ëÿýùï¯7éµæ)HOµ£+*¡¹ø¹â Þ +ÝòÖ ¤TÏ™|«rZhÜê«9e¬h _¦sƒ.ýÊL›ŠBVUäSK ¢É$ 4/^Æ%jgBÌ–.Pš#)4G&ÐŒ ø7ç—ÛÝ «\Ó*NÕœ>Sg£àºú2oö¥Rµݾà;r£jep]¡E“4Í^µòWºV€÷ð5ÚÐöØ·ô·ÞÜ3Ú^Á ‰S:¨ ¤ý„›Q0jLT­ŒjŵŒNõÄHËÒÊ94a¥)\q'`¿JcëèêX0Nt¬¤­c p†êXÌŒjÏ'=¥–JVuÖ­•,`éLµ,Ñʵ,ø›§f©X¬_5‹rM»©ÅY³¸C¯g ’7soÀèÍìHZ¶—]æ÷ Y¿'~½êÃùˆ•áã›ïÉ_ê?Çç›Û|߉ÜÍÉ•LÏížÛŸ·/ÂIp{šÙs{¯N§Í<œSåi#§ˆ¦útqîîiݹOû\mü,æyèd3'OÞ'5ì ŠGk º»† ¯2jqp{væQ¼;˜ul«Æè™ðï³r4S»(_ÝmÇõåÍ¢Þ,ª`¢8„£k…fQÝ3Ó&›hÙ&æÙ7ÅÃÖR½¹ d8 Äj˜î6=–"{Ü Ùµ/Š´§Xi(LÆWEÄÝöq[$y mH5{Y{¬ýEZTþñm}‰c$ŽèUµjr%#º¬««zµÊZa6MªUs› x®Ç&Ä݈èŽÕ¯˜iMôÎHkíVS“E”+dÐWÌlÚÒÇÔ¬Õ§á”e M³©&¡¹ m‡¦ÓÅ4FéÚ’§Sž§KËi+Ì_™1ÛÔSµ§jOÕ.ö88U§+TÝ«! ^©ôzˆWb[7%´3”)ôíþrzïP_&ý9Q—™žåVHõnx½ª% g< W]r,œ¼úÓ÷ôÏ¿ž=ÿŠTe•r(U1ê *,°Ð”±ë°À¯(wS”3E¹Âî¾ôd÷——›íV/Ñ%nþ¸5IRÉ?ãÂUÁIÅC(©x9º¥.1Ñ…j)úíëív†—s)Æβã»c¾A ZöÄt„)1h5%€9ƒŠ9ËY{Êì…2S0h§Ø?SeÍhk­â‹$ª£ ¸JáôÕåC6Jä“ß7ª\­¾œ¢Y ™ünÀ¥¦ƒÿñ¥T„Ø&’4èL$I +Þd$ Õ‹Ð +Fÿ»* ´ô©–{¤ËZ`Cúú‘ Eÿˆš¶-«QÍE³=iÑ™‚ŒÂ?7)OO“¦§Àˆžž*û$™ââ}µsd•‹dŽ?ÌGI,æ#F)Ãÿ®´1)O)u±˜Lsk¯‰Õë!×ÀÔ #U»¸ewj-ÏF+ ü…€3ÙE g*cCùəң»‹ñ1˜ÚŸ¹ÆÊÜM«*#?§išÄŒgé‰/h0¦É. +•ð´VY®ÏÊgsñ½} ßùÜ\|oNbJßÛ Ô×Åwµ&µ“™Þ¼2Ï&`<¦Š¶®Ê'/|KRPX¿Q—øwv©a¤åláՌɪþêšBY*2sö£€À¥|¥ +Ȥ"'Ÿ²’)ÕaH¤[MD¿¨Y›M:³bƒ£Ew:ÜRZD¹°ãÁUÁQ×Á‰í²“Ùòp“"¶oNï¸çMÏ›¦¼cð¦Ä‘$àÍ>ÃY”…/N•£…˜¡"QQô—Hé/1£?ΙñònópxØ!ìÖãÂÝ9¿§DÂuóeŽ Á˜Io/˜g]h‹éth%"m\þNvt‰½”í5/m+‘qq)@Ów%HCcNÎ5 MJÌQeË×äÍ0(Æ«™‘Å|φ×Æi¨Ê o"7¦2€™1›-)p”¼ÈÍ R.1„X¨–X¤§(di(X(…š©X1yˆ¡øÛ ¥rB嘛ÌÓ·ºÙÄ~CÓ¬:Zi‹7wm£M¸Ìƒü ˆßË’wAÙnSÎvë˜x§gÍõÄë‰WI¼‘êz|ïÄ ×zÖ%ÞIYuŸ'ù +í‘ K¿†¦_¯îzu׳®g]¯îzu÷ +wlu÷á°Í%eüóü3þ_©•4kúK»g‡‰P3ŸÈÎÈ€ùÚ·p柒po¬³óoèßÿØåY‡çôo‰û4^™uw–úN£âÍqO¢`öfû¯%·uE›¾,'9|è¨Ð›J_AêM-ßÒ”&ŸÀU…+c»XÅvÕ‡ù-)0Ìd!½‹@N8&-üAûÞ¥ã²?鱦 Aº ‚I@‡dŠˆQŸ9s,ú’¿°]@™š³Ùß?`•ˆÅ)™wpzæÌ)¨>à(6F[Y#…`±gíÑÌ®êÛ­IŽIÊfbò5v^ºKÚ «p!”"”Œl ­Y›ys;à\ü†¢×¬Kz­ ðÆœŸðŽïÌ'söÉœ•ÉœcÃdÎÒƒ²À‰6É6éœ%Ó?Ÿ³túGc“Ô!Âx ó¬! gžóäÎKðz–Y”‰“(ü”õî¡ø²L•‚¦rEyrQ!Ïšv®,pò:#F&•&ÄGŒ —ºÆaý” "úéΦî’BÇ+؇¥½‡ +ï1ËnÒ›Cè=ž(Ÿ Q^¡~–$ŠôZ=’e§øP“eŸŸ[Ô'Mû+F3dÅt&PbZ¹`}EÕ±@­¶ú“«„{¦'}ÛŸ¤Cý/»Ãz‚°ŽkÉv{AK3à’ƒü~ Â1¦¢á´ˆPr–•’Ó H¯æx{à«4†cÿê÷in1˜¶˜¤vb˜Wröbµf&Ó4¶—KnÔ\7yJCè¹ eÓ/SÞSƒ:Τtœu¤ãÝ 2k*ntÓ‘‹Ù^Üq“qÒŒéä ظú½¨yŒvÎÆýQÍ‹üCb;æñ¿—¡qDŒÃ¤Ypk5§œ½jpvýzž´¯…´ŸyÆÅ4†ï£ ÈÉÙÂ’“é±³ eì~ªqÀ³²!+ë¼@MMCѲʉ§ËZv„ÜŒŠ±¸Ž rçeÆ}[ìyØóðóãáDqG}ktbÇÃè¿)áÃæÆ;&‰<'›r²–eôEï{#æ÷‡ãùîr–TÃã&{A0v³C0r|»¹Ëçåãs\K1VñO}ó´´?‡vêÎ]š´¶“æ :°V—+/*;Óª³ò¤0ÒM˜þkÆ]ÖýÓ¿uÍÓ'sh8©i˜y_höéš•WK9õr;¦Ý¶$;ã=8mó/îå*é·œ¿ròéZHy´µöY—ã–mSÎ"½š™k× Ö$Ó hùô d¡b°ÍûZòjÂñªM‘B båú<±zb½"bM“tÄšEÖÄ:jø³œŠešìõP±LM3." +ºÄgÍX+¯Òz•Ö3¯g^¯Òz•Ö«´ â¡Ö…^»{ tçP³½itéDÁå:u¢çf 0í_Ú2ãwz{+WnT€SvÏùr‹¿±Þ\:xùËhj?ó0rl¬ Ó‰žŽoéù(Þð×_~ûÎóõùÚ‘s7TÒqúYãg¼U¦ÊÕi¶šW/ôÝ´ä7ðØ8ãJ]®PÌkÍfZó²“ÖÌFßÊóú”Ÿµs/ &oKíî22…1¨*Gº$ÞM5®’3…õ>ÃëÚR‹ÑV*)ùdž‹pÁgfª;òÉ™|r&Ur¦6 “3™6‘Åæ^‹œM³?}“Ñ’ö–ɉÃýËé|¸ßý·;³ +be:þ2¯z¶Vûª@©F—n”¾ä‹´·ÛñííL­aƒO^rýòyÿP×?"b˜a9BÅ-âtì` àmñ+ ·­Zz[IÅÏ"„×[ešÞJ¡éµ7™@ÝÓ¶äÉâ}—@¼¯Pácc™VÁ¿<ØÖºbpÆEÙãÆr*?ãu•ÇƒŸs0ý¯•±QŸæÓþ¬#š`t×Ô_žÇlƒäoúÍÀ÷¶t²6ÉXã„ÛÑ §JœU›Ü’ÿ'˜Òó¿ç[þ_N‹ÿ\ùZé!Ÿ7ñkç ™6’…wË )ûœ‘½Ã¼U‚ü‘v¾%»g ª-“XñÜžÑ *÷Ìî5{ÏìOÙ#¸þÀHÌÝäíÊ콦¬‚xZ‡um<- Ç2ÖÏû È:oþ8<î?­Ñè÷/7Û­f¸â±Šªª~>î6·—ýæøé zjkT®Ñ鋺EVS¸:ã/ïð~¬ äËHj+`£¬~-þÆFY¡1¿/8£n4ä×Ûí F9ac|. ûî˜#Vfx'Wã¶Ä+ðÍ'Åã+fÊõ!:ë’tàÏ„Õ]§ÜÀË +v±»&Ë݉"VZ¸±Þk°26#ÚTnm`fj`>•*ŽÁB§ »u „ —M‘}Ckýä¼.ûb£Vv…˜m8Zçߨ&†Û1,‡ÿ)U­ù[Êswy +’t„]DÒÉjq* bœ8¥€Œ»üã©šÖ?п~F_Œe`„”Û}Û.^ŒˆzÂ]¬wäûbüææY"ÀvwzÜo>ÍRü¦ïòuP¾ëšôQ6BáÇ‚< +_{A‚Ô™¹Xç&OB]GŠ7ª÷Å%ú—µj½² `Y®Ìæøî‚g†øZ ô÷õB°ð_h¿´û âóâ3Äô3”¢زžö²±g~& ŠO½ù¹èÏࣦxíl<œVÐ99ia!Ú/ ?‹¶ðSîHÓsPh©å)É&öqs%8¼§¨jP€Û:HÀ4Œ‚à8jà!”“(ð_QþW“kÿÐ;ÊåxõœJBïì&=TI©>êJõ2Ǻ”?5|ç—xǣ#°Þ‰aÔ™»eäÛ¢±i„ð r€KµmÉ…cÙX È0æ]&‘æ:e.òfñQ¯!§ŠÊîîém¥EoKéS¡LIž"‘ ±ô%  €œóÐ*ö:ËI?gB‡u{'dÂd˜(É°ôìZPaЦ'8éƪð»#ÂÚQì‰Ðáµa¢ŠürN„™>b ° AŒDk< JI0H°éa(ðí.ßo×—ÝšüÇÉ‘{÷ðûÀòóF¯\½`gLÌuw&Í– ˆI³Eg¿oc®Zd‹ª…5ÿ”Ÿ‰5Ö~ÿö ñ*âËV¬ÍßF/ÚVPѶü‰ÌVOïÍ‚~ ÊKìÓ¨³ÛKiã&ochÆß…¹6Ì PM©EÒÙ³w£Ž×娤 #DZl'V'PH§þ’æzÍ´éJ‘Èr01wg«»‹¸\6w¶°•³…ƒÞw¬Â-Njq% G8‹ß–¤C”X×@°vü—E¢Ž8ï^…+Ùš›…x ®U x„'í˜p5·^§0.É >lëZö†Ï9r•q]±À·*L;¢mJÆ'@FÉÒp,e–IŸ]Dkåz×pfR&•jO³ /58)ëLIÁ¦¤D²®Ýè¨Ê¥IG¨¿_«¥÷€ˆ ¨þ™²Oe à×—4*¨\bôV¹Rä¶AmùdBìL تÅê=H…kuS»œnú™nµXÜ:ɘñ!èl}tì’‚4 yɪ]ýDàïSŽëõ¯Ÿ…ËôDÖDúŒ™»dz¼#štsB¼ù{Ñí¯ùñþ$#ü°ÝÁ|Ã|_.„gûçÄöx•Méþ~z\Ÿ©.{¦÷/FYhþ+&zÈëþ¶º¿½àHgHíÿ˜Ÿr—êÿœth'$0]8I-•Y+Rp.É;Z˜o«®9?jüK®ÎöØ›·×3úµ3ússU§i<^wL€Á‚ÔqHp.žÑí]/¸Bò¾èg¦|I!I¶ù1?<îó9:Ň â íÄÌàsN*ó…`f&j žAG­œ¹ÈC’®~M»{Ÿñ¬N`ÖFŒ¿E;œj™44f` ô oZýù¸«!ÕÓ+¤2›˜YñRÄ̺‰™MjR1³â•ë+f–àùÞÎ{Ðqƒùgü¿_æ´ ÆIë\à +·»ÃvûUñ‰æ§Ö­ëßJ¼¬P«yMšY7y–ƒ˜¶ýÓ¿±™ +)òË~ÇŒ}2KiP½)?¨ô¦r|þ‚«7]ÆKEÈRµ#`šHŠ#+`ƒ€°9ÓX7ÁÿA3УfYTW›ìõ ÊúM¥Ê·^.GL8$[‡ÔÃ8 ô-‘Ro–Áo5±4¥{A–(Œ~쌺™ÛJ“ꪀó·—ý^â{‹ë´¡DK`ün¿¡³>7ü»ÜÛfÊ9廸ÜܨžX<±`b ‰q¬ˆ%ŒÄâ0LEL²˜”±iB\–$«X ‡PJ¡)=¨néQHm@bÛÕÝi‚h·h¢v¯Ñµx"é1Õ8úÇÏÕL Ç3ºu +©¾ž°“¤ã ÓL0ÓžÙ à–¾ÓƒÓc0õìß‹bÁ„Ù¿‡Oó<ÒqG1\ +Dhþ!¡ÍCoAZ˜·€A2g‰–€ Ñ3y<œ7w‚xÎh·v‘ê0†–UÀH‡í¦²”I 77Yœnã‹.tÓ0[uëO㘱®<Âö¹]‰²ÐÐÉ >B—w»¹ÃA4ÑóJ–¦Oqœ¢­:o!;§êš_Õ~žB¨÷ +“ Dæ8×Xü>–f¦”mÒ›ÛͳˆÆ™Ê]Ež»ÃPʆ*Žó×½}éUºPx:øÝ%¸Û‘Ê>L?éB@?ìÝÃjäifl¬ÜF¥Z$©åªýºWÉ1}ˆü€5öª²*X§#}ºËÿ—,`{ÖR—7- 3ã‰L^ Ó ]N/5Ÿ§KO—é2‰™yú¢Ë$êB—}&Ùk‘Ÿ,ŠyÂä'I¤ʹ×z ÖclG È´îD€ìóMþ b‹ +Õ`˜[”5—Y0}úc¯ÙãJïÜ5{’ù‡ü\ƒ |¯ó»ÝÛÝxïÞÓ¢§EŸ®&ÅHq5G‹[ŠFÃ\pbØæD9&N‰ò‘ƒẌR¨†ÑY&|âåí1ßü¾Þî~×KG.WÿÆÿûeNz¸Á=˜äƒcúyÑê‰I5Y*Lªz/e©!&ôj³Îm‹*êó¯øçý¹r£TÓž +ÆtvŠ;æ¯ç¿g¯®àë@läY‘¸&¿LzÌÔÌáÆ &@ü•õ”¸7äÄÙTrÆ$Èž][;j½: +ªj™TÑõ73-›€Lú´¡¸5¨uI¦hLC?@Ciåê##ÛET;£øñÚ»aàGãk5EòáC>ä!K8C¨8­šôHQ™˜4l©I¦1zì‡|ú +ÿ kR±²-Ö›W§ä¨ÅyØ|ƒ,$&zKý$(4u´GMõ†ÊI{¯˜xÅäZŸôª˜$p±H™b2©°Ž§«Ž0AèRÎè]ÙîNøstáÄƓƤÈ>裸\®9 +ÐëÐ"pUiQ ¡qU‰è0¿ýÀ^QÚüsóÇß‘8¼)Q&Ä,V]5'Ù=[{†%{ÖïEg|:¾¥;¹xÕ2Ã'×É’«£t¡’; LΚÙ"О™.±ªóœº'V‰ò&"Vz-3JÔˆà©UD­ò×ÌÓ?·^÷»»…ÚYvÐdùÜ8ÈÀŒ¬q¬G³å„,õϸ¤7¶¿VšðoË_»k¡à@ž+'Ë•^UyÈÆPDáJÅR¾,Ï› ;}˜ƒëISHš+%i²ø×+m’0Ë2ÙfGæÄ}Ì?×IS5".ï ¤~²ÑQW`àe¢§£¶ÞÊJ[McmµLœÒÌHû7ûîôB0ZŠÌs~ÿx:£ÎJn3£ÚÄ”jË2=‚u›ðÊø3‹à{za ¾kbŠCV]†Æ¬Šð¿üû+òC=«òç?ÿ»˜lcë ÌÖJ¨ó­W™«BW´¹¦m“ï>™+T1a¬LÊãYïÆß]ÙÚÝ Á8ɪ¥¥Ó×¢åÀ5-{ +öü,(8 G0ùÆÆ*lŸXJ“Þm˜¦&*»ê§ÒDÞrv×òÖCGhãAcMóÁ­?4Y±›šQBt6Óq‡’[o½¡%ÑÕoå¡WjàõÎÐë2¢ÇÐ2ʨ‚oÕíeTãa¯&]´ÃðÿíjÍ-wcÐ-:sdzœL&Ñ»1½›¥o´ ý)ºî`°íç"…×G¢LáCÇ® ßî›jqԯĴ‹#™*pòÖ]×ñM î;7ðVéØË·¿?lÕºrÑø76ºÍ<èàVcˆ#ȸ Ýåf`§/kŽ;}B4zÏ*ïÉ_X]˜¶ùÏç§üáòW|5µ¬JÙ*gð-欘¦!»ãÄ »oùΞùuÉ Ò¼øM[\Å5 ´ŽP‘ Ö%Ë¢¬ôQ›Bóýš¤Þâ9¿.YA;£OxGqkñsüìœ}Ìë=¸(b ’~Bgx.VðŸ-åul¾#«^àøOh\– ð ðß~EØõ:/’´ˆ§ÉûæýápÊgø‚ß3ú5gTóñ¼àyÁ%/$†·ä à†ƒu#ž ª>l˜Âd¦¨jc¶°ÎhMò 澎äAÔ+òà{—<ʹŒGxFäðäáÉãúȃ=l]É£ì£_ò¨gzäÁ.läAÿí7ïò5õFcYß’L“ÅaSÒ iMÿ÷¦x¦ÊáÏõDŽí—¢à~½òŸ&Ä#ë… ãŠÂ¤;!-£"¤%Ýa»ZÔ·Ò=g‘”Ϥdçkú•¿ÞnEižÑO³j|¸<'Æfî Š~êBd²æéÄ_€a.nqµÎ¬G÷V°l¼çšRr;,g³-<[Ð=‰L¤Ðhä2E4õ9<É:¤™ i*Øs09Ç‘ðÀ½Z‚Ító¦ `ØÝÕžeå«¡ì Å2›ë„ØóÒÆFÛøá:ËÃQŒ\ž"ûØ.¥DyB1$…nÁñ+ ,¥ÒÉÒ‰t‚Nß½Ž\R´«¸·ø·)¤Ñ³Rò¨{¼KzÏRh?š½ñtå ÂGdêN LÒŒ€QtÝ·h!âbÈVï,60oÂßth1Ëc‹iªÈ¼ÝÞO-Ò`x…‡ ¾¡Ñ€ ,/°Ó¸*I¡½„rAk-årøÕœ¹#¡D@žP%×õø"‘Λ»ó»úIC ín¤Ïc¨°•¸žû•à\´iÀä§+ ຯíÆî5Änˆ‘,aý®tbÛÔ[¼x`$°›ÇH<`ÅP<à0‰Šõ4®O<à–°“xÀ¯¥†xÐüj#‹ +òI[¦÷cÄ„}ÁbÄl0Ù0WÜÌ/›Ž—í%è-Åùðïóϸ½FÉ2ÒØa`L ˱KnYŠÙ Šž`ž=û2e§pÈ#[žŸ¡}BL£¨¦ô®!êv ’ÐÅ(L:0 ¯Ë@ö‡ˆ‡§~Ìïw8„þÓËwùyŽ÷™ÜÏ_ƒa4EÓ9nJZÑÙC°…ö÷ys/7Oè=ëBD^‚AÄ™ š„ô‹""ÓŒ~:‡þôO, iü Íâ4‰_™9øÐHè¾I8ŸŒÜ7©s„¾IÕö¾IõäF¨µѽ˜]À5 L¥RN‹¤ù2Ç u€ùÓ²‚áîAQ{&ÌÂ13´B^Ö„¬‡¼xÈ¿‘Œ%°ÄÇ"#À˲´d\'?A»âÍö_§dC®&ä/ ¸ü%ÏÔ`|ežÆxÇBrrjµ±Û迬ŸãCãË$!AûúÏä¤ZÜ×Å…Àéa7¼™[ž«ZÀ§¯(¿†Ë½«R¸¯Í%ƒˆ#-q,^{{ïw÷Hõ™¿}›ß‘ÐJYéù"Êæ>ßî6sòàÍéüiÏd§"\“?~ÁÑ7å_hÿ†å/H·MÓO˜,ºgã€?YØŒ.êþÞú&#öNmÄ( ¤;z¡öüŸß‘ŽE÷i18â@NòÔŒNÂÜZÄ'Ù¤}Ñ÷6e°^#9ÑÌ™å™lY1×ÒGqš_÷}’JÒRpƒ þ.ÍMV{>Ùƒ.L¯Ñ +›b ̺\½vox„Y™™n•e#Sý³Úäæ ì}{䶷˜¬×öOÖEï[î þ»VA2õ—‚d†n[Æmå…Pªˆ3}R+˜:ì“tpRÇ6ß#Hw#sÐ.O þ7†°?Ê^pÖñ¢ûýUçkÅ9;YnBI¼P %ß’Þa¹í]ú³M¼hâEÑd9qÑ$X¬œÈ&%V‰ä“µÄbJ¢2/`î+¡ÏE +ñ–D£26¯X !N¥šøiK5ˆW…R “6»ÎJ*à¬L|'Þ¡]fu·ÂKûJ +:\³Vö»÷ïhcYªÄ™ïÐǃ…„'øG/ÊxQÆ‹2:¢L˜N]”ò®õ ÊLÊÔÒL¼¹Å¹% : &=š\ȘêÀ4M²hÈw’H#]6‘ŽHaŸK#ávš4“Cç^2ð’Ïá$Œ[ˆ S­šœuà‚´&Ñ[fÒОã’i¬§ó|,7=w6€;àô Áà=IxšóáõÂÏtOK5a¨—muwÙ™š’Œk#Eèõ_Á*Ü —‡çuZ&Øh¶¯ñhß>>ì›-÷¶Í÷»’TÚWr1Ú%%=ƒ·k»¦ð?•2–o"Ò Iô)pJaI©S:Õ"ÍÿNA­ FÖº û¾¶ön@±“–Õ®d-®ñᜪoK!%âü¦"ÂèŸ/·ûÝ]›wg4â|Z“¡¸³6'¦Ý-ÀIƒÕ.É1}!;j *€ñÌè™ñé1ã¾ícÏŒ™ô¬Â™Ñ|é’#îyRµ®£ËTlì$E²å”°¦}ý Á\uÍãwâjU˜ÄOÌqóùgò0yòqy·{ø2çz:¡Î^0ÏÖ´ÙÝÊ™Âé‚ÁbÞÊWêèM+Ø)–™úGBÿø !vŽÆ…s?6ó1¿·špöOÜѤìž)Fz~©A>ÛµÙ_R32}V> ‹çóò…¦¡*#$¸mäœÊA ¼&`ÝÆG07'P{8Ñ2X^ô¢ dÍ·ð2)HW±^ò”“Âã”gµÒ‹Y¨¦=æ§Ü’jù. ¹–yØÙfÑ$ÛÀ”lɼ:²m&gÛ_p×0Ý¢nȯ|™ñ-ÁÞ»Ëé|¸gtÛ$­éS>×W¯ðl~8‘?œéÁö,îY¼dñP‹ÅëÞ¹&t_N—äam|8’‡‹3k’VÐ|E4½ñ;R/`ÕÞaÙ™˜†;ŒÀéé«KPJÕ¨†(Ü%‹¶8‡‹¶e@XR*Oë»Í~»¹#Tõ„s³þJ50)fc"ïŠÓ«0…iRšUæUc!L;sܤŠ™ÃáIÛ€pXw*]­©ŽT(–¸³;ti©¹zÜm10Þ–ˆÇ*UL†©uùwSGãSfž Á<屟,ïdŠ²ü®¹hÅà„€v¨fSI™‡Št;DÙÆkÈïXðï#Õ;ÚË3P2Y›ûI%[îÝ5˜¼\‡«LÍ~Í ¨GN¼I. W˜œ«yeÆ”Ómâ: áà¶75ÁxŸ‚G¦Õ,ÕhÄZ[(j$4ÏkóÕ§s%›…ŠRH«@os{Ùí·T¸Þ'?æ4 +ÒF6çËi^<¤¶ÇÈžsbY© +ÙÃ3зÇpÞ”UµÝ ²÷Ä/´ÇŸ+ýýô}ežö•®Ìuð\í3âS¢°ÏÀº±Ïˆ&5)ûŒxåú±ÏÜ#tÚ=îó"Ç™·‹ÓFÅ eüÄNÁ‹¶Œg¾#\7/¯i¶°ŸŠe47Ž§œq÷ëÁÙƒ³œ!8+€Ìµù” ,+¦3LV.X?€|Ì?ìðÎS2™LU€>s*LÖå¿uÒJ’~^0]ÒF†a ðvMœ”º‰ækt‹YbóAÖ°MËÖÐþ%ù 14ù óÍq¿C]ÎJ¾D, +¨p/S Á½‘]õ…çgšRÈ@ÂìƒIRD(’U¯X ¨0ìò¼M%å±Dôdµ%¥Y{¡<Þ•-¼üÓDS(+"èÁËQ¿§qEq•YîÄú{H¯âè}n§7WűC„]…Ô°«ýÝUž]Ñädz%»ÒN Ù•}È »f–ìJgÔ»þBz‡Ù5ÅAÁøçÙùà˜`/Û'XO°ž`9‚¥gÝ’`KÀð{u[cý0{>nN{ +w.Èvþy¿yxw‡ÛvâÝV Yv¿‘³„³F¤†Ì˼S7Æw€tHø×úc€|ŒÕÄ|<;¼mfÌõí zʽLb #}Ħ´†/Oh7Î^.í9›4(§¡ K­Èø·–Ç5¤»½ T KÒ H±© é,Øï1—‚BŠ…¥LPè)#‚yâD¿u¤:n¨™ÞWø _c”¦ +_#0¼>£ƒ©|#&ÚNä›rQ|³:,Ó$Z$æ¼l_ÆÞÈ>/Ã6º¡K°:ñÚ>‹o9ÞsËâká7n…ßxÊߜΤü¦‚sî7Åoû’BeÞøÒjîÞÏñÏF ãÁç\dŠªCxú¼ÂÑÅŠ²Åf¿Ûœ +¹µ"›0©áCÕȪ„¯)§„§\Ž{:ªoññ-½ŠÏ‹"ø~ÐMð¡hRãçU×Z9çÆB³%ñ€ áÖóÏ»­~=±Öã­{¤Ým€QzßÚÉêáwèˆâxoäu0Ž§Ž×™ð<{  +È“H‘Fuƒ‚Ȭ-TçÆõ$ã:9Ø †Æâ2fõ–r3šÎëJ/g²«éôZ¦Ø€„âUxÛ‹‰¡|‡ûÃ.ÿ¨M#]¸Ã!I$ó'’•ÔbŠ¼äùÛ/?²ˆ ¥„‰ŒÁ\òüý›¿ä‰&ø÷bÁ‹Yýž:áæ™e²—<¯:¢Uf¨¨Î ÿ/¤}Ö¦PžÖÔÆ7æè.a?öœÆm#%˜ÓöÖœ¢—öœ¬«°ë½G·®p ™l$“0Ö“ +ÇnoÕ̪Ãæy]«Ž¥dWR±l#úâ$Nв£õÊ“³íÄ°×u¡ØÝïØzôÍjs÷þžËØ[RKÕjµ¨Dò@ɺ0ÖÂ#k±…0‘/s÷¦êýÍÿÆÿ…ýœœÏiÊ>Ív7ûÍ]>Ã#¡7¥ŠØ Uxq~sÌŽ¦›c¤»¾í!âÁ'l 1’iŒžžK¾FΓuÕ Z„²6Úòz=Újþ¹hM2À–ÿ’Jì­.Úå:ƒîBzƒI_ð]DÝÉ[o¼Ò^:Ük´ÌòÛæ¸K¦ŒH¬söFeäËú‚ÅTŠ{VÑ™Pì&®dYÄnrŸ¬ˆZê)~óz¹Äª, ›xzT“ÆEB™=RNˆÙ_0%€0åÇZp‚U fh® (ýû@A«7uP”[KÕLx •e@ÕßË©ãCrã!€h.þiïô¨)÷í.ßo‰ÅG¸øgeÍ?Ópß_˜;ƒóÏÄ“rÞ†´Æ8/Êÿ× æUçÄúaW)ËøMcÿ–vü½Ôæo2iØTGúÆžÇÔ«9T×?ð¨Å+N­B·'ñ‘H%³”/š×Híî†6>½aþ…eriTKŽ’Ý7$E }>\Ša+q=‡°ýþµ@ˆb™TËŒD1ƒ¤o$‚X«‰Tœâ[»°$/…9`X;i(Ó–†êñ¼ ÙÛ;صw„6dÉ~ ÈqƒœËæÖcáF7K–ƹݘzýHe¼ë5#<ÞÑ;ŸóÏô/ER•Ž*ïËa¨G²UÕX|JüBúQà-D…e—høžqsó!ÿ8£mI%9“œ6Å`wÇ|ƒ ‡1ܨ¦8ÜkÑëæÔ€«þËJÓl¼„¿ìßËeÿf + n[M•p’ÕB]àºyš;\Fz'ËÊ.™–˜‚•½æD†ˆ_,@ç{Jïd©4–}ü[KÕô×RPéû|ÿH³Ñ?´(µ˜É̃Ë(n[qdÖ=2)’¸šƒjQ`$ +e©3í— ZMþÂê!´Í£¶bäé³÷õ'(rÈÐ>f]ØÇZéá/Ë;PPe¸Üâl…½«°âês«ªðéõªYõ$E¾ÓbªW¨…¥†‘<Âóè`Ô@Úll£Áƒ¤ebýK´(Ϋ’²”qA2«‚2p]Êh´†2ªA¯ˆ2˜u÷”á)ÃS†epç]EUcG”Á >)Êà¥/Ê8滇mþ‡ºª_K*žìf¨ãvrÕVeG|=™†¾mŽ«öÇÀeÁ/E}VÒé7tEA.ùfÚÏŠ± %x‹0ײJÆñ7²ü,ùeÛÈ0ašþÙ®Û®­âi› ·šÓ›À•[í…t~é¶@y0óMCGªÍ¨üî½³#×”g%í§‘´Ì·mïÎô—Ÿ Q”Cê´ú½‰ÈËû^ÞoÈû¡aždþ˜‘s¬oÁ ì8CåÐB?ÿÒ x¤ð˜Á»®«@ÓKà,L) +Àt øk³ãYA?ŽO2€þo=Ø{°÷`¯ö±©q8ÞägMW€I§5äøXÀ*ôyE8@Óö¯iö’ðxçoà÷à9 ”Ç[Ÿ# CNŠÊUpÏïÇóÝåüú»ºÄ žÇÍ”ãÛÍ]>/ŸžßoЉ.ÿ€ÐîüG:Ýà~µ-ýŠî_4zdò=tO˜œ… Ť­¬lÝÞ]ßCù¡™ª)e÷;ãÓnnÁ¬nKãKçÔ9°ÃO9“Wü+›ÒM¯A¾a=M<9uV5ëpMŸI˜¯ó;#WÅ›®àJÛu8ik+Á¤·lõrÀ€Im™´Ý¸Ÿ7ä!á½Ì «Kž +ÞÒH!"Fzc¡Ä2ë„íÅ”g„0_Uyl0ø1Ý&‡§ÅNZi±µ  ûK‹-ò_ï5÷ Ü4ºv& +p»‘–àýÈÌôëÉWBK0Ý«Ì—•D.x7Eƒò—ÑD~(æÑA#]ñLjÉE'{:¾¥'§xË.‰&¼|ð$ä]Å–D§°?ãM4U©!C/3!©a¤®µ—ÞHe!=)#>TÀç¥)"m]/2åÊ~… ´ Ý x¦¨k1ëÇE˜`œÅà^m¡ ˜‘=ºÍ›6ˆWV¶»l8«¶h·»¼­ÝîyüšxÜ_çÕ‹rÌ`JZ©;} í|hÀÝ„›QW–îÓ?²ÓZ»‰d)îôqwœ¥$öþ3þß/Õ¤¤YãÉ/šm+Êê®ÆÆ)4IÒBƒ³Ô§&.˜}ÙVEI4ûk²@Œ*Ú"*´!^׃K£Ù1âÜ]NçÃ=ÃL  ÃC¿zEèëêQËú y©{øOèÞlÿUÂvU[€ä*ñ«²»+ð{ßÒ@鎚*#% E%”j?À•梮A¬ âÿ¨tÀ +lt?œÈ[­¯~[¹²W¿¶T‰ãÏ%‰57rG«‰÷S9¸£õétÎï_Æ‹ ÍC~š³?AÁ?ÆüÁXDň­úW¿=l.èàwÿoåÔ”Ucaëô‰çJÝaÑ€èâ³O££N$Žmiow ´%¥¡-U3›È–æ€ã¶´—À}\K €‘#-Œ œ¾€[ŽhpŒÚˆ¾"•gÛüaçÐ#`oé!`ä £É!`Ô/ÆbŒµ0vˆ€+5ƃ#`ÜF@|\6ïòÙÃá<{{¸vPÖÙAw¡@›Ö8àbHËù÷WÄtÏ”—úó¿÷™V¼~y&qè­T.¡z•ƒÁ•ïËT<Ž ÆçéÆß¹«I”Á¶Ñ¨÷¡…ä´®¡™˜|ø€£¾èktÀÉ-‰MÄàÛk +ZRI:¿w/>{ôïËü—4› SˆÑËVdš ¢=èøFh!z5C@±.¶»ßîÚŘ¹iñMT¨Í´v!cÃ÷ +ƒV¥P2ì4‘}çŸèì“önÅçò|ŒŽÊ’Õ“/;“L¥“º3MG0>+ª•L§ñŽø ÍsÆÌÓ¶lÇ—c†‰=uç¸yKñ/RÁ—^L¬±¦ÜìÛÝéq¿ùTüžâ7{—¯IÖ=šº&—Nï ì÷Oû"PD ^LY‰vVߤla6š£ Ð^–¯²9¾»à)"|}¬žÿªûË;ôštYbº,dö© e=«eãþLÿ(žzSr¥î"S8æçËñá„Žÿ íÓS^ýfDJ5Þ?-î©`Ï!½$¦ñ… ·'W<­X^Ó†ÿþ$¸ý^ Ü=ló?äÁÿ&¤¦™‹«ë!L5Å’ñ¬„÷X*¼!%Žj@ŸÌÉËëƒßFïRí»qP„Õ¾™v¶Õ¾¹!LJR`úÒcþx8žEa#Í_…`Ê5t§1˜Û‰¡šbÄiZC2s]ÌÏ\ëò³ÓîœÏ˜÷ô úÌAÕBJe¶‘BJ-Zº‘R«aLJVp-úW$]î*'Ê ×þ]°¦. NŸTIë1§ ²8Xºž¡—a=Ü:†ÛÔ4¡³àèÖf6…^ÕÖrÁ¡Ç]ÁŠô»·xg“œ¥/ߟïÛW Éï2°e8H÷.À|vxQÈ@V¸*Ïc÷WÜ? «dÀGƒ¤ÒõÝf¿¿ÝÜýNºÌºuù+…dGÊÜø¸tÛ¦2©£g +¿nªËŠàÿý᰾ݽ[?î°±VW¼9:9ô¹ÆlF´z‡SCïkËÅß%—tu$…¹¤I Ë\ÒÅ(“È%]½q_¹¤<þçéÐN¿?0/Và•n3<œã±ÜÄ |b«ÇG |¼; ˆÿ8ÿü{þIšSŸ´zрư»5 eª'd,¢”‰zfAñxQy£Võ9¤}¬ñìlñþ8 Qò§¤´YÖèZOÓâ(§¿GÝ =,Ê~Ÿ] °Ó¬›ä$à<÷¨£2Í}ŸIî«)ËsÜ×s—f¶gÀ%ˆ†BÍJôxѧÖGXŸò3Þª дÖ‡»z—ë˜gjI4±¸bâm;[3º¾¹ )„ÃÒÒÉ£î÷è½ßM8³*á›c—¨ì¸*L©ù‘:¯îø¬÷“NŸT¡ûk4¹® åRåyÑKÅÉ<ä4'7™¡DZqéyåj5:–"tl€Ð§ã[òí*¤9ÙsþW¨i»¡‹Z¤ h ÎBnD;Ó@*3 |ƒçW<+ ß•”ã°Ô<àKŽ>u5¾S@@k_ ¸–¶aÇ÷MkÑ—ŒºÝàƒAÍ\›íV/ùå?usÞÝçsúèiÎ>«›SÖM…¡iwá’öqÜdù„ ¼aX¨C3Ù?Ub.vi1‹ü²è}Mpìëv†ÛÍJãéØb®ß9Âñr&úŸÎŸY”ê½ðùüôH×ÍŽ.å+®e U>ÏE :U[2™—ÇßևãîÝ}Î2ùÅÜê™—tYŠïDÖŒûk—ÉÃvSú’OT*3Ã`Þ.'¿MB ¨sý¼iudÁQæsˆÁR1…uYçÁ¥R‚K îv}yÄ£¶¨þùåãûÇùçãԨ]7uæóÕÄöýÆÌôeü#xÑ|÷ðö gŒ £5yl³5óŠw[_Žû5{!ôŸTGû€gOJf•wŸ‚¼6åýÛêËLŠ¸Ê_ýJtg +Äÿ€Ûôû½cA›½ÁíÊ⮇ÇaJ»–ï (ìÚ|©í›Y—aü‡ÀZ÷¥[”Öå|À[jŸ \´A[!?æèÀp­çŸÏ›ã»œÈ/óϧ|Ÿßáýº¦—ÜßJKûZ墔ùÿ¿Ì/;Ã|ƒ¯–¸ØA_ïneÊohmúŽLùkfží+áì¯ÞJä­D"ÎX îâU‘8xw2‡ Â5ü*ÍÓPEß„ì챑¶»Ë¢ÀU”g‘ŸbÌOÌ;”DEþÞz Á¯ì[¨½»±=ÏÉÖ’_ç¾ï nnÓms«|Á“´ÎG‡x»×½éRX +€(UR¡À>ý.D”à…Ó‡üˆÈ •”ÈÙœ¹^ÂNbˆ¯À‚34°wçÓ–uþíáãÃþ°Ù²äº-þVXéàY>w6žm=ÛJ4´¥" ‰ÙS--jœ˜ãúh˜†¥¢FûFYc_G¡°µßKª²5jÀbðÍÅï+ ´éÅ/‘Rmdb7†)ÂOºˆe +#°ö~X1‡nMÌId#š0 Óë¤`\Þ,¸É¬Êzìã›Æ÷¥<¯ø¦84tØkž!¡Oðè›ëlâZ3?äIsûÀw÷8ÏÞùpØÿ¾;a?­÷HnˆÙEµLÍ5t©™ør7¢Œ’t0Í +—ßýpa[ÁK‘Ò £ÅTQDÒõíÎïgwå,É`œÌƒ2÷Otj?¢uý¾ÙLPwÉ ÔÌ›z)ÚKÑ=e¹ev™"ËmÑÒM–ÛjØÑ‘^‹!À—Ö];ŠêMç¸é¼ÙT…ÈÀSnü– >6ßͲ*”ý†v9°7çõág'è»-ñtv9g·ù,Àû­‡iÓCÉÕI¹Z~¬„rµø1[¹Z6¡ñMkÖ›QƒÅöü´!wL6û@Ã>ß õ.œPÀ +4qÇ éºs“éÆ ÃÂðÁwÌ2nÀrlÛÃ=x*óRšø~°áñ‚S/¼á:±¹l4×ñ)ÃtmûæËÃîá„Æj'cç'Ùn§ä‡Æ#.Š1 ²²µ´ªz쎎Ε þ+;=ú[=ºyòäf34˨ d0ÐÔÏØHúÒ©Œ_‡Y±R}…Ÿ· ZS´¯Ú›rº‰V­9Q(^\K“NûJ„á2Ù_c+~Ÿ]<¦{L:`ÅІ£{˜ñèÂgmÂU´¦6¾|®»„}ÉåhW’Œ@ø2ªy|"ót§ÅÖóN?…ïe²¸ f&ExØoúsݯðÒÏìØ>PÑãþи›â¾ÉQ^UÕéÀ†ô'90YÑÞ¸ Ì³Ö‚þ¢DêÑÒùrš³ U5 [Ï8I—C<“к=º¬ ƒ[‘èLvzËô‡‡·6J‘&¡zªh¾ Í}£UV|øX(²â·r“šÌøP¬\­Þà ·9æëÒS$‹1¶˜Yf柋ÈÛm¥)]]5³E‹îÙ¡—°‹u)у/ÔÝÕú’Ôéòöšq­eyTJMãŽ}¾z…§%/˜êí<^ÞwJ É2•ç¤Á¯ÌŸ5a·@yla‚ÁÙÃ% âÄãì5üD†(³¾¾<™Ö:HÓÛˆÖÜi h9ôÖ”˜öK¤ì—˜°É¥µ&Ipʤ[Š\eøÿ൳•µˆ,µ(æ G‹Æ­|eÕ,­4.Ë„B®,ñ® #ó~õ§—ÿZr‡OWölÓ•eŠ„Òܶàë%,ÃÉ< ËÞ—Ýÿå¥S”/68Exþ-Üæ)k­ÓìÿBÇ’iëút—•4U!‚Vsíª5ü“nþá„­A­9ttÀAÿ¿½ ]ÂeƒY=ü“µ+y­a²Ô™, ’•ç`†TRé¥|Ôi®2`bã¢4ׯ/sÔíÍ·y¾=éÖÒ¥UŸNsôàÍãåv¿;½7É]#}Þ_ÌiÐv½fâôO§ïñâ †ùåõë;¶G|øƒû…a;‘_Xr”¿p”âe:xÃôàÔ1,œåHÀdI{£‚ËÚ«‘.õ£Ö7zÕ¥º|0©|´¹xÊ)Ø™jÂv-Ô'k£Aãü´y¸löûOr3MWh§Ó:ß®ÙzÅ]l9N +RâyXßmÀU˜?!.pdö µÀ¾.YÁ5Ák²L^éÒ;a +Ñ¿ýä›Ëšì:óšèk­^_heËâB‘lÉ…üOmOøéæi}ù_ö¼2È`sO›aá™t”ÿ— üÿuþCÝ7¬ -ô×ÍiwG1Þ[¼.pÕº€øXiêuoØÜê¢YN€L–´Wv¸ßìðA#·îÛvbKa(+ód§­çeÁLº1VÌðSÝ÷Ohyav@ÿbÚáø}ÀSÃÕ_hœ+óëL½]à&9Ef¬hoÌ@T-‘©iÈaF°.p.}d‡. ´£B•·êa[L^“Ÿft‚é§jºf(–†%J¤Hé 6ïØ\)Obüd²ê+8ý4ÔæøI?Ùn œl´uy±A[D[ƒZpœµÃòM˼®ð^«&#]9ùœ#'£Ô0±¼p»°ÉÕv­Ûd+†?a±hQúÊY|&›=üNdÄØ<>æhÿj,šm]d‡óصÄ_ª–¼p‰~ÍuÒQ˜e•.ìÏh]Í/6eœ¨z.ιOŸ½xÚ c¡S"ÄØfc[Œm> Œ…¥WŒ]Ëo±Ö3™k'~=ãÀN.àÌ °ts“Ô˜‘VH]Å È‚«*5˜(tDˆ¬îÂE<î>³ØheèT"E:‚öCnÒ@“ß~ Z¬Þl´¡Ì”ô/ör‚Û 'ÛÄ£w ìŒ ØGÀúuѵ0®£–·»Çux9Ù;ìºÕê0µò*Ù Ùî都3>h+×kÔ^“êc÷ü3y@#S ôpón}w†ñ(S8Ʋu-g?œ ÒÀ`c?ýD¶XR—ŸqõRíÚ‡Ií¹ÁªwJŠ*º¼ßêã•êɵ~2êÙ[˜7”_ìïB#‚utæ‹ye)Å?迳莊YÖŒªi¥ÙP‡M¸g\(‰!§r1u2£c"ŠÙæ4+ÎÎ¥2¨/eõ–É­ü·´×_ë}çM?Þôsý¦æØé›~Š‡œ›~87óM?Ìbõ«D¨òN2s2(Ö$~Ê؇ptäð0,Ù$L ܺÞ/quQß¾‡³ºS5’¢f+ô˜›š­ð„Æqõ’õã»ûü¿P +ƒ‹ßçŸ7··ÇüÃŽ„™>¼}‹¤Mô»Óz»ù´ß½{^Ÿ6ÐZã‡tÒ-òƒ;ˆƒIW„íiÊ u~!- +ˆEж±PLŠ¥wù¹üs‰™ìŒ™) ·£“Ç;ã&(@HðxûE £ýRP–5ªV½µ 錸=ª¶  8¦0]AÊ;ÿåß_3;ÁÒšTþFgÙü«`¦e³?ÿ;þbŠ­ Ròå¢6ªÎ3ç nb“ö¸õý¿à$NàG…(·‡=6P¬”`ß:Ï‚|>ö·›ãËÓåö|Ìó¶Ã½h0/Ì?¿ßœä™[8Ì~œ‰uHöcÁLíÔ,ÆXVôÏ‘,ýg/+FÿúŸ›?îŒów—ÓùpÏÐ#›Ò_oD¯/·¿¢a¿®úñÜÚ[±Ó†W£Èç™*µÆª„úxߣ3 dÇ 86mŠúZZg 3bKý6ŠÌÉðkIý*ÜJ ”9yV?Ù“/ÛÍ9yG ;Òô:’»´ùüˆN¢ôª–¨u²hía;úÞƒÜhoÔ÷þùï_ÊŽù ª'FF¿Ì·}È<“oT;Þž?nŽyÑ9@aXý3ÄôÅŽÞÒ6´cÞćבkRœ¼âãUD?MB‰2Ãêê’ã\uˆ‹ÛÜ沃h£[à$KӼߓԎk­„ÖÌÉ.ÕÏ‹<æ$Î*åú:ðœ@G9þ44 f¹³ª7@`%ò‚%œrçl!¡Y®%Ùæ“cïƒQù`V†>£¨ x{ÎÆ £œÒørt–ÍyèTI¤ –â\YuKÛ“>àBà_Â…­Ú…êMüçíìg ™ÌÿÓ~¨Gà‰õ^ü6{È?µ½üïåÿ'"ÿ/M=óÒÓ ¨P~Ñ?^2Ñùõ¦ Pð§ÿR•ðå©ëô2í]  +_ž ZëVÚ[:{VÀÿ[5‡ûèå/ê=Ô? ¨ïžr­})×øܤ\kNb|œ—-P_0O• +…ŒßÔ<´e}éƒ.ì>Q×Æj"hLaá?«dÏžž Ä|oϤÀ +p2…Uw¥Oº­¯ÒšÖøÆ ½ÅëËTP…T#hNKW3=ç„'KPEX Ô« +ž$ꦴºxbãûæúõðAƨî®VŠ (­>„Cþi÷螶ç¯?lv{Ó\ îÚ§ÝsÍØmãªÎÂUÛx«¶5ñMøÒ5ꈯ“§š´Ñ7ÜÍ]8j3PÊk³7î: sÈ[b®J>–˜(3M|$9šä³Fz®½ à §0>ÜKV§_°—áé\tMïíÖNn_!9ÌvXoe÷Øî±}Ä»WÍè¸{Å6ws÷ŠŸÀèÀ.Y÷¸~B»˜žÉ»c.FõÇüð¸Ïç6BXš; µ„ƒìk"äÆ5°¬`dB#îΟ*€Ç°CV}l‚¶-G=}½ÝÎp#óTÕ)专)(§N@™6xù¹6Ou£ïU¼><bËÃF×êmRB ‚Ëb™Â è´úëKú=]×Na»iõzOÒZc!¸·N¤BpçÚ»ÜSße«Ó'Àï¶ù|/ + Óù¨ñhî¤Î8œ§^/n\+Ëy*M$Ý®ÌHÛ|gñSþpùëþp÷;­ÓØî.¦gj9¯®`ÎJj óNvaLONf÷vs+$nsíÝ qc +SBâÖêô‰ÄõFù3 b€ª„îÖ3N¼šª‚íÁ;ÚU˜¼ xÅ +« +ú¯Ÿë®áaF^fxSø 9‘¼ñ&Ïy}0JŸIýᣣHêß~ÈMRh2ãã´j±úë;¼m÷ë"ïX ©q›ùgü¿_æ´é¼h:ÿŒÓ^žÎ›ûÇ/4‘c¾E38¤™'I/]5£ ƒÎ¾\€yþô:½Š¾Çí‚/¨Å7´ï:ä‡ËÃyF‡Ü7RÃð‘.QƒØ8ôo.§$íþÒkaŸ¨ÞïE¹Mëש^Aj‘k;Qô÷DöË6ßç•Ù©´àyoÿu +öóƒV"Mö§Ç=˦‰´§_“N¤yBè4À¬•XÔŠªLpjMüA›ÿÏ L)“LW³hfŸffÂfœ3\bž³½–9¦ùU.ÒL xaùEªIëΠš*Ýha˜Æ!˜â‘ë/*MAÝu 9 M-Å_AèÈŸ€tJ¥ƒTW:ØÞí`¡€ÿIDõL+¥‚4žáÕf†ë¨”"¥ìGÜ)¨Ž¡ g»…"†Æ^ã>×»ÓMñÚ>tšhßýãë‘|Õ+<âf‡ 流îŸ5+5Ô›¾ÈÑ8U>ŒbÃäÀJ,¤5¹‘f6¾çæ€iX’”ýí%h f(EÍÐ5_¾?Ÿ'X%§ t +Sñ¯äêË×—ó{,eÞˆUfÊ"SK´1þMYªóŸ'(Ÿ=–ƒ%Ú¯ä ø‘hQL–k\ `É©î”Môî1óybf±eTÐÙÌŸ,EP®±N{¿äªÚüxš&VxZÞ€’¡ª´ð—–ÒŽÇÑ‘k†MùÓ9x¦p‘01x +à“Z´¶RÁðã#ªp]zVô…hÊþ&ÁѪ™]$`ª˜ d5µÂÎ8Õ7¥—ÃuSá;F›ûŠNO@7¼à íp@ Ì80Díl`°5äø¬BŸÐ'—&GÀ¿ ƒ¯î¸Ä¿ta!;ÊÐ06BCP„4)Šá5uWõ1tpÖA™Œ!Ôx»“†Ý{N»Ç}¾VÄ–ðá᤭þ­¦¹‹Ø¿,Ö¼uSÅ[˜{˜âfxGåaú©X1ÍñMíÕkÿÑãÝÇñaÿfåqºÆ°?7cªS£y3†´w{3¦˜Âøq~’Õé'Ä,#ŠQ ¾Í¸< LÅFÚt¾ä/õ5}ü;x¡ûŸ>•±zÞàM†iš€Ud,*YÔåaO}äÕ‚ûº ¤ èƒÜ©}Ï„ˆ»ø—‡ãV©æó {tÅ¥$^ŽØMždÁ¡q¤è¾C²Âõ‘Oùyöép9Îê9È@Oê(òQKÃÊ–¾¢ÁÁ11Í›&WµÊ^¶´QÚaÇWÛÁµèGq/³@À8ÚúU„£|C8†pìRµ4lú +MA²S6 ,ú}CÜuZâ"¾KPå*ÂRcc²ãé3¹91|ö‹cëÏþäPšÝÑr”.[:Ai~E'‚ÒìZô…ÒHvA4úiþù²Û~ißiSßË£¿Àÿ¯Ýaܼ“ôvá|ã:“¶³ ("RYÛ™ÆÏŠò¨—ó€²²›ÄGrYïÏÃúnƒ€èI弸2ù8 aãAÄÌÙ½¤˜^K]i«£8É.‡q: XœÆg’ÜoÛmËëið}¶ê2[ù1¶ÇÖ7Øê÷ìãæZçÞÊTÜfS‡÷ŸAzÍð«»$º03]Æã6a €ì)Ù%FdG4c%ãéÒ[ƒÚR‹dÛ°Z’ÌfEc‰A0Û»üüKÉd°ùÇÍÜ_^óÔö -äqÇM¸³`°(T0˜˜šBkjb8 °åpQ\|*Æ÷¨ïýù%)¶K®Ë5Íkt MWêâUgHáÚÈq¨§Ú˜ÞV†˜¡éÐQp~,‡õ¼àyal^È2¸¾ñÐÊOæFùQ]LTæ-l)/ÿYê8¬²d¥Þ´”%“”2¢”%í¸e)^ É4l’iÍB­:ÊÙð¡ÞvÛâT.è·:ÍÙfªËÆNŠFÇpØeÔ ‘ªÆ¶ôðdÔ¹±ÆýÕf!8ç9n3b‚Å>3Ÿ¯˜e=)çè)Úµ½=)úP[2—ÇßևãííÍ~žú°»Ë_6|A4i™] ¯ÓŸ|¶ôkŒ ]® cBåG ]¶ÃB«Gl"Ce¿þ¨bœ×ý°Ë?¾¼Å‰¾×Åy!á¢ë@À§óñrw¾ó9yˆþïMñ¨šJ4wÁ+I^Ÿ“ÆD´H&ùœ²êáÅ=U“ûú>eu2„Û*•“? ùÍåt>ÜÏÈÄfûÝíqsüTpîw½Û–ê:÷ùÊÓ´Ý÷›OE³/þ¬*”…5é£ltzøXÀ/úd¿Ú’éõ¾n™•1ë¡©» ½eù†›ã» ž9BþÇú(âSƯÀãþ‚u¥])`<QÂØ—¶¬gµl|ïŸIƒâÅSoʨcݵ§ôtÌÑ}8!Ð9¡~"´HxoBté3Ö¿ŠcÃÐ  jSc2Ô>ÿ¦ìÀ¦`·öÇgM“õì‡BµÈS›&aBì@Ã÷/HÔ +7âèÌ—‘ÄÉåTx®«AÊÉ-Œõ» +fÅ\f‡X×É?zªëBu Ou×DuÝ¢[#ŒäZÚF 6†F”`k-œG BB»>?iÑÑœ4Õ&%¶¹ƒ›×q¦ªwÈ;:AaçV9žž¼Š_ÆÑÀ°ª '2OM^ {FÔdW7ŒÇuÝ°º½³ºaì¦tO½µ:ÎãAš”êPÖürBm矺¬Òð?ñÃÍhÀhÑ=$kÏD-Ò‡f?:¯á]…‰mF¦Õ•ÝBÏn…‹ ­ù²ÅÚÿíYϳž[Ö+®×ˆã*¹Í±`*Ã'PÖ8TÃa1dÔ!"/™7”Ç^ª^UŠÉ¯£S…q!&ᥔ<ú ¼§d|Dãa$zù6Ï·€y<^þq/ÍØÅ5qF&‚k'ã5*†F‹yîD ZN!ÒeîÓ?7M—¦Ä¹š†VˆWÊ%?~_ôgÏžÇÓþBCí¯€E2&$‡Ú¼Á,´^†€”g°÷`ò…ê)›Ë@ŠéŒŸgA½`½ø0(W\μ)ö.¤QÓÖœm=?oÞ©ÉCùp-HwD  $ÍÜ’LÃJÀNÆ€Ö´)cŸ~û±™ñÅÃÛÙXȨ¦Lóäê {Ä«82 ºÒ?^³WU$OÛdÊÑœÞP²½8œK!{¹TBp ·8=éšÚÛÅæ`~ùüÏÓL27ÅcóÏøï_æŸkÛ1αN£ÆMÐfç/¸žÁHQØôWpöTL¦/kE +q +©"ŠÍ-o‚ñŸt¤®Ä÷U}J€¢¨K]£V2ðÀÔÛò¸%â#B­Wúç©ßÿ<ýßRל SÏùýãéŒ:+éÃL3IL5“òƒLOqÍa8÷  nL•¿VY òWåÁ-vw5 º%¨û¶éîªRuÓ­$PTBTLTA"ÑR°wúŸ§¿TçîÏjÞTXƒÆ ¹çõ”ù„èhÒjwê¢îS±>–ñdÍå·M^}]Ué'ú+D¯ÒŸëŽÌ%¼Øg_0?KÔJÉæDWÛØiAª( +5±ðý ^„›¥T¸Yš 7Ô?ý·HI…¹ž>ñ|'i¦è®-ÇA÷ؼåLp‘$ +9FünvÂKÓeÔ^è°^rñ’‹—\Š#¼ŒÅVL ߨ²ŠÌ$•L4…B,.E Ûr-•(!‹t')!è2c‘K²r,¦RDç-åÔ”ˆ ÁœÁC&2c¡X¡xı¤ìÕ¹$%‡T,¹i¾dŸÄ·t4/@x ¥é#…#j¦dúX %Æx«GV,ªãzƒ‡3ƒG´”H.…`ìȲ¬·ùy³ÛŸLüÜMñœ+³קcÛM³×’`r Fõ–vòK&•_¾CcK‡ö2Œ—a¼ SAØ}3¦de$³`TySÁÊ36…+,ÆzƒHwƒHHÄŠ&µ`颗˜î([pN¤–h§qWÑ"\$°h‘ÉE Íwµ“0VR ã;íCD¼Œáe ^Æ‚‘Ä®‰y‰tàN B³€ÛXòÙ$³–MÊu²I*[Œ•í•L©¬$“W°Ñ€ >IœII©C)©—óáR‚BL/– ÄÖ€–P+©µê*@­Ñç8n:ˆQ7äÁ…©b€Dª%xÍ"‹´D*½÷¶¬’X_°úÏÆKW^ºòÒU%]­®KºŠàŠÍ +éêMD^Ⱥ!‹¤ØâhÄËZ=ÉZ²ðP¾Pâz‡0ù±‹ÄEìS⢸—¸øb«®Ä¥õÞZWè@âú<Fâ¢Ûš|áÓ™PóR˜—¼ö<¤0 +N^ +»N)Œ|=/…(…q2ÇRØ1ÇmÞÝÀªrR]÷ë<¨:†¯•Çr‘KçE휆‰TÒú¥ß›´¼0å…©*°¾™5¥Àj3ñ©ÆZÝÇ…rìkªÖØGW»»N.sâ5Ø|€øêjÄk;É47ôYWqÖ­~ÇZÇ -I[°‘¼­Ó.Ðl¾'Ã{ñÆ‹7^¼)c®â͈1×ÁÂìæy/oJ|yÎ×+}Ü”çÄ_w¾'Qùv€ìc~8b|+ÖÄèzWñìMù¬;Ñ£ÑïDDõÛö+zñ‹ëþº—=¼èñ¤Dr¼ßÔøâEp½èÑ›èÑæÛ~Ecþx@O"Ì÷[‘ÌQ4šÓFt¶7'„R…òaåB¬ÙÇd`–LÃ.Òº´%ÏôN§ò}56Wi“Òo§|;Û=”9ÜR…OïÁ›%‚‡HtÁ+NPùœÍ =ý¦xÜM&xéüƯ䡿’ýUô(‘žV©TA=W³­äÇ*xϺ£{¢{¸l­*7¾®ã\#pýçz𰓇fÕô<°{`wR‰ieX‰Iãàµ=bÒ ,/´)ǤœÓ@0ž Q\gÙz)œMðû”ŸñÇ?¡ƒ÷ooÝZ~åsóÖsÚ†@ANJ4-SÚ%náÖdôa©^Uµ¿ªÐDm|E¿¯‹Ê¨tÍ1L” gÖˉy\wë]ªða[Y£éÛÊ.ûô`?MmBØ7=T€T/Ë[Ðê ̦:~©'Ó¥í¯àSE·›ÓîÎ#ºSƒCSN¸M9â|I5ðv!‚H˜uç¯x¥,€7ƒþ#€hX}Uóübªù6V‰MÀ„£·~Ýì7ùùú¬ÿòçûÿã¢Mòßá]ró²ë6ÿò盿ÐbÀ¿bË¿|uÊ7Ç»÷ëíæ¼Aƒõ¿f5%–uÜõGôçÓnÛüú+>—t*»‡3™%ùóÃá<{¸ì÷ä§_ùí»ê—Óî¿‹ÐÙÚìëg +Œ¥?.ê¿_N»wù¶Ñןéÿùj¿yxwwØæÒÉ}Ào»9®7§»ÝNgšûüá]IyA(˜äWüLªáz™EqÙ·;épøŒ˜}’ÛÝ»Æ Ç|÷°Íÿ˜Þ—ÿ34Ž»ûÍñÓì÷ü¸a›{„ýRu/÷ŸNÿg¿ÆŸfs‡íŸ2—Ëùíòþ–2³ƒEWMãX}<åçªØ4¦Û%YèmZ¬'v¬Owˆ´¥ã½Ý6¢òý×?¾V~ÍD¶&³MôÙù9ÈkÁRÖ·»gàfÞá=Îx¦hï—‡ó´öHå~óø/Ré±½Ò0Iô> ѯ'0üÇŠè‰êÝA¾ŠD«Fÿ®ùfÅ=ãÄÿ¹äH1<élIõ^8åÇK•]èÕNFªWØb±vèEÎìê`„® +cj*Ç‚ Qõj}tWjˆÆ­õu.}Ë= +ùF%wYŠ6ËƱÁJk¿Y©’§v÷ùé¼¹Aî¬VŸîé n÷‡[C5ÈRò‘ã£tŪßø7„÷÷¥’´úBHDØgthuŽ âôŽøÁb¶Õ+÷r2¢°ùmTàãøð¨Kiî{nÛ_vˆ8p;@k(¿ÙCë5^ºõZD­p 5’FßìnX¯Ï›ã;ôÁ/Åí7ç©"ÑòZ¼^{õ«³ +LµÅ+}}‘Jè¬;&¾K²Û ˆnonóM|G›ôf³V7oo³·wËxû6¸S ÏåÊ©„†SÉ7áÝ&Û.n6·Éò&΂ۛMz·½¹Û®V›0‰ï–ËD>cÙ‚ß,Äøfq½KãÜЀCxð¼×O‹Òa¥«ëc¾{÷^¾žâ«¯}ãyì~G wÜõóf‹¸EdÀ!“ŽXø"{Èj±p­µEçy÷ð©Î†/MæZc6l¾ÒZör¬©­µõ'ãÿÒ‚gŒ t*ÙóÜ?¸ãñ¿ê?FµíÏýp3ˆ4Í-lZä? L¡¸×âÎ$ó‡òÈ?ŸÒùìÉaíW_o·³ÂGϽùñ&\4_¨¨ëUðò¼ÁRêP¯Á~/Yƒ`¥Xêæ:èi!:Ë<øx_И†e¥£G ¿D¿&HÇ£¤¦„‹–Q@ò:ÕO½ªúç͇‡Ãý'}_çÃDwÆy¢óÂÉ«/r™ÈíÔ‚&5Ÿww¿·6¨¥ ËÈH¢¥©ü\d¿ñqtµøvÖ3ååÇûõƒ@îde³âÛ3ÿ&Ÿ¢úw¹4Ãq2m]Wuœ0ì}èÓÀæíyà'¯[Ê zü–êÁªð÷/ÓO>ôm‡¦åê@õmEö4`<Ò Mj[;¶ùéî¸#ò»xÃk…ð™˜<˜1qDóÆÞ8˜¹LgWâÂ{œt«ëY12ÌMÍŒtîdGâAÉܘt¶²&H Ŧá‡ÑnÈ¿ƒUPýóϲQÖçc†š°oÂØbÜŽiH€5ìºØ7k½ßåGŒ Ÿ&AZ•ªÇÍ1WÏM4 ™9ãÅK¨O¸ðm™zÛ=—ã~½Ùï6Zf£Çñµ†Óár¼ëYhîŽ&Òås ¼V‡G-…›,hÍfà.kâm±g¥€Û¦5fJÍ-e8~±í'ÐÑFÇp½>öz1·\RfÝX±«·ù>wnI2°di;GVäŽù‡Ž{{ýcŒ–)žî Å-ªÞ¿ >KRÛ„óàqM€­÷lãqkg®uA¡øÁdϲ¿š®k³…à,Å,R‚Nbæm¹•m¬{ë>äú+/û?™@¦ÉÏÁ¯l•Nãæ')ÿ{Jë#Vö +}š¯Ê+ìÇÍùpüª£Ç’.:ùßǹaëYʳ”g)ëµ`”&Y :¥Í^˦ilÍLZ¶b~%z ” aóf(7òÇݶhÚ“¥Óð¼ï=&R1¡§.ÏhŸ@AÃÞÕÂi©öì³wÕòÑE2W-ÙHZkñY 8W7 îi{š§»wØ^ú”Ðu/ÞOä +¥Úqì$VžšÜQ×.U •ÄšíkH*wû Õ¬.Ür\oŠÑ&=ßfñ$c“ð8Y~<æ[=½]„dtšoŒ9ôã’6›“Ë$²ä~³Û÷4TSùÂ÷¬ÿûðв׊²ŠüÕ@ÝálGˆÍ¡‰x§ýáÝNžCÀÑívý˜7Úû|jM *¹R}…Ûp!5`€’\: LÕo-µt> … †¾¦'™´ú_õ¿%¡JlçbXÂÎÏ +O_ñCj¢ò>¤'ÂBAãï º&¿V[‡2}‡rkž ÿb¢ø¹¥«àüƒY™SŠ½·+îƒißîkª¹°GF ¿âJu­Nͬ×¢Ú£Õ_8ýŠ[#vÚìþû*ˆ—Ár±ÌÒ”_:ðGf÷UkP¡tù— +N«y™Þ ljøì3iÕXì¯þçëÿù]ôý%ù퇟þßÕëÅËÓ_ó߶»4øÿÎÿû¿ÂþþëÏáü×/?,¶é÷/ÿúÏßþ-Úü¸ÿîÝWüújó°=æ»ÿgs>?Ý]^–›œÿdß]Ž‡Ç|þŸùþ´{ø}×þ‚Á°_PC[S0”æÕ»m)Á!$½´¼p’XýÔUÂýÿ’Œ§Bz \ No newline at end of file diff --git a/core/modules/system/tests/fixtures/update/drupal-8.entity-data-revision-metadata-fields-2248983.php b/core/modules/system/tests/fixtures/update/drupal-8.entity-data-revision-metadata-fields-2248983.php new file mode 100644 index 0000000..0c537f5 --- /dev/null +++ b/core/modules/system/tests/fixtures/update/drupal-8.entity-data-revision-metadata-fields-2248983.php @@ -0,0 +1,162 @@ +insert('entity_test_revlog') + ->fields([ + 'id', + 'revision_id', + 'type', + 'uuid', + 'langcode', + 'revision_created', + 'revision_user', + 'revision_log_message', + 'name', + ]) + ->values([ + 'id' => '1', + 'revision_id' => '2', + 'type' => 'entity_test_revlog', + 'uuid' => 'f0b962b1-391b-441b-a664-2468ad520d96', + 'langcode' => 'en', + 'revision_created' => '1476268518', + 'revision_user' => '1', + 'revision_log_message' => 'second revision', + 'name' => 'entity 1', + ]) + ->execute(); + +$connection->insert('entity_test_revlog_revision') + ->fields([ + 'id', + 'revision_id', + 'langcode', + 'revision_created', + 'revision_user', + 'revision_log_message', + 'name', + ]) + ->values([ + 'id' => '1', + 'revision_id' => '1', + 'langcode' => 'en', + 'revision_created' => '1476268517', + 'revision_user' => '1', + 'revision_log_message' => 'first revision', + 'name' => 'entity 1', + ]) + ->values([ + 'id' => '1', + 'revision_id' => '2', + 'langcode' => 'en', + 'revision_created' => '1476268518', + 'revision_user' => '1', + 'revision_log_message' => 'second revision', + 'name' => 'entity 1', + ]) + ->execute(); + +// Data for entity type "entity_test_mul_revlog" +$connection->insert('entity_test_mul_revlog') + ->fields([ + 'id', + 'revision_id', + 'type', + 'uuid', + 'langcode', + ]) + ->values([ + 'id' => '1', + 'revision_id' => '2', + 'type' => 'entity_test_mul_revlog', + 'uuid' => '6f04027a-1cbd-46e3-a67e-72636b493d4f', + 'langcode' => 'en', + ]) + ->execute(); + +$connection->insert('entity_test_mul_revlog_field_data') + ->fields([ + 'id', + 'revision_id', + 'type', + 'langcode', + 'revision_created', + 'revision_user', + 'revision_log_message', + 'name', + 'default_langcode', + ]) + ->values([ + 'id' => '1', + 'revision_id' => '2', + 'type' => 'entity_test_mul_revlog', + 'langcode' => 'en', + 'revision_created' => '1476268518', + 'revision_user' => '1', + 'revision_log_message' => 'second revision', + 'name' => 'entity 1', + 'default_langcode' => '1', + ]) + ->execute(); + +$connection->insert('entity_test_mul_revlog_field_revision') + ->fields([ + 'id', + 'revision_id', + 'langcode', + 'revision_created', + 'revision_user', + 'revision_log_message', + 'name', + 'default_langcode', + ]) + ->values([ + 'id' => '1', + 'revision_id' => '1', + 'langcode' => 'en', + 'revision_created' => '1476268517', + 'revision_user' => '1', + 'revision_log_message' => 'first revision', + 'name' => 'entity 1', + 'default_langcode' => '1', + ]) + ->values([ + 'id' => '1', + 'revision_id' => '2', + 'langcode' => 'en', + 'revision_created' => '1476268518', + 'revision_user' => '1', + 'revision_log_message' => 'second revision', + 'name' => 'entity 1', + 'default_langcode' => '1', + ]) + ->execute(); + +$connection->insert('entity_test_mul_revlog_revision') + ->fields([ + 'id', + 'revision_id', + 'langcode', + ]) + ->values([ + 'id' => '1', + 'revision_id' => '1', + 'langcode' => 'en', + ]) + ->values([ + 'id' => '1', + 'revision_id' => '2', + 'langcode' => 'en', + ]) + ->execute(); diff --git a/core/modules/system/tests/fixtures/update/drupal-8.views-revision-metadata-fields-2248983.php b/core/modules/system/tests/fixtures/update/drupal-8.views-revision-metadata-fields-2248983.php new file mode 100644 index 0000000..4cc5227 --- /dev/null +++ b/core/modules/system/tests/fixtures/update/drupal-8.views-revision-metadata-fields-2248983.php @@ -0,0 +1,35 @@ +insert('config') + ->fields([ + 'collection', + 'name', + 'data', + ]) + ->values([ + 'collection' => '', + 'name' => 'views.view.' . $views_config['id'], + 'data' => serialize($views_config), + ]) + ->execute(); +} diff --git a/core/modules/system/tests/fixtures/update/views.view.entity_test_mul_revlog_for_2248983.yml b/core/modules/system/tests/fixtures/update/views.view.entity_test_mul_revlog_for_2248983.yml new file mode 100644 index 0000000..d9eba9a --- /dev/null +++ b/core/modules/system/tests/fixtures/update/views.view.entity_test_mul_revlog_for_2248983.yml @@ -0,0 +1,435 @@ +uuid: 25b89168-a8e5-4ae1-8fb5-c8efb91f0938 +langcode: en +status: true +dependencies: + module: + - entity_test_revlog +id: entity_test_mul_revlog_for_2248983 +label: entity_test_mul_revlog +module: views +description: '' +tag: '' +base_table: entity_test_mul_revlog_property_data +base_field: id +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: none + options: { } + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: mini + options: + items_per_page: 10 + offset: 0 + id: 0 + total_pages: null + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + tags: + previous: ‹‹ + next: ›› + style: + type: table + options: + grouping: { } + row_class: '' + default_row_class: true + override: true + sticky: false + caption: '' + summary: '' + description: '' + columns: + name: name + info: + name: + sortable: false + default_sort_order: asc + align: '' + separator: '' + empty_column: false + responsive: '' + default: '-1' + empty_table: false + row: + type: fields + options: + inline: { } + separator: '' + hide_empty: false + default_field_elements: true + fields: + name: + table: entity_test_mul_revlog_property_data + field: name + id: name + entity_type: entity_test_mul_revlog + entity_field: name + plugin_id: field + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: string + settings: { } + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + revision_created: + id: revision_created + table: entity_test_mul_revlog_property_data + field: revision_created + relationship: none + group_type: group + admin_label: '' + label: 'Revision create time' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: timestamp + settings: + date_format: medium + custom_date_format: '' + timezone: '' + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: entity_test_mul_revlog + entity_field: revision_created + plugin_id: field + revision_id: + id: revision_id + table: entity_test_mul_revlog_property_data + field: revision_id + relationship: none + group_type: group + admin_label: '' + label: 'Revision ID' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: number_integer + settings: + thousand_separator: '' + prefix_suffix: true + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: entity_test_mul_revlog + entity_field: revision_id + plugin_id: field + revision_log_message: + id: revision_log_message + table: entity_test_mul_revlog_property_data + field: revision_log_message + relationship: none + group_type: group + admin_label: '' + label: 'Revision log message' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: basic_string + settings: { } + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: entity_test_mul_revlog + entity_field: revision_log_message + plugin_id: field + revision_user: + id: revision_user + table: entity_test_mul_revlog_property_data + field: revision_user + relationship: none + group_type: group + admin_label: '' + label: 'Revision user' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: target_id + type: entity_reference_label + settings: + link: true + group_column: target_id + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: entity_test_mul_revlog + entity_field: revision_user + plugin_id: field + filters: { } + sorts: { } + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: 0 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + tags: { } diff --git a/core/modules/system/tests/fixtures/update/views.view.entity_test_revlog_for_2248983.yml b/core/modules/system/tests/fixtures/update/views.view.entity_test_revlog_for_2248983.yml new file mode 100644 index 0000000..412972c --- /dev/null +++ b/core/modules/system/tests/fixtures/update/views.view.entity_test_revlog_for_2248983.yml @@ -0,0 +1,436 @@ +uuid: 5a8b00d2-67ce-415b-9e7d-6c013bf7f6b8 +langcode: en +status: true +dependencies: + module: + - entity_test_revlog +id: entity_test_revlog_for_2248983 +label: entity_test_revlog +module: views +description: '' +tag: '' +base_table: entity_test_revlog +base_field: id +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: none + options: { } + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: mini + options: + items_per_page: 10 + offset: 0 + id: 0 + total_pages: null + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + tags: + previous: ‹‹ + next: ›› + style: + type: table + options: + grouping: { } + row_class: '' + default_row_class: true + override: true + sticky: false + caption: '' + summary: '' + description: '' + columns: + name: name + info: + name: + sortable: false + default_sort_order: asc + align: '' + separator: '' + empty_column: false + responsive: '' + default: '-1' + empty_table: false + row: + type: fields + options: + inline: { } + separator: '' + hide_empty: false + default_field_elements: true + fields: + name: + id: name + table: entity_test_revlog + field: name + relationship: none + group_type: group + admin_label: '' + label: Name + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: string + settings: + link_to_entity: false + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: entity_test_revlog + entity_field: name + plugin_id: field + revision_created: + id: revision_created + table: entity_test_revlog + field: revision_created + relationship: none + group_type: group + admin_label: '' + label: 'Revision create time' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: timestamp + settings: + date_format: medium + custom_date_format: '' + timezone: '' + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: entity_test_revlog + entity_field: revision_created + plugin_id: field + revision_id: + id: revision_id + table: entity_test_revlog + field: revision_id + relationship: none + group_type: group + admin_label: '' + label: 'Revision ID' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: number_integer + settings: + thousand_separator: '' + prefix_suffix: true + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: entity_test_revlog + entity_field: revision_id + plugin_id: field + revision_log_message: + id: revision_log_message + table: entity_test_revlog + field: revision_log_message + relationship: none + group_type: group + admin_label: '' + label: 'Revision log message' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: basic_string + settings: { } + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: entity_test_revlog + entity_field: revision_log_message + plugin_id: field + revision_user: + id: revision_user + table: entity_test_revlog + field: revision_user + relationship: none + group_type: group + admin_label: '' + label: 'Revision user' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: target_id + type: entity_reference_label + settings: + link: true + group_column: target_id + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: entity_test_revlog + entity_field: revision_user + plugin_id: field + filters: { } + sorts: { } + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: 0 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + tags: { } diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestNoBundle.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestNoBundle.php new file mode 100644 index 0000000..5907021 --- /dev/null +++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestNoBundle.php @@ -0,0 +1,24 @@ +setLabel(t('Name')) + ->setDescription(t('The name of the test entity.')) + ->setTranslatable(TRUE) + ->setRevisionable(TRUE) + ->setSetting('max_length', 32) + ->setDisplayOptions('view', [ + 'label' => 'hidden', + 'type' => 'string', + 'weight' => -5, + ]) + ->setDisplayOptions('form', [ + 'type' => 'string_textfield', + 'weight' => -5, + ]); + + return $fields; + } + +} diff --git a/core/modules/system/tests/src/Functional/FileTransfer/TestFileTransfer.php b/core/modules/system/tests/src/Functional/FileTransfer/TestFileTransfer.php index ca11ad0..2f233be 100644 --- a/core/modules/system/tests/src/Functional/FileTransfer/TestFileTransfer.php +++ b/core/modules/system/tests/src/Functional/FileTransfer/TestFileTransfer.php @@ -46,7 +46,7 @@ public function createDirectoryJailed($directory) { public function removeFileJailed($destination) { if (!ftp_delete($this->connection, $item)) { - throw new FileTransferException('Unable to remove to file @file.', NULL, ['@file' => $item]); + throw new FileTransferException('Unable to remove the file @file.', NULL, ['@file' => $item]); } } diff --git a/core/modules/taxonomy/migration_templates/d7_taxonomy_vocabulary.yml b/core/modules/taxonomy/migration_templates/d7_taxonomy_vocabulary.yml index 80c38a7..1a5befd 100644 --- a/core/modules/taxonomy/migration_templates/d7_taxonomy_vocabulary.yml +++ b/core/modules/taxonomy/migration_templates/d7_taxonomy_vocabulary.yml @@ -5,7 +5,16 @@ migration_tags: source: plugin: d7_taxonomy_vocabulary process: - vid: machine_name + vid: + - + plugin: machine_name + source: name + - + plugin: dedupe_entity + entity_type: taxonomy_vocabulary + field: vid + length: 32 + migrated: true label: name name: name description: description diff --git a/core/modules/taxonomy/src/Entity/Term.php b/core/modules/taxonomy/src/Entity/Term.php index 43e62c6..535fa8c 100644 --- a/core/modules/taxonomy/src/Entity/Term.php +++ b/core/modules/taxonomy/src/Entity/Term.php @@ -229,7 +229,8 @@ public function setWeight($weight) { * {@inheritdoc} */ public function getVocabularyId() { - return $this->get('vid')->target_id; + @trigger_error('The ' . __METHOD__ . ' method is deprecated since version 8.4.0 and will be removed before 9.0.0. Use ' . __CLASS__ . '::bundle() instead to get the vocabulary ID.', E_USER_DEPRECATED); + return $this->bundle(); } /** diff --git a/core/modules/taxonomy/src/Plugin/migrate/D7TaxonomyTermDeriver.php b/core/modules/taxonomy/src/Plugin/migrate/D7TaxonomyTermDeriver.php index b66a5cf..561a9af 100644 --- a/core/modules/taxonomy/src/Plugin/migrate/D7TaxonomyTermDeriver.php +++ b/core/modules/taxonomy/src/Plugin/migrate/D7TaxonomyTermDeriver.php @@ -9,12 +9,14 @@ use Drupal\migrate\Exception\RequirementsException; use Drupal\migrate\Plugin\MigrationDeriverTrait; use Drupal\migrate_drupal\Plugin\MigrateCckFieldPluginManagerInterface; +use Drupal\migrate_drupal\Plugin\MigrateFieldPluginManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** * Deriver for Drupal 7 taxonomy term migrations based on vocabularies. */ class D7TaxonomyTermDeriver extends DeriverBase implements ContainerDeriverInterface { + use MigrationDeriverTrait; /** @@ -39,16 +41,33 @@ class D7TaxonomyTermDeriver extends DeriverBase implements ContainerDeriverInter protected $cckPluginManager; /** + * Already-instantiated field plugins, keyed by ID. + * + * @var \Drupal\migrate_drupal\Plugin\MigrateFieldInterface[] + */ + protected $fieldPluginCache; + + /** + * The field plugin manager. + * + * @var \Drupal\migrate_drupal\Plugin\MigrateFieldPluginManagerInterface + */ + protected $fieldPluginManager; + + /** * D7TaxonomyTermDeriver constructor. * * @param string $base_plugin_id * The base plugin ID for the plugin ID. * @param \Drupal\migrate_drupal\Plugin\MigrateCckFieldPluginManagerInterface $cck_manager * The CCK plugin manager. + * @param \Drupal\migrate_drupal\Plugin\MigrateFieldPluginManagerInterface $field_manager + * The field plugin manager. */ - public function __construct($base_plugin_id, MigrateCckFieldPluginManagerInterface $cck_manager) { + public function __construct($base_plugin_id, MigrateCckFieldPluginManagerInterface $cck_manager, MigrateFieldPluginManagerInterface $field_manager) { $this->basePluginId = $base_plugin_id; $this->cckPluginManager = $cck_manager; + $this->fieldPluginManager = $field_manager; } /** @@ -57,7 +76,8 @@ public function __construct($base_plugin_id, MigrateCckFieldPluginManagerInterfa public static function create(ContainerInterface $container, $base_plugin_id) { return new static( $base_plugin_id, - $container->get('plugin.manager.migrate.cckfield') + $container->get('plugin.manager.migrate.cckfield'), + $container->get('plugin.manager.migrate.field') ); } @@ -112,15 +132,25 @@ public function getDerivativeDefinitions($base_plugin_definition) { foreach ($fields[$bundle] as $field_name => $info) { $field_type = $info['type']; try { - $plugin_id = $this->cckPluginManager->getPluginIdFromFieldType($field_type, ['core' => 7], $migration); - if (!isset($this->cckPluginCache[$field_type])) { - $this->cckPluginCache[$field_type] = $this->cckPluginManager->createInstance($plugin_id, ['core' => 7], $migration); + $plugin_id = $this->fieldPluginManager->getPluginIdFromFieldType($field_type, ['core' => 7], $migration); + if (!isset($this->fieldPluginCache[$field_type])) { + $this->fieldPluginCache[$field_type] = $this->fieldPluginManager->createInstance($plugin_id, ['core' => 7], $migration); } - $this->cckPluginCache[$field_type] - ->processCckFieldValues($migration, $field_name, $info); + $this->fieldPluginCache[$field_type] + ->processFieldValues($migration, $field_name, $info); } catch (PluginNotFoundException $ex) { - $migration->setProcessOfProperty($field_name, $field_name); + try { + $plugin_id = $this->cckPluginManager->getPluginIdFromFieldType($field_type, ['core' => 7], $migration); + if (!isset($this->cckPluginCache[$field_type])) { + $this->cckPluginCache[$field_type] = $this->cckPluginManager->createInstance($plugin_id, ['core' => 7], $migration); + } + $this->cckPluginCache[$field_type] + ->processCckFieldValues($migration, $field_name, $info); + } + catch (PluginNotFoundException $ex) { + $migration->setProcessOfProperty($field_name, $field_name); + } } } } diff --git a/core/modules/taxonomy/src/Plugin/views/argument/IndexTidDepth.php b/core/modules/taxonomy/src/Plugin/views/argument/IndexTidDepth.php index 6933e74..37abdd5 100644 --- a/core/modules/taxonomy/src/Plugin/views/argument/IndexTidDepth.php +++ b/core/modules/taxonomy/src/Plugin/views/argument/IndexTidDepth.php @@ -2,6 +2,7 @@ namespace Drupal\taxonomy\Plugin\views\argument; +use Drupal\Core\Database\Query\Condition; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; @@ -106,7 +107,7 @@ public function query($group_by = FALSE) { // Now build the subqueries. $subquery = db_select('taxonomy_index', 'tn'); $subquery->addField('tn', 'nid'); - $where = db_or()->condition('tn.tid', $tids, $operator); + $where = (new Condition('OR'))->condition('tn.tid', $tids, $operator); $last = "tn"; if ($this->options['depth'] > 0) { diff --git a/core/modules/taxonomy/src/Plugin/views/argument_default/Tid.php b/core/modules/taxonomy/src/Plugin/views/argument_default/Tid.php index 95e5b1e..fa18654 100644 --- a/core/modules/taxonomy/src/Plugin/views/argument_default/Tid.php +++ b/core/modules/taxonomy/src/Plugin/views/argument_default/Tid.php @@ -194,7 +194,7 @@ public function getArgument() { $taxonomy_terms = $node->{$field->getName()}->referencedEntities(); /** @var \Drupal\taxonomy\TermInterface $taxonomy_term */ foreach ($taxonomy_terms as $taxonomy_term) { - $taxonomy[$taxonomy_term->id()] = $taxonomy_term->getVocabularyId(); + $taxonomy[$taxonomy_term->id()] = $taxonomy_term->bundle(); } } } diff --git a/core/modules/taxonomy/src/Plugin/views/field/TaxonomyIndexTid.php b/core/modules/taxonomy/src/Plugin/views/field/TaxonomyIndexTid.php index e0b9a32..367565c 100644 --- a/core/modules/taxonomy/src/Plugin/views/field/TaxonomyIndexTid.php +++ b/core/modules/taxonomy/src/Plugin/views/field/TaxonomyIndexTid.php @@ -145,8 +145,8 @@ public function preRender(&$values) { foreach ($data as $tid => $term) { $this->items[$node_nid][$tid]['name'] = \Drupal::entityManager()->getTranslationFromContext($term)->label(); $this->items[$node_nid][$tid]['tid'] = $tid; - $this->items[$node_nid][$tid]['vocabulary_vid'] = $term->getVocabularyId(); - $this->items[$node_nid][$tid]['vocabulary'] = $vocabularies[$term->getVocabularyId()]->label(); + $this->items[$node_nid][$tid]['vocabulary_vid'] = $term->bundle(); + $this->items[$node_nid][$tid]['vocabulary'] = $vocabularies[$term->bundle()]->label(); if (!empty($this->options['link_to_taxonomy'])) { $this->items[$node_nid][$tid]['make_link'] = TRUE; diff --git a/core/modules/taxonomy/src/Plugin/views/filter/TaxonomyIndexTidDepth.php b/core/modules/taxonomy/src/Plugin/views/filter/TaxonomyIndexTidDepth.php index 732f5b1..9311558 100644 --- a/core/modules/taxonomy/src/Plugin/views/filter/TaxonomyIndexTidDepth.php +++ b/core/modules/taxonomy/src/Plugin/views/filter/TaxonomyIndexTidDepth.php @@ -2,6 +2,7 @@ namespace Drupal\taxonomy\Plugin\views\filter; +use Drupal\Core\Database\Query\Condition; use Drupal\Core\Form\FormStateInterface; /** @@ -72,7 +73,7 @@ public function query() { // Now build the subqueries. $subquery = db_select('taxonomy_index', 'tn'); $subquery->addField('tn', 'nid'); - $where = db_or()->condition('tn.tid', $this->value, $operator); + $where = (new Condition('OR'))->condition('tn.tid', $this->value, $operator); $last = "tn"; if ($this->options['depth'] > 0) { diff --git a/core/modules/taxonomy/src/TermForm.php b/core/modules/taxonomy/src/TermForm.php index 6e307ca..1eae95a 100644 --- a/core/modules/taxonomy/src/TermForm.php +++ b/core/modules/taxonomy/src/TermForm.php @@ -85,7 +85,7 @@ public function form(array $form, FormStateInterface $form_state) { '#value' => $term->id(), ]; - return parent::form($form, $form_state, $term); + return parent::form($form, $form_state); } /** diff --git a/core/modules/taxonomy/src/TermInterface.php b/core/modules/taxonomy/src/TermInterface.php index f832620..9c0c47e 100644 --- a/core/modules/taxonomy/src/TermInterface.php +++ b/core/modules/taxonomy/src/TermInterface.php @@ -87,6 +87,9 @@ public function setWeight($weight); * * @return int * The id of the vocabulary. + * + * @deprecated Scheduled for removal before Drupal 9.0.0. Use + * TermInterface::bundle() instead. */ public function getVocabularyId(); diff --git a/core/modules/taxonomy/src/Tests/Views/TaxonomyFieldAllTermsTest.php b/core/modules/taxonomy/src/Tests/Views/TaxonomyFieldAllTermsTest.php index 4178041..a75f16a 100644 --- a/core/modules/taxonomy/src/Tests/Views/TaxonomyFieldAllTermsTest.php +++ b/core/modules/taxonomy/src/Tests/Views/TaxonomyFieldAllTermsTest.php @@ -57,7 +57,7 @@ public function testViewsHandlerAllTermsWithTokens() { $this->assertText('The taxonomy term name for the term: ' . $this->term1->getName()); // The machine name for the vocabulary the term belongs to: {{ term_node_tid__vocabulary_vid }} - $this->assertText('The machine name for the vocabulary the term belongs to: ' . $this->term1->getVocabularyId()); + $this->assertText('The machine name for the vocabulary the term belongs to: ' . $this->term1->bundle()); // The name for the vocabulary the term belongs to: {{ term_node_tid__vocabulary }} $vocabulary = Vocabulary::load($this->term1->bundle()); diff --git a/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTest.php b/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTest.php index fbcc129..214e784 100644 --- a/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTest.php +++ b/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTest.php @@ -76,7 +76,7 @@ protected function assertEntity($id, $expected_label, $expected_vid, $expected_d $entity = Term::load($id); $this->assertTrue($entity instanceof TermInterface); $this->assertIdentical($expected_label, $entity->label()); - $this->assertIdentical($expected_vid, $entity->getVocabularyId()); + $this->assertIdentical($expected_vid, $entity->bundle()); $this->assertEqual($expected_description, $entity->getDescription()); $this->assertEquals($expected_format, $entity->getFormat()); $this->assertEqual($expected_weight, $entity->getWeight()); diff --git a/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyVocabularyTest.php b/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyVocabularyTest.php index 7f2ceef..c7f560c 100644 --- a/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyVocabularyTest.php +++ b/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyVocabularyTest.php @@ -57,6 +57,7 @@ public function testTaxonomyVocabulary() { $this->assertEntity('tags', 'Tags', 'Use tags to group articles on similar topics into categories.', VocabularyInterface::HIERARCHY_DISABLED, 0); $this->assertEntity('forums', 'Forums', 'Forum navigation vocabulary', VocabularyInterface::HIERARCHY_SINGLE, -10); $this->assertEntity('test_vocabulary', 'Test Vocabulary', 'This is the vocabulary description', VocabularyInterface::HIERARCHY_SINGLE, 0); + $this->assertEntity('vocabulary_name_much_longer_than', 'vocabulary name much longer than thirty two characters', 'description of vocabulary name much longer than thirty two characters', VocabularyInterface::HIERARCHY_SINGLE, 0); } } diff --git a/core/modules/tour/tour.module b/core/modules/tour/tour.module index 1de8399..ce9f309 100644 --- a/core/modules/tour/tour.module +++ b/core/modules/tour/tour.module @@ -54,6 +54,7 @@ function tour_toolbar() { '#attributes' => [ 'class' => ['toolbar-icon', 'toolbar-icon-help'], 'aria-pressed' => 'false', + 'type' => 'button', ], ], '#wrapper_attributes' => [ diff --git a/core/modules/tracker/src/Tests/TrackerTest.php b/core/modules/tracker/src/Tests/TrackerTest.php deleted file mode 100644 index fa95299..0000000 --- a/core/modules/tracker/src/Tests/TrackerTest.php +++ /dev/null @@ -1,448 +0,0 @@ -drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']); - - $permissions = ['access comments', 'create page content', 'post comments', 'skip comment approval']; - $this->user = $this->drupalCreateUser($permissions); - $this->otherUser = $this->drupalCreateUser($permissions); - $this->addDefaultCommentField('node', 'page'); - user_role_grant_permissions(AccountInterface::ANONYMOUS_ROLE, [ - 'access content', - 'access user profiles', - ]); - $this->drupalPlaceBlock('local_tasks_block', ['id' => 'page_tabs_block']); - $this->drupalPlaceBlock('local_actions_block', ['id' => 'page_actions_block']); - } - - /** - * Tests for the presence of nodes on the global tracker listing. - */ - public function testTrackerAll() { - $this->drupalLogin($this->user); - - $unpublished = $this->drupalCreateNode([ - 'title' => $this->randomMachineName(8), - 'status' => 0, - ]); - $published = $this->drupalCreateNode([ - 'title' => $this->randomMachineName(8), - 'status' => 1, - ]); - - $this->drupalGet('activity'); - $this->assertNoText($unpublished->label(), 'Unpublished node does not show up in the tracker listing.'); - $this->assertText($published->label(), 'Published node shows up in the tracker listing.'); - $this->assertLink(t('My recent content'), 0, 'User tab shows up on the global tracker page.'); - - // Assert cache contexts, specifically the pager and node access contexts. - $this->assertCacheContexts(['languages:language_interface', 'route', 'theme', 'url.query_args:' . MainContentViewSubscriber::WRAPPER_FORMAT, 'url.query_args.pagers:0', 'user.node_grants:view', 'user']); - // Assert cache tags for the action/tabs blocks, visible node, and node list - // cache tag. - $expected_tags = Cache::mergeTags($published->getCacheTags(), $published->getOwner()->getCacheTags()); - // Because the 'user.permissions' cache context is being optimized away. - $role_tags = []; - foreach ($this->user->getRoles() as $rid) { - $role_tags[] = "config:user.role.$rid"; - } - $expected_tags = Cache::mergeTags($expected_tags, $role_tags); - $block_tags = [ - 'block_view', - 'config:block.block.page_actions_block', - 'config:block.block.page_tabs_block', - 'config:block_list', - ]; - $expected_tags = Cache::mergeTags($expected_tags, $block_tags); - $additional_tags = [ - 'node_list', - 'rendered', - ]; - $expected_tags = Cache::mergeTags($expected_tags, $additional_tags); - $this->assertCacheTags($expected_tags); - - // Delete a node and ensure it no longer appears on the tracker. - $published->delete(); - $this->drupalGet('activity'); - $this->assertNoText($published->label(), 'Deleted node does not show up in the tracker listing.'); - - // Test proper display of time on activity page when comments are disabled. - // Disable comments. - FieldStorageConfig::loadByName('node', 'comment')->delete(); - $node = $this->drupalCreateNode([ - // This title is required to trigger the custom changed time set in the - // node_test module. This is needed in order to ensure a sufficiently - // large 'time ago' interval that isn't numbered in seconds. - 'title' => 'testing_node_presave', - 'status' => 1, - ]); - - $this->drupalGet('activity'); - $this->assertText($node->label(), 'Published node shows up in the tracker listing.'); - $this->assertText(\Drupal::service('date.formatter')->formatTimeDiffSince($node->getChangedTime()), 'The changed time was displayed on the tracker listing.'); - } - - /** - * Tests for the presence of nodes on a user's tracker listing. - */ - public function testTrackerUser() { - $this->drupalLogin($this->user); - - $unpublished = $this->drupalCreateNode([ - 'title' => $this->randomMachineName(8), - 'uid' => $this->user->id(), - 'status' => 0, - ]); - $my_published = $this->drupalCreateNode([ - 'title' => $this->randomMachineName(8), - 'uid' => $this->user->id(), - 'status' => 1, - ]); - $other_published_no_comment = $this->drupalCreateNode([ - 'title' => $this->randomMachineName(8), - 'uid' => $this->otherUser->id(), - 'status' => 1, - ]); - $other_published_my_comment = $this->drupalCreateNode([ - 'title' => $this->randomMachineName(8), - 'uid' => $this->otherUser->id(), - 'status' => 1, - ]); - $comment = [ - 'subject[0][value]' => $this->randomMachineName(), - 'comment_body[0][value]' => $this->randomMachineName(20), - ]; - $this->drupalPostForm('comment/reply/node/' . $other_published_my_comment->id() . '/comment', $comment, t('Save')); - - $this->drupalGet('user/' . $this->user->id() . '/activity'); - $this->assertNoText($unpublished->label(), "Unpublished nodes do not show up in the user's tracker listing."); - $this->assertText($my_published->label(), "Published nodes show up in the user's tracker listing."); - $this->assertNoText($other_published_no_comment->label(), "Another user's nodes do not show up in the user's tracker listing."); - $this->assertText($other_published_my_comment->label(), "Nodes that the user has commented on appear in the user's tracker listing."); - - // Assert cache contexts. - $this->assertCacheContexts(['languages:language_interface', 'route', 'theme', 'url.query_args:' . MainContentViewSubscriber::WRAPPER_FORMAT, 'url.query_args.pagers:0', 'user', 'user.node_grants:view']); - // Assert cache tags for the visible nodes (including owners) and node list - // cache tag. - $expected_tags = Cache::mergeTags($my_published->getCacheTags(), $my_published->getOwner()->getCacheTags()); - $expected_tags = Cache::mergeTags($expected_tags, $other_published_my_comment->getCacheTags()); - $expected_tags = Cache::mergeTags($expected_tags, $other_published_my_comment->getOwner()->getCacheTags()); - // Because the 'user.permissions' cache context is being optimized away. - $role_tags = []; - foreach ($this->user->getRoles() as $rid) { - $role_tags[] = "config:user.role.$rid"; - } - $expected_tags = Cache::mergeTags($expected_tags, $role_tags); - $block_tags = [ - 'block_view', - 'config:block.block.page_actions_block', - 'config:block.block.page_tabs_block', - 'config:block_list', - ]; - $expected_tags = Cache::mergeTags($expected_tags, $block_tags); - $additional_tags = [ - 'node_list', - 'rendered', - ]; - $expected_tags = Cache::mergeTags($expected_tags, $additional_tags); - - $this->assertCacheTags($expected_tags); - $this->assertCacheContexts(['languages:language_interface', 'route', 'theme', 'url.query_args:' . MainContentViewSubscriber::WRAPPER_FORMAT, 'url.query_args.pagers:0', 'user', 'user.node_grants:view']); - - $this->assertLink($my_published->label()); - $this->assertNoLink($unpublished->label()); - // Verify that title and tab title have been set correctly. - $this->assertText('Activity', 'The user activity tab has the name "Activity".'); - $this->assertTitle(t('@name | @site', ['@name' => $this->user->getUsername(), '@site' => $this->config('system.site')->get('name')]), 'The user tracker page has the correct page title.'); - - // Verify that unpublished comments are removed from the tracker. - $admin_user = $this->drupalCreateUser(['post comments', 'administer comments', 'access user profiles']); - $this->drupalLogin($admin_user); - $this->drupalPostForm('comment/1/edit', ['status' => CommentInterface::NOT_PUBLISHED], t('Save')); - $this->drupalGet('user/' . $this->user->id() . '/activity'); - $this->assertNoText($other_published_my_comment->label(), 'Unpublished comments are not counted on the tracker listing.'); - - // Test escaping of title on user's tracker tab. - \Drupal::service('module_installer')->install(['user_hooks_test']); - Cache::invalidateTags(['rendered']); - \Drupal::state()->set('user_hooks_test_user_format_name_alter', TRUE); - $this->drupalGet('user/' . $this->user->id() . '/activity'); - $this->assertEscaped('' . $this->user->id() . ''); - - \Drupal::state()->set('user_hooks_test_user_format_name_alter_safe', TRUE); - Cache::invalidateTags(['rendered']); - $this->drupalGet('user/' . $this->user->id() . '/activity'); - $this->assertNoEscaped('' . $this->user->id() . ''); - $this->assertRaw('' . $this->user->id() . ''); - } - - /** - * Tests the metadata for the "new"/"updated" indicators. - */ - public function testTrackerHistoryMetadata() { - $this->drupalLogin($this->user); - - // Create a page node. - $edit = [ - 'title' => $this->randomMachineName(8), - ]; - $node = $this->drupalCreateNode($edit); - - // Verify that the history metadata is present. - $this->drupalGet('activity'); - $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->getChangedTime()); - $this->drupalGet('activity/' . $this->user->id()); - $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->getChangedTime()); - $this->drupalGet('user/' . $this->user->id() . '/activity'); - $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->getChangedTime()); - - // Add a comment to the page, make sure it is created after the node by - // sleeping for one second, to ensure the last comment timestamp is - // different from before. - $comment = [ - 'subject[0][value]' => $this->randomMachineName(), - 'comment_body[0][value]' => $this->randomMachineName(20), - ]; - sleep(1); - $this->drupalPostForm('comment/reply/node/' . $node->id() . '/comment', $comment, t('Save')); - // Reload the node so that comment.module's hook_node_load() - // implementation can set $node->last_comment_timestamp for the freshly - // posted comment. - $node = Node::load($node->id()); - - // Verify that the history metadata is updated. - $this->drupalGet('activity'); - $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp); - $this->drupalGet('activity/' . $this->user->id()); - $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp); - $this->drupalGet('user/' . $this->user->id() . '/activity'); - $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp); - - // Log out, now verify that the metadata is still there, but the library is - // not. - $this->drupalLogout(); - $this->drupalGet('activity'); - $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp, FALSE); - $this->drupalGet('user/' . $this->user->id() . '/activity'); - $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp, FALSE); - } - - /** - * Tests for ordering on a users tracker listing when comments are posted. - */ - public function testTrackerOrderingNewComments() { - $this->drupalLogin($this->user); - - $node_one = $this->drupalCreateNode([ - 'title' => $this->randomMachineName(8), - ]); - - $node_two = $this->drupalCreateNode([ - 'title' => $this->randomMachineName(8), - ]); - - // Now get otherUser to track these pieces of content. - $this->drupalLogin($this->otherUser); - - // Add a comment to the first page. - $comment = [ - 'subject[0][value]' => $this->randomMachineName(), - 'comment_body[0][value]' => $this->randomMachineName(20), - ]; - $this->drupalPostForm('comment/reply/node/' . $node_one->id() . '/comment', $comment, t('Save')); - - // If the comment is posted in the same second as the last one then Drupal - // can't tell the difference, so we wait one second here. - sleep(1); - - // Add a comment to the second page. - $comment = [ - 'subject[0][value]' => $this->randomMachineName(), - 'comment_body[0][value]' => $this->randomMachineName(20), - ]; - $this->drupalPostForm('comment/reply/node/' . $node_two->id() . '/comment', $comment, t('Save')); - - // We should at this point have in our tracker for otherUser: - // 1. node_two - // 2. node_one - // Because that's the reverse order of the posted comments. - - // Now we're going to post a comment to node_one which should jump it to the - // top of the list. - - $this->drupalLogin($this->user); - // If the comment is posted in the same second as the last one then Drupal - // can't tell the difference, so we wait one second here. - sleep(1); - - // Add a comment to the second page. - $comment = [ - 'subject[0][value]' => $this->randomMachineName(), - 'comment_body[0][value]' => $this->randomMachineName(20), - ]; - $this->drupalPostForm('comment/reply/node/' . $node_one->id() . '/comment', $comment, t('Save')); - - // Switch back to the otherUser and assert that the order has swapped. - $this->drupalLogin($this->otherUser); - $this->drupalGet('user/' . $this->otherUser->id() . '/activity'); - // This is a cheeky way of asserting that the nodes are in the right order - // on the tracker page. - // It's almost certainly too brittle. - $pattern = '/' . preg_quote($node_one->getTitle()) . '.+' . preg_quote($node_two->getTitle()) . '/s'; - $this->verbose($pattern); - $this->assertPattern($pattern, 'Most recently commented on node appears at the top of tracker'); - } - - /** - * Tests that existing nodes are indexed by cron. - */ - public function testTrackerCronIndexing() { - $this->drupalLogin($this->user); - - // Create 3 nodes. - $edits = []; - $nodes = []; - for ($i = 1; $i <= 3; $i++) { - $edits[$i] = [ - 'title' => $this->randomMachineName(), - ]; - $nodes[$i] = $this->drupalCreateNode($edits[$i]); - } - - // Add a comment to the last node as other user. - $this->drupalLogin($this->otherUser); - $comment = [ - 'subject[0][value]' => $this->randomMachineName(), - 'comment_body[0][value]' => $this->randomMachineName(20), - ]; - $this->drupalPostForm('comment/reply/node/' . $nodes[3]->id() . '/comment', $comment, t('Save')); - - // Start indexing backwards from node 3. - \Drupal::state()->set('tracker.index_nid', 3); - - // Clear the current tracker tables and rebuild them. - db_delete('tracker_node') - ->execute(); - db_delete('tracker_user') - ->execute(); - tracker_cron(); - - $this->drupalLogin($this->user); - - // Fetch the user's tracker. - $this->drupalGet('activity/' . $this->user->id()); - - // Assert that all node titles are displayed. - foreach ($nodes as $i => $node) { - $this->assertText($node->label(), format_string('Node @i is displayed on the tracker listing pages.', ['@i' => $i])); - } - - // Fetch the site-wide tracker. - $this->drupalGet('activity'); - - // Assert that all node titles are displayed. - foreach ($nodes as $i => $node) { - $this->assertText($node->label(), format_string('Node @i is displayed on the tracker listing pages.', ['@i' => $i])); - } - } - - /** - * Tests that publish/unpublish works at admin/content/node. - */ - public function testTrackerAdminUnpublish() { - \Drupal::service('module_installer')->install(['views']); - \Drupal::service('router.builder')->rebuild(); - $admin_user = $this->drupalCreateUser(['access content overview', 'administer nodes', 'bypass node access']); - $this->drupalLogin($admin_user); - - $node = $this->drupalCreateNode([ - 'title' => $this->randomMachineName(), - ]); - - // Assert that the node is displayed. - $this->drupalGet('activity'); - $this->assertText($node->label(), 'A node is displayed on the tracker listing pages.'); - - // Unpublish the node and ensure that it's no longer displayed. - $edit = [ - 'action' => 'node_unpublish_action', - 'node_bulk_form[0]' => $node->id(), - ]; - $this->drupalPostForm('admin/content', $edit, t('Apply to selected items')); - - $this->drupalGet('activity'); - $this->assertText(t('No content available.'), 'A node is displayed on the tracker listing pages.'); - } - - /** - * Passes if the appropriate history metadata exists. - * - * Verify the data-history-node-id, data-history-node-timestamp and - * data-history-node-last-comment-timestamp attributes, which are used by the - * drupal.tracker-history library to add the appropriate "new" and "updated" - * indicators, as well as the "x new" replies link to the tracker. - * We do this in JavaScript to prevent breaking the render cache. - * - * @param int $node_id - * A node ID, that must exist as a data-history-node-id attribute - * @param int $node_timestamp - * A node timestamp, that must exist as a data-history-node-timestamp - * attribute. - * @param int $node_last_comment_timestamp - * A node's last comment timestamp, that must exist as a - * data-history-node-last-comment-timestamp attribute. - * @param bool $library_is_present - * Whether the drupal.tracker-history library should be present or not. - */ - public function assertHistoryMetadata($node_id, $node_timestamp, $node_last_comment_timestamp, $library_is_present = TRUE) { - $settings = $this->getDrupalSettings(); - $this->assertIdentical($library_is_present, isset($settings['ajaxPageState']) && in_array('tracker/history', explode(',', $settings['ajaxPageState']['libraries'])), 'drupal.tracker-history library is present.'); - $this->assertIdentical(1, count($this->xpath('//table/tbody/tr/td[@data-history-node-id="' . $node_id . '" and @data-history-node-timestamp="' . $node_timestamp . '"]')), 'Tracker table cell contains the data-history-node-id and data-history-node-timestamp attributes for the node.'); - $this->assertIdentical(1, count($this->xpath('//table/tbody/tr/td[@data-history-node-last-comment-timestamp="' . $node_last_comment_timestamp . '"]')), 'Tracker table cell contains the data-history-node-last-comment-timestamp attribute for the node.'); - } - -} diff --git a/core/modules/tracker/tests/src/Functional/TrackerTest.php b/core/modules/tracker/tests/src/Functional/TrackerTest.php new file mode 100644 index 0000000..17f95ee --- /dev/null +++ b/core/modules/tracker/tests/src/Functional/TrackerTest.php @@ -0,0 +1,448 @@ +drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']); + + $permissions = ['access comments', 'create page content', 'post comments', 'skip comment approval']; + $this->user = $this->drupalCreateUser($permissions); + $this->otherUser = $this->drupalCreateUser($permissions); + $this->addDefaultCommentField('node', 'page'); + user_role_grant_permissions(AccountInterface::ANONYMOUS_ROLE, [ + 'access content', + 'access user profiles', + ]); + $this->drupalPlaceBlock('local_tasks_block', ['id' => 'page_tabs_block']); + $this->drupalPlaceBlock('local_actions_block', ['id' => 'page_actions_block']); + } + + /** + * Tests for the presence of nodes on the global tracker listing. + */ + public function testTrackerAll() { + $this->drupalLogin($this->user); + + $unpublished = $this->drupalCreateNode([ + 'title' => $this->randomMachineName(8), + 'status' => 0, + ]); + $published = $this->drupalCreateNode([ + 'title' => $this->randomMachineName(8), + 'status' => 1, + ]); + + $this->drupalGet('activity'); + $this->assertNoText($unpublished->label(), 'Unpublished node does not show up in the tracker listing.'); + $this->assertText($published->label(), 'Published node shows up in the tracker listing.'); + $this->assertLink(t('My recent content'), 0, 'User tab shows up on the global tracker page.'); + + // Assert cache contexts, specifically the pager and node access contexts. + $this->assertCacheContexts(['languages:language_interface', 'route', 'theme', 'url.query_args:' . MainContentViewSubscriber::WRAPPER_FORMAT, 'url.query_args.pagers:0', 'user.node_grants:view', 'user']); + // Assert cache tags for the action/tabs blocks, visible node, and node list + // cache tag. + $expected_tags = Cache::mergeTags($published->getCacheTags(), $published->getOwner()->getCacheTags()); + // Because the 'user.permissions' cache context is being optimized away. + $role_tags = []; + foreach ($this->user->getRoles() as $rid) { + $role_tags[] = "config:user.role.$rid"; + } + $expected_tags = Cache::mergeTags($expected_tags, $role_tags); + $block_tags = [ + 'block_view', + 'config:block.block.page_actions_block', + 'config:block.block.page_tabs_block', + 'config:block_list', + ]; + $expected_tags = Cache::mergeTags($expected_tags, $block_tags); + $additional_tags = [ + 'node_list', + 'rendered', + ]; + $expected_tags = Cache::mergeTags($expected_tags, $additional_tags); + $this->assertCacheTags($expected_tags); + + // Delete a node and ensure it no longer appears on the tracker. + $published->delete(); + $this->drupalGet('activity'); + $this->assertNoText($published->label(), 'Deleted node does not show up in the tracker listing.'); + + // Test proper display of time on activity page when comments are disabled. + // Disable comments. + FieldStorageConfig::loadByName('node', 'comment')->delete(); + $node = $this->drupalCreateNode([ + // This title is required to trigger the custom changed time set in the + // node_test module. This is needed in order to ensure a sufficiently + // large 'time ago' interval that isn't numbered in seconds. + 'title' => 'testing_node_presave', + 'status' => 1, + ]); + + $this->drupalGet('activity'); + $this->assertText($node->label(), 'Published node shows up in the tracker listing.'); + $this->assertText(\Drupal::service('date.formatter')->formatTimeDiffSince($node->getChangedTime()), 'The changed time was displayed on the tracker listing.'); + } + + /** + * Tests for the presence of nodes on a user's tracker listing. + */ + public function testTrackerUser() { + $this->drupalLogin($this->user); + + $unpublished = $this->drupalCreateNode([ + 'title' => $this->randomMachineName(8), + 'uid' => $this->user->id(), + 'status' => 0, + ]); + $my_published = $this->drupalCreateNode([ + 'title' => $this->randomMachineName(8), + 'uid' => $this->user->id(), + 'status' => 1, + ]); + $other_published_no_comment = $this->drupalCreateNode([ + 'title' => $this->randomMachineName(8), + 'uid' => $this->otherUser->id(), + 'status' => 1, + ]); + $other_published_my_comment = $this->drupalCreateNode([ + 'title' => $this->randomMachineName(8), + 'uid' => $this->otherUser->id(), + 'status' => 1, + ]); + $comment = [ + 'subject[0][value]' => $this->randomMachineName(), + 'comment_body[0][value]' => $this->randomMachineName(20), + ]; + $this->drupalPostForm('comment/reply/node/' . $other_published_my_comment->id() . '/comment', $comment, t('Save')); + + $this->drupalGet('user/' . $this->user->id() . '/activity'); + $this->assertNoText($unpublished->label(), "Unpublished nodes do not show up in the user's tracker listing."); + $this->assertText($my_published->label(), "Published nodes show up in the user's tracker listing."); + $this->assertNoText($other_published_no_comment->label(), "Another user's nodes do not show up in the user's tracker listing."); + $this->assertText($other_published_my_comment->label(), "Nodes that the user has commented on appear in the user's tracker listing."); + + // Assert cache contexts. + $this->assertCacheContexts(['languages:language_interface', 'route', 'theme', 'url.query_args:' . MainContentViewSubscriber::WRAPPER_FORMAT, 'url.query_args.pagers:0', 'user', 'user.node_grants:view']); + // Assert cache tags for the visible nodes (including owners) and node list + // cache tag. + $expected_tags = Cache::mergeTags($my_published->getCacheTags(), $my_published->getOwner()->getCacheTags()); + $expected_tags = Cache::mergeTags($expected_tags, $other_published_my_comment->getCacheTags()); + $expected_tags = Cache::mergeTags($expected_tags, $other_published_my_comment->getOwner()->getCacheTags()); + // Because the 'user.permissions' cache context is being optimized away. + $role_tags = []; + foreach ($this->user->getRoles() as $rid) { + $role_tags[] = "config:user.role.$rid"; + } + $expected_tags = Cache::mergeTags($expected_tags, $role_tags); + $block_tags = [ + 'block_view', + 'config:block.block.page_actions_block', + 'config:block.block.page_tabs_block', + 'config:block_list', + ]; + $expected_tags = Cache::mergeTags($expected_tags, $block_tags); + $additional_tags = [ + 'node_list', + 'rendered', + ]; + $expected_tags = Cache::mergeTags($expected_tags, $additional_tags); + + $this->assertCacheTags($expected_tags); + $this->assertCacheContexts(['languages:language_interface', 'route', 'theme', 'url.query_args:' . MainContentViewSubscriber::WRAPPER_FORMAT, 'url.query_args.pagers:0', 'user', 'user.node_grants:view']); + + $this->assertLink($my_published->label()); + $this->assertNoLink($unpublished->label()); + // Verify that title and tab title have been set correctly. + $this->assertText('Activity', 'The user activity tab has the name "Activity".'); + $this->assertTitle(t('@name | @site', ['@name' => $this->user->getUsername(), '@site' => $this->config('system.site')->get('name')]), 'The user tracker page has the correct page title.'); + + // Verify that unpublished comments are removed from the tracker. + $admin_user = $this->drupalCreateUser(['post comments', 'administer comments', 'access user profiles']); + $this->drupalLogin($admin_user); + $this->drupalPostForm('comment/1/edit', ['status' => CommentInterface::NOT_PUBLISHED], t('Save')); + $this->drupalGet('user/' . $this->user->id() . '/activity'); + $this->assertNoText($other_published_my_comment->label(), 'Unpublished comments are not counted on the tracker listing.'); + + // Test escaping of title on user's tracker tab. + \Drupal::service('module_installer')->install(['user_hooks_test']); + Cache::invalidateTags(['rendered']); + \Drupal::state()->set('user_hooks_test_user_format_name_alter', TRUE); + $this->drupalGet('user/' . $this->user->id() . '/activity'); + $this->assertEscaped('' . $this->user->id() . ''); + + \Drupal::state()->set('user_hooks_test_user_format_name_alter_safe', TRUE); + Cache::invalidateTags(['rendered']); + $this->drupalGet('user/' . $this->user->id() . '/activity'); + $this->assertNoEscaped('' . $this->user->id() . ''); + $this->assertRaw('' . $this->user->id() . ''); + } + + /** + * Tests the metadata for the "new"/"updated" indicators. + */ + public function testTrackerHistoryMetadata() { + $this->drupalLogin($this->user); + + // Create a page node. + $edit = [ + 'title' => $this->randomMachineName(8), + ]; + $node = $this->drupalCreateNode($edit); + + // Verify that the history metadata is present. + $this->drupalGet('activity'); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->getChangedTime()); + $this->drupalGet('activity/' . $this->user->id()); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->getChangedTime()); + $this->drupalGet('user/' . $this->user->id() . '/activity'); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->getChangedTime()); + + // Add a comment to the page, make sure it is created after the node by + // sleeping for one second, to ensure the last comment timestamp is + // different from before. + $comment = [ + 'subject[0][value]' => $this->randomMachineName(), + 'comment_body[0][value]' => $this->randomMachineName(20), + ]; + sleep(1); + $this->drupalPostForm('comment/reply/node/' . $node->id() . '/comment', $comment, t('Save')); + // Reload the node so that comment.module's hook_node_load() + // implementation can set $node->last_comment_timestamp for the freshly + // posted comment. + $node = Node::load($node->id()); + + // Verify that the history metadata is updated. + $this->drupalGet('activity'); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp); + $this->drupalGet('activity/' . $this->user->id()); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp); + $this->drupalGet('user/' . $this->user->id() . '/activity'); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp); + + // Log out, now verify that the metadata is still there, but the library is + // not. + $this->drupalLogout(); + $this->drupalGet('activity'); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp, FALSE); + $this->drupalGet('user/' . $this->user->id() . '/activity'); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp, FALSE); + } + + /** + * Tests for ordering on a users tracker listing when comments are posted. + */ + public function testTrackerOrderingNewComments() { + $this->drupalLogin($this->user); + + $node_one = $this->drupalCreateNode([ + 'title' => $this->randomMachineName(8), + ]); + + $node_two = $this->drupalCreateNode([ + 'title' => $this->randomMachineName(8), + ]); + + // Now get otherUser to track these pieces of content. + $this->drupalLogin($this->otherUser); + + // Add a comment to the first page. + $comment = [ + 'subject[0][value]' => $this->randomMachineName(), + 'comment_body[0][value]' => $this->randomMachineName(20), + ]; + $this->drupalPostForm('comment/reply/node/' . $node_one->id() . '/comment', $comment, t('Save')); + + // If the comment is posted in the same second as the last one then Drupal + // can't tell the difference, so we wait one second here. + sleep(1); + + // Add a comment to the second page. + $comment = [ + 'subject[0][value]' => $this->randomMachineName(), + 'comment_body[0][value]' => $this->randomMachineName(20), + ]; + $this->drupalPostForm('comment/reply/node/' . $node_two->id() . '/comment', $comment, t('Save')); + + // We should at this point have in our tracker for otherUser: + // 1. node_two + // 2. node_one + // Because that's the reverse order of the posted comments. + + // Now we're going to post a comment to node_one which should jump it to the + // top of the list. + + $this->drupalLogin($this->user); + // If the comment is posted in the same second as the last one then Drupal + // can't tell the difference, so we wait one second here. + sleep(1); + + // Add a comment to the second page. + $comment = [ + 'subject[0][value]' => $this->randomMachineName(), + 'comment_body[0][value]' => $this->randomMachineName(20), + ]; + $this->drupalPostForm('comment/reply/node/' . $node_one->id() . '/comment', $comment, t('Save')); + + // Switch back to the otherUser and assert that the order has swapped. + $this->drupalLogin($this->otherUser); + $this->drupalGet('user/' . $this->otherUser->id() . '/activity'); + // This is a cheeky way of asserting that the nodes are in the right order + // on the tracker page. + // It's almost certainly too brittle. + $pattern = '/' . preg_quote($node_one->getTitle()) . '.+' . preg_quote($node_two->getTitle()) . '/s'; + $this->verbose($pattern); + $this->assertPattern($pattern, 'Most recently commented on node appears at the top of tracker'); + } + + /** + * Tests that existing nodes are indexed by cron. + */ + public function testTrackerCronIndexing() { + $this->drupalLogin($this->user); + + // Create 3 nodes. + $edits = []; + $nodes = []; + for ($i = 1; $i <= 3; $i++) { + $edits[$i] = [ + 'title' => $this->randomMachineName(), + ]; + $nodes[$i] = $this->drupalCreateNode($edits[$i]); + } + + // Add a comment to the last node as other user. + $this->drupalLogin($this->otherUser); + $comment = [ + 'subject[0][value]' => $this->randomMachineName(), + 'comment_body[0][value]' => $this->randomMachineName(20), + ]; + $this->drupalPostForm('comment/reply/node/' . $nodes[3]->id() . '/comment', $comment, t('Save')); + + // Start indexing backwards from node 3. + \Drupal::state()->set('tracker.index_nid', 3); + + // Clear the current tracker tables and rebuild them. + db_delete('tracker_node') + ->execute(); + db_delete('tracker_user') + ->execute(); + tracker_cron(); + + $this->drupalLogin($this->user); + + // Fetch the user's tracker. + $this->drupalGet('activity/' . $this->user->id()); + + // Assert that all node titles are displayed. + foreach ($nodes as $i => $node) { + $this->assertText($node->label(), format_string('Node @i is displayed on the tracker listing pages.', ['@i' => $i])); + } + + // Fetch the site-wide tracker. + $this->drupalGet('activity'); + + // Assert that all node titles are displayed. + foreach ($nodes as $i => $node) { + $this->assertText($node->label(), format_string('Node @i is displayed on the tracker listing pages.', ['@i' => $i])); + } + } + + /** + * Tests that publish/unpublish works at admin/content/node. + */ + public function testTrackerAdminUnpublish() { + \Drupal::service('module_installer')->install(['views']); + \Drupal::service('router.builder')->rebuild(); + $admin_user = $this->drupalCreateUser(['access content overview', 'administer nodes', 'bypass node access']); + $this->drupalLogin($admin_user); + + $node = $this->drupalCreateNode([ + 'title' => $this->randomMachineName(), + ]); + + // Assert that the node is displayed. + $this->drupalGet('activity'); + $this->assertText($node->label(), 'A node is displayed on the tracker listing pages.'); + + // Unpublish the node and ensure that it's no longer displayed. + $edit = [ + 'action' => 'node_unpublish_action', + 'node_bulk_form[0]' => $node->id(), + ]; + $this->drupalPostForm('admin/content', $edit, t('Apply to selected items')); + + $this->drupalGet('activity'); + $this->assertText(t('No content available.'), 'A node is displayed on the tracker listing pages.'); + } + + /** + * Passes if the appropriate history metadata exists. + * + * Verify the data-history-node-id, data-history-node-timestamp and + * data-history-node-last-comment-timestamp attributes, which are used by the + * drupal.tracker-history library to add the appropriate "new" and "updated" + * indicators, as well as the "x new" replies link to the tracker. + * We do this in JavaScript to prevent breaking the render cache. + * + * @param int $node_id + * A node ID, that must exist as a data-history-node-id attribute + * @param int $node_timestamp + * A node timestamp, that must exist as a data-history-node-timestamp + * attribute. + * @param int $node_last_comment_timestamp + * A node's last comment timestamp, that must exist as a + * data-history-node-last-comment-timestamp attribute. + * @param bool $library_is_present + * Whether the drupal.tracker-history library should be present or not. + */ + public function assertHistoryMetadata($node_id, $node_timestamp, $node_last_comment_timestamp, $library_is_present = TRUE) { + $settings = $this->getDrupalSettings(); + $this->assertIdentical($library_is_present, isset($settings['ajaxPageState']) && in_array('tracker/history', explode(',', $settings['ajaxPageState']['libraries'])), 'drupal.tracker-history library is present.'); + $this->assertIdentical(1, count($this->xpath('//table/tbody/tr/td[@data-history-node-id="' . $node_id . '" and @data-history-node-timestamp="' . $node_timestamp . '"]')), 'Tracker table cell contains the data-history-node-id and data-history-node-timestamp attributes for the node.'); + $this->assertIdentical(1, count($this->xpath('//table/tbody/tr/td[@data-history-node-last-comment-timestamp="' . $node_last_comment_timestamp . '"]')), 'Tracker table cell contains the data-history-node-last-comment-timestamp attribute for the node.'); + } + +} diff --git a/core/modules/update/update.manager.inc b/core/modules/update/update.manager.inc index e796007..d9067f4 100644 --- a/core/modules/update/update.manager.inc +++ b/core/modules/update/update.manager.inc @@ -99,10 +99,10 @@ function _update_manager_check_backends(&$form, $operation) { $available_backends = drupal_get_filetransfer_info(); if (empty($available_backends)) { if ($operation == 'update') { - $form['available_backends']['#markup'] = t('Your server does not support updating modules and themes from this interface. Instead, update modules and themes by uploading the new versions directly to the server, as described in the handbook.', [':handbook_url' => 'https://www.drupal.org/getting-started/install-contrib']); + $form['available_backends']['#markup'] = t('Your server does not support updating modules and themes from this interface. Instead, update modules and themes by uploading the new versions directly to the server, as documented in Extending Drupal 8.', [':doc_url' => 'https://www.drupal.org/docs/8/extending-drupal-8/overview']); } else { - $form['available_backends']['#markup'] = t('Your server does not support installing modules and themes from this interface. Instead, install modules and themes by uploading them directly to the server, as described in the handbook.', [':handbook_url' => 'https://www.drupal.org/getting-started/install-contrib']); + $form['available_backends']['#markup'] = t('Your server does not support installing modules and themes from this interface. Instead, install modules and themes by uploading them directly to the server, as documented in Extending Drupal 8.', [':doc_url' => 'https://www.drupal.org/docs/8/extending-drupal-8/overview']); } return FALSE; } @@ -114,21 +114,21 @@ function _update_manager_check_backends(&$form, $operation) { if ($operation == 'update') { $form['available_backends']['#markup'] = \Drupal::translation()->formatPlural( count($available_backends), - 'Updating modules and themes requires @backends access to your server. See the handbook for other update methods.', - 'Updating modules and themes requires access to your server via one of the following methods: @backends. See the handbook for other update methods.', + 'Updating modules and themes requires @backends access to your server. See Extending Drupal 8 for other update methods.', + 'Updating modules and themes requires access to your server via one of the following methods: @backends. See Extending Drupal 8 for other update methods.', [ '@backends' => implode(', ', $backend_names), - ':handbook_url' => 'https://www.drupal.org/getting-started/install-contrib', + ':doc_url' => 'https://www.drupal.org/docs/8/extending-drupal-8/overview', ]); } else { $form['available_backends']['#markup'] = \Drupal::translation()->formatPlural( count($available_backends), - 'Installing modules and themes requires @backends access to your server. See the handbook for other installation methods.', - 'Installing modules and themes requires access to your server via one of the following methods: @backends. See the handbook for other installation methods.', + 'Installing modules and themes requires @backends access to your server. See Extending Drupal 8 for other installation methods.', + 'Installing modules and themes requires access to your server via one of the following methods: @backends. See Extending Drupal 8 for other installation methods.', [ '@backends' => implode(', ', $backend_names), - ':handbook_url' => 'https://www.drupal.org/getting-started/install-contrib', + ':doc_url' => 'https://www.drupal.org/docs/8/extending-drupal-8/overview', ]); } return TRUE; diff --git a/core/modules/user/src/Plugin/EntityReferenceSelection/UserSelection.php b/core/modules/user/src/Plugin/EntityReferenceSelection/UserSelection.php index 6f44aa5..feec81b 100644 --- a/core/modules/user/src/Plugin/EntityReferenceSelection/UserSelection.php +++ b/core/modules/user/src/Plugin/EntityReferenceSelection/UserSelection.php @@ -3,6 +3,7 @@ namespace Drupal\user\Plugin\EntityReferenceSelection; use Drupal\Core\Database\Connection; +use Drupal\Core\Database\Query\Condition; use Drupal\Core\Database\Query\SelectInterface; use Drupal\Core\Entity\EntityManagerInterface; use Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection; @@ -228,17 +229,17 @@ public function entityQueryAlter(SelectInterface $query) { // Re-add the condition and a condition on uid = 0 so that we end up // with a query in the form: // WHERE (name LIKE :name) OR (:anonymous_name LIKE :name AND uid = 0) - $or = db_or(); + $or = new Condition('OR'); $or->condition($condition['field'], $condition['value'], $condition['operator']); // Sadly, the Database layer doesn't allow us to build a condition // in the form ':placeholder = :placeholder2', because the 'field' // part of a condition is always escaped. // As a (cheap) workaround, we separately build a condition with no // field, and concatenate the field and the condition separately. - $value_part = db_and(); + $value_part = new Condition('AND'); $value_part->condition('anonymous_name', $condition['value'], $condition['operator']); $value_part->compile($this->connection, $query); - $or->condition(db_and() + $or->condition((new Condition('AND')) ->where(str_replace('anonymous_name', ':anonymous_name', (string) $value_part), $value_part->arguments() + [':anonymous_name' => \Drupal::config('user.settings')->get('anonymous')]) ->condition('base_table.uid', 0) ); diff --git a/core/modules/user/src/Plugin/migrate/User.php b/core/modules/user/src/Plugin/migrate/User.php index d89787c..0605443 100644 --- a/core/modules/user/src/Plugin/migrate/User.php +++ b/core/modules/user/src/Plugin/migrate/User.php @@ -3,12 +3,12 @@ namespace Drupal\user\Plugin\migrate; use Drupal\migrate\Exception\RequirementsException; -use Drupal\migrate_drupal\Plugin\migrate\CckMigration; +use Drupal\migrate_drupal\Plugin\migrate\FieldMigration; /** * Plugin class for Drupal 7 user migrations dealing with fields and profiles. */ -class User extends CckMigration { +class User extends FieldMigration { /** * {@inheritdoc} @@ -30,16 +30,26 @@ public function getProcess() { if (empty($field_type)) { continue; } - if ($this->cckPluginManager->hasDefinition($field_type)) { - if (!isset($this->cckPluginCache[$field_type])) { - $this->cckPluginCache[$field_type] = $this->cckPluginManager->createInstance($field_type, [], $this); + if ($this->fieldPluginManager->hasDefinition($field_type)) { + if (!isset($this->fieldPluginCache[$field_type])) { + $this->fieldPluginCache[$field_type] = $this->fieldPluginManager->createInstance($field_type, [], $this); } $info = $row->getSource(); - $this->cckPluginCache[$field_type] - ->processCckFieldValues($this, $field_name, $info); + $this->fieldPluginCache[$field_type] + ->processFieldValues($this, $field_name, $info); } else { - $this->process[$field_name] = $field_name; + if ($this->cckPluginManager->hasDefinition($field_type)) { + if (!isset($this->cckPluginCache[$field_type])) { + $this->cckPluginCache[$field_type] = $this->cckPluginManager->createInstance($field_type, [], $this); + } + $info = $row->getSource(); + $this->cckPluginCache[$field_type] + ->processCckFieldValues($this, $field_name, $info); + } + else { + $this->process[$field_name] = $field_name; + } } } } diff --git a/core/modules/user/src/Plugin/views/filter/Current.php b/core/modules/user/src/Plugin/views/filter/Current.php index e712673..aa42529 100644 --- a/core/modules/user/src/Plugin/views/filter/Current.php +++ b/core/modules/user/src/Plugin/views/filter/Current.php @@ -2,6 +2,7 @@ namespace Drupal\user\Plugin\views\filter; +use Drupal\Core\Database\Query\Condition; use Drupal\views\Plugin\views\display\DisplayPluginBase; use Drupal\views\ViewExecutable; use Drupal\views\Plugin\views\filter\BooleanOperator; @@ -28,7 +29,7 @@ public function query() { $this->ensureMyTable(); $field = $this->tableAlias . '.' . $this->realField . ' '; - $or = db_or(); + $or = new Condition('OR'); if (empty($this->value)) { $or->condition($field, '***CURRENT_USER***', '<>'); diff --git a/core/modules/user/src/Tests/Views/AccessRoleTest.php b/core/modules/user/src/Tests/Views/AccessRoleTest.php index 4d5db4d..5705557 100644 --- a/core/modules/user/src/Tests/Views/AccessRoleTest.php +++ b/core/modules/user/src/Tests/Views/AccessRoleTest.php @@ -126,7 +126,7 @@ public function testRenderCaching() { $account_switcher->switchTo($this->webUser); $result = $renderer->renderPlain($build); // @todo Fix this in https://www.drupal.org/node/2551037, - // DisplayPluginBase::applyDisplayCachablityMetadata() is not invoked when + // DisplayPluginBase::applyDisplayCacheabilityMetadata() is not invoked when // using buildBasicRenderable() and a Views access plugin returns FALSE. //$this->assertTrue(in_array('user.roles', $build['#cache']['contexts'])); //$this->assertEqual([], $build['#cache']['tags']); diff --git a/core/modules/views/src/Entity/View.php b/core/modules/views/src/Entity/View.php index 298c20b..a8d8d44 100644 --- a/core/modules/views/src/Entity/View.php +++ b/core/modules/views/src/Entity/View.php @@ -5,7 +5,9 @@ use Drupal\Component\Utility\NestedArray; use Drupal\Core\Cache\Cache; use Drupal\Core\Config\Entity\ConfigEntityBase; +use Drupal\Core\Entity\ContentEntityTypeInterface; use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Language\LanguageInterface; use Drupal\views\Views; use Drupal\views\ViewEntityInterface; @@ -290,10 +292,13 @@ public function calculateDependencies() { public function preSave(EntityStorageInterface $storage) { parent::preSave($storage); + $displays = $this->get('display'); + + $this->fixTableNames($displays); + // Sort the displays. - $display = $this->get('display'); - ksort($display); - $this->set('display', ['default' => $display['default']] + $display); + ksort($displays); + $this->set('display', ['default' => $displays['default']] + $displays); // @todo Check whether isSyncing is needed. if (!$this->isSyncing()) { @@ -302,6 +307,45 @@ public function preSave(EntityStorageInterface $storage) { } /** + * Fixes table names for revision metadata fields of revisionable entities. + * + * Views for revisionable entity types using revision metadata fields might + * be using the wrong table to retrieve the fields after system_update_8300 + * has moved them correctly to the revision table. This method updates the + * views to use the correct tables. + * + * @param array &$displays + * An array containing display handlers of a view. + * + * @deprecated in Drupal 8.3.0, will be removed in Drupal 9.0.0. + */ + private function fixTableNames(array &$displays) { + // Fix wrong table names for entity revision metadata fields. + foreach ($displays as $display => $display_data) { + if (isset($display_data['display_options']['fields'])) { + foreach ($display_data['display_options']['fields'] as $property_name => $property_data) { + if (isset($property_data['entity_type']) && isset($property_data['field']) && isset($property_data['table'])) { + $entity_type = $this->entityTypeManager()->getDefinition($property_data['entity_type']); + // We need to update the table name only for revisionable entity + // types, otherwise the view is already using the correct table. + if (($entity_type instanceof ContentEntityTypeInterface) && is_subclass_of($entity_type->getClass(), FieldableEntityInterface::class) && $entity_type->isRevisionable()) { + $revision_metadata_fields = $entity_type->getRevisionMetadataKeys(); + // @see \Drupal\Core\Entity\Sql\SqlContentEntityStorage::initTableLayout() + $revision_table = $entity_type->getRevisionTable() ?: $entity_type->id() . '_revision'; + + // Check if this is a revision metadata field and if it uses the + // wrong table. + if (in_array($property_data['field'], $revision_metadata_fields) && $property_data['table'] != $revision_table) { + $displays[$display]['display_options']['fields'][$property_name]['table'] = $revision_table; + } + } + } + } + } + } + } + + /** * Fills in the cache metadata of this view. * * Cache metadata is set per view and per display, and ends up being stored in diff --git a/core/modules/views/src/ManyToOneHelper.php b/core/modules/views/src/ManyToOneHelper.php index d772db1..cb2e575 100644 --- a/core/modules/views/src/ManyToOneHelper.php +++ b/core/modules/views/src/ManyToOneHelper.php @@ -2,6 +2,7 @@ namespace Drupal\views; +use Drupal\Core\Database\Query\Condition; use Drupal\Core\Form\FormStateInterface; use Drupal\views\Plugin\views\HandlerBase; @@ -268,8 +269,8 @@ public function addFilter() { $options['group'] = 0; } - // add_condition determines whether a single expression is enough(FALSE) or the - // conditions should be added via an db_or()/db_and() (TRUE). + // If $add_condition is set to FALSE, a single expression is enough. If it + // is set to TRUE, conditions will be added. $add_condition = TRUE; if ($operator == 'not') { $value = NULL; @@ -326,7 +327,7 @@ public function addFilter() { if ($add_condition) { $field = $this->handler->realField; - $clause = $operator == 'or' ? db_or() : db_and(); + $clause = $operator == 'or' ? new Condition('OR') : new Condition('AND'); foreach ($this->handler->tableAliases as $value => $alias) { $clause->condition("$alias.$field", $value); } diff --git a/core/modules/views/src/Plugin/views/area/Result.php b/core/modules/views/src/Plugin/views/area/Result.php index f0082db..d2f1b01 100644 --- a/core/modules/views/src/Plugin/views/area/Result.php +++ b/core/modules/views/src/Plugin/views/area/Result.php @@ -83,9 +83,13 @@ public function render($empty = FALSE) { // Not every view has total_rows set, use view->result instead. $total = isset($this->view->total_rows) ? $this->view->total_rows : count($this->view->result); $label = Html::escape($this->view->storage->label()); + // If there is no result the "start" and "current_record_count" should be + // equal to 0. To have the same calculation logic, we use a "start offset" + // to handle all the cases. + $start_offset = empty($total) ? 0 : 1; if ($per_page === 0) { $page_count = 1; - $start = 1; + $start = $start_offset; $end = $total; } else { @@ -94,10 +98,10 @@ public function render($empty = FALSE) { if ($total_count > $total) { $total_count = $total; } - $start = ($current_page - 1) * $per_page + 1; + $start = ($current_page - 1) * $per_page + $start_offset; $end = $total_count; } - $current_record_count = ($end - $start) + 1; + $current_record_count = ($end - $start) + $start_offset; // Get the search information. $replacements = []; $replacements['@start'] = $start; @@ -109,7 +113,7 @@ public function render($empty = FALSE) { $replacements['@current_record_count'] = $current_record_count; $replacements['@page_count'] = $page_count; // Send the output. - if (!empty($total)) { + if (!empty($total) || !empty($this->options['empty'])) { $output .= Xss::filterAdmin(str_replace(array_keys($replacements), array_values($replacements), $format)); } // Return as render array. diff --git a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php index e31b5a2..3744fea 100644 --- a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php +++ b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php @@ -2115,7 +2115,7 @@ public function render() { '#cache' => &$this->view->element['#cache'], ]; - $this->applyDisplayCachablityMetadata($this->view->element); + $this->applyDisplayCacheabilityMetadata($this->view->element); return $element; } @@ -2126,7 +2126,7 @@ public function render() { * @param array $element * The render array with updated cacheability metadata. */ - protected function applyDisplayCachablityMetadata(array &$element) { + protected function applyDisplayCacheabilityMetadata(array &$element) { /** @var \Drupal\views\Plugin\views\cache\CachePluginBase $cache */ $cache = $this->getPlugin('cache'); @@ -2139,6 +2139,22 @@ protected function applyDisplayCachablityMetadata(array &$element) { } /** + * Applies the cacheability of the current display to the given render array. + * + * @param array $element + * The render array with updated cacheability metadata. + * + * @deprecated in Drupal 8.4.0, will be removed before Drupal 9.0. Use + * DisplayPluginBase::applyDisplayCacheabilityMetadata instead. + * + * @see \Drupal\views\Plugin\views\display\DisplayPluginBase::applyDisplayCacheabilityMetadata() + */ + protected function applyDisplayCachablityMetadata(array &$element) { + @trigger_error('The DisplayPluginBase::applyDisplayCachablityMetadata method is deprecated since version 8.4 and will be removed in 9.0. Use DisplayPluginBase::applyDisplayCacheabilityMetadata instead.', E_USER_DEPRECATED); + $this->applyDisplayCacheabilityMetadata($element); + } + + /** * {@inheritdoc} */ public function elementPreRender(array $element) { @@ -2330,7 +2346,7 @@ public function buildRenderable(array $args = [], $cache = TRUE) { // of cacheability metadata (e.g.: cache contexts), so they can bubble up. // Thus, we add the cacheability metadata first, then modify / remove the // cache keys depending on the $cache argument. - $this->applyDisplayCachablityMetadata($this->view->element); + $this->applyDisplayCacheabilityMetadata($this->view->element); if ($cache) { $this->view->element['#cache'] += ['keys' => []]; // Places like \Drupal\views\ViewExecutable::setCurrentPage() set up an diff --git a/core/modules/views/src/Plugin/views/display/EntityReference.php b/core/modules/views/src/Plugin/views/display/EntityReference.php index 73b137c..4dedd51 100644 --- a/core/modules/views/src/Plugin/views/display/EntityReference.php +++ b/core/modules/views/src/Plugin/views/display/EntityReference.php @@ -2,6 +2,8 @@ namespace Drupal\views\Plugin\views\display; +use Drupal\Core\Database\Query\Condition; + /** * The plugin that handles an EntityReference display. * @@ -131,7 +133,7 @@ public function query() { } // Multiple search fields are OR'd together. - $conditions = db_or(); + $conditions = new Condition('OR'); // Build the condition using the selected search fields. foreach ($style_options['options']['search_fields'] as $field_id) { diff --git a/core/modules/views/src/Plugin/views/display/Feed.php b/core/modules/views/src/Plugin/views/display/Feed.php index b9a2990..c664c05 100644 --- a/core/modules/views/src/Plugin/views/display/Feed.php +++ b/core/modules/views/src/Plugin/views/display/Feed.php @@ -106,7 +106,7 @@ public function preview() { public function render() { $build = $this->view->style_plugin->render($this->view->result); - $this->applyDisplayCachablityMetadata($build); + $this->applyDisplayCacheabilityMetadata($build); return $build; } diff --git a/core/modules/views/src/Plugin/views/filter/StringFilter.php b/core/modules/views/src/Plugin/views/filter/StringFilter.php index 4cae120..a9ab5a1 100644 --- a/core/modules/views/src/Plugin/views/filter/StringFilter.php +++ b/core/modules/views/src/Plugin/views/filter/StringFilter.php @@ -2,6 +2,7 @@ namespace Drupal\views\Plugin\views\filter; +use Drupal\Core\Database\Query\Condition; use Drupal\Core\Form\FormStateInterface; /** @@ -265,7 +266,7 @@ protected function opContains($field) { } protected function opContainsWord($field) { - $where = $this->operator == 'word' ? db_or() : db_and(); + $where = $this->operator == 'word' ? new Condition('OR') : new Condition('AND'); // Don't filter on empty strings. if (empty($this->value)) { diff --git a/core/modules/views/src/Plugin/views/query/Sql.php b/core/modules/views/src/Plugin/views/query/Sql.php index 1210c1c..ab056f5 100644 --- a/core/modules/views/src/Plugin/views/query/Sql.php +++ b/core/modules/views/src/Plugin/views/query/Sql.php @@ -5,6 +5,7 @@ use Drupal\Component\Utility\NestedArray; use Drupal\Core\Cache\Cache; use Drupal\Core\Database\Database; +use Drupal\Core\Database\Query\Condition; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\views\Plugin\views\display\DisplayPluginBase; @@ -830,7 +831,7 @@ public function clearFields() { * @code * $this->query->addWhere( * $this->options['group'], - * db_or() + * (new Condition('OR')) * ->condition($field, $value, 'NOT IN') * ->condition($field, $value, 'IS NULL') * ); @@ -1056,13 +1057,13 @@ protected function buildCondition($where = 'where') { $has_arguments = FALSE; $has_filter = FALSE; - $main_group = db_and(); - $filter_group = $this->groupOperator == 'OR' ? db_or() : db_and(); + $main_group = new Condition('AND'); + $filter_group = $this->groupOperator == 'OR' ? new Condition('OR') : new Condition('AND'); foreach ($this->$where as $group => $info) { if (!empty($info['conditions'])) { - $sub_group = $info['type'] == 'OR' ? db_or() : db_and(); + $sub_group = $info['type'] == 'OR' ? new Condition('OR') : new Condition('AND'); foreach ($info['conditions'] as $clause) { if ($clause['operator'] == 'formula') { $has_condition = TRUE; diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_area_result.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_area_result.yml new file mode 100644 index 0000000..8ca5628 --- /dev/null +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_area_result.yml @@ -0,0 +1,79 @@ +langcode: en +status: true +dependencies: { } +id: test_area_result +label: '' +module: views +description: '' +tag: '' +base_table: views_test_data +base_field: nid +core: '8' +display: + default: + display_options: + defaults: + fields: false + pager: false + sorts: false + fields: + id: + field: id + id: id + relationship: none + table: views_test_data + plugin_id: numeric + pager: + options: + offset: 0 + type: none + sorts: + id: + field: id + id: id + order: ASC + relationship: none + table: views_test_data + plugin_id: numeric + empty: + title: + field: title + id: title + table: views + plugin_id: title + title: test_title_empty + header: + result: + id: result + table: views + field: result + relationship: none + group_type: group + admin_label: '' + empty: true + content: "start: @start | end: @end | total: @total | label: @label | per page: @per_page | current page: @current_page | current record count: @current_record_count | page count: @page_count" + plugin_id: result + display_plugin: default + display_title: Master + id: default + position: 0 + page_1: + display_options: + path: test-area-result + defaults: + header: false + header: + result: + id: result + table: views + field: result + relationship: none + group_type: group + admin_label: '' + empty: false + content: "start: @start | end: @end | total: @total | label: @label | per page: @per_page | current page: @current_page | current record count: @current_record_count | page count: @page_count" + plugin_id: result + display_plugin: page + display_title: 'Page 1' + id: page_1 + position: 1 diff --git a/core/modules/views/tests/src/Kernel/Handler/AreaResultTest.php b/core/modules/views/tests/src/Kernel/Handler/AreaResultTest.php new file mode 100644 index 0000000..8b67fec --- /dev/null +++ b/core/modules/views/tests/src/Kernel/Handler/AreaResultTest.php @@ -0,0 +1,72 @@ +setDisplay('default'); + $this->executeView($view); + $output = $view->render(); + $output = \Drupal::service('renderer')->renderRoot($output); + $this->setRawContent($output); + $this->assertText('start: 1 | end: 5 | total: 5 | label: test_area_result | per page: 0 | current page: 1 | current record count: 5 | page count: 1'); + } + + /** + * Tests the results area handler. + */ + public function testResultEmpty() { + $view = Views::getView('test_area_result'); + + // Test that the area is displayed if we have checked the empty checkbox. + $view->setDisplay('default'); + + // Add a filter that will make the result set empty. + $view->displayHandlers->get('default')->overrideOption('filters', [ + 'name' => [ + 'id' => 'name', + 'table' => 'views_test_data', + 'field' => 'name', + 'relationship' => 'none', + 'operator' => '=', + 'value' => 'non-existing-name', + ], + ]); + + $this->executeView($view); + $output = $view->render(); + $output = \Drupal::service('renderer')->renderRoot($output); + $this->setRawContent($output); + $this->assertText('start: 0 | end: 0 | total: 0 | label: test_area_result | per page: 0 | current page: 1 | current record count: 0 | page count: 1'); + + // Test that the area is not displayed if we have not checked the empty + // checkbox. + $view->setDisplay('page_1'); + + $this->executeView($view); + $output = $view->render(); + $output = \Drupal::service('renderer')->renderRoot($output); + $this->setRawContent($output); + $this->assertNoText('start: 0 | end: 0 | total: 0 | label: test_area_result | per page: 0 | current page: 1 | current record count: 0 | page count: 1'); + } + +} diff --git a/core/modules/views/tests/src/Kernel/TestViewsTest.php b/core/modules/views/tests/src/Kernel/TestViewsTest.php index f9aa673..dcd1859 100644 --- a/core/modules/views/tests/src/Kernel/TestViewsTest.php +++ b/core/modules/views/tests/src/Kernel/TestViewsTest.php @@ -34,7 +34,8 @@ public function testDefaultConfig() { \Drupal::service('config.storage'), new TestInstallStorage(InstallStorage::CONFIG_SCHEMA_DIRECTORY), \Drupal::service('cache.discovery'), - \Drupal::service('module_handler') + \Drupal::service('module_handler'), + \Drupal::service('class_resolver') ); // Create a configuration storage with access to default configuration in diff --git a/core/modules/views/tests/src/Unit/EntityViewsDataTest.php b/core/modules/views/tests/src/Unit/EntityViewsDataTest.php index 195e3d4..74bcc08 100644 --- a/core/modules/views/tests/src/Unit/EntityViewsDataTest.php +++ b/core/modules/views/tests/src/Unit/EntityViewsDataTest.php @@ -9,7 +9,6 @@ use Drupal\Core\Config\Entity\ConfigEntityType; use Drupal\Core\Entity\ContentEntityType; -use Drupal\Core\Entity\EntityType; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\Sql\DefaultTableMapping; use Drupal\Core\Field\BaseFieldDefinition; @@ -1087,7 +1086,7 @@ public function setEntityType(EntityTypeInterface $entity_type) { } -class TestEntityType extends EntityType { +class TestEntityType extends ContentEntityType { /** * Sets a specific entity key. diff --git a/core/modules/views/views.post_update.php b/core/modules/views/views.post_update.php index a278034..e09159e 100644 --- a/core/modules/views/views.post_update.php +++ b/core/modules/views/views.post_update.php @@ -201,3 +201,15 @@ function views_post_update_boolean_filter_values() { function views_post_update_grouped_filters() { // Empty update to cause a cache rebuild so that the schema changes are read. } + +/** + * Fix table names for revision metadata fields. + */ +function views_post_update_revision_metadata_fields() { + // The table names are fixed automatically in + // \Drupal\views\Entity\View::preSave(), so we just need to re-save all views. + $views = View::loadMultiple(); + array_walk($views, function(View $view) { + $view->save(); + }); +} diff --git a/core/modules/workflows/src/Form/WorkflowEditForm.php b/core/modules/workflows/src/Form/WorkflowEditForm.php index 6b01920..c0ea5a4 100644 --- a/core/modules/workflows/src/Form/WorkflowEditForm.php +++ b/core/modules/workflows/src/Form/WorkflowEditForm.php @@ -191,7 +191,6 @@ public function save(array $form, FormStateInterface $form_state) { $workflow = $this->entity; $workflow->save(); drupal_set_message($this->t('Saved the %label Workflow.', ['%label' => $workflow->label()])); - $form_state->setRedirectUrl($workflow->toUrl('collection')); } /** diff --git a/core/modules/workflows/tests/src/Functional/WorkflowUiTest.php b/core/modules/workflows/tests/src/Functional/WorkflowUiTest.php index 91c69b7..3014dd7 100644 --- a/core/modules/workflows/tests/src/Functional/WorkflowUiTest.php +++ b/core/modules/workflows/tests/src/Functional/WorkflowUiTest.php @@ -176,9 +176,8 @@ public function testWorkflowCreation() { $workflow = $workflow_storage->loadUnchanged('test'); $this->assertEquals('draft', $workflow->getInitialState()->id()); - // This will take us to the list of workflows, so we need to edit the - // workflow again. - $this->clickLink('Edit'); + // Verify that we are still on the workflow edit page. + $this->assertSession()->addressEquals('admin/config/workflow/workflows/manage/test'); // Ensure that weight changes the transition ordering. $this->assertEquals(['publish', 'create_new_draft'], array_keys($workflow->getTransitions())); @@ -187,9 +186,8 @@ public function testWorkflowCreation() { $workflow = $workflow_storage->loadUnchanged('test'); $this->assertEquals(['create_new_draft', 'publish'], array_keys($workflow->getTransitions())); - // This will take us to the list of workflows, so we need to edit the - // workflow again. - $this->clickLink('Edit'); + // Verify that we are still on the workflow edit page. + $this->assertSession()->addressEquals('admin/config/workflow/workflows/manage/test'); // Ensure that a delete link for the published state exists before deleting // the draft state. diff --git a/core/modules/workflows/tests/src/Unit/StateTest.php b/core/modules/workflows/tests/src/Unit/StateTest.php index 82feca3..ae9fc46 100644 --- a/core/modules/workflows/tests/src/Unit/StateTest.php +++ b/core/modules/workflows/tests/src/Unit/StateTest.php @@ -27,6 +27,9 @@ protected function setUp() { // mocked. $container = new ContainerBuilder(); $workflow_type = $this->prophesize(WorkflowTypeInterface::class); + $workflow_type->setConfiguration(Argument::any())->will(function ($arguments) { + $this->getConfiguration()->willReturn($arguments[0]); + }); $workflow_type->decorateState(Argument::any())->willReturnArgument(0); $workflow_type->decorateTransition(Argument::any())->willReturnArgument(0); $workflow_type->deleteState(Argument::any())->willReturn(NULL); diff --git a/core/modules/workflows/tests/src/Unit/TransitionTest.php b/core/modules/workflows/tests/src/Unit/TransitionTest.php index 3202e8a..c343fed 100644 --- a/core/modules/workflows/tests/src/Unit/TransitionTest.php +++ b/core/modules/workflows/tests/src/Unit/TransitionTest.php @@ -27,6 +27,9 @@ protected function setUp() { // mocked. $container = new ContainerBuilder(); $workflow_type = $this->prophesize(WorkflowTypeInterface::class); + $workflow_type->setConfiguration(Argument::any())->will(function ($arguments) { + $this->getConfiguration()->willReturn($arguments[0]); + }); $workflow_type->decorateState(Argument::any())->willReturnArgument(0); $workflow_type->decorateTransition(Argument::any())->willReturnArgument(0); $workflow_manager = $this->prophesize(WorkflowTypeManager::class); diff --git a/core/modules/workflows/tests/src/Unit/WorkflowTest.php b/core/modules/workflows/tests/src/Unit/WorkflowTest.php index 89ee31f..e2a7401 100644 --- a/core/modules/workflows/tests/src/Unit/WorkflowTest.php +++ b/core/modules/workflows/tests/src/Unit/WorkflowTest.php @@ -27,6 +27,9 @@ protected function setUp() { // mocked. $container = new ContainerBuilder(); $workflow_type = $this->prophesize(WorkflowTypeInterface::class); + $workflow_type->setConfiguration(Argument::any())->will(function ($arguments) { + $this->getConfiguration()->willReturn($arguments[0]); + }); $workflow_type->decorateState(Argument::any())->willReturnArgument(0); $workflow_type->decorateTransition(Argument::any())->willReturnArgument(0); $workflow_manager = $this->prophesize(WorkflowTypeManager::class); @@ -215,6 +218,9 @@ public function testDeleteState() { // correctly. $container = new ContainerBuilder(); $workflow_type = $this->prophesize(WorkflowTypeInterface::class); + $workflow_type->setConfiguration(Argument::any())->will(function ($arguments) { + $this->getConfiguration()->willReturn($arguments[0]); + }); $workflow_type->decorateState(Argument::any())->willReturnArgument(0); $workflow_type->decorateTransition(Argument::any())->willReturnArgument(0); $workflow_type->deleteState('draft')->shouldBeCalled(); @@ -636,6 +642,9 @@ public function testDeleteTransition() { // correctly. $container = new ContainerBuilder(); $workflow_type = $this->prophesize(WorkflowTypeInterface::class); + $workflow_type->setConfiguration(Argument::any())->will(function ($arguments) { + $this->getConfiguration()->willReturn($arguments[0]); + }); $workflow_type->decorateState(Argument::any())->willReturnArgument(0); $workflow_type->decorateTransition(Argument::any())->willReturnArgument(0); $workflow_type->deleteTransition('publish')->shouldBeCalled(); diff --git a/core/phpcs.xml.dist b/core/phpcs.xml.dist index be8c46e..a593e62 100644 --- a/core/phpcs.xml.dist +++ b/core/phpcs.xml.dist @@ -12,16 +12,16 @@ - - - - - - - + + + + + + + - + @@ -41,9 +41,9 @@ - - - + + + @@ -65,41 +65,40 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - + diff --git a/core/profiles/minimal/src/Tests/MinimalTest.php b/core/profiles/minimal/src/Tests/MinimalTest.php deleted file mode 100644 index c7e3228..0000000 --- a/core/profiles/minimal/src/Tests/MinimalTest.php +++ /dev/null @@ -1,42 +0,0 @@ -drupalGet(''); - // Check the login block is present. - $this->assertLink(t('Create new account')); - $this->assertResponse(200); - - // Create a user to test tools and navigation blocks for logged in users - // with appropriate permissions. - $user = $this->drupalCreateUser(['access administration pages', 'administer content types']); - $this->drupalLogin($user); - $this->drupalGet(''); - $this->assertText(t('Tools')); - $this->assertText(t('Administration')); - - // Ensure that there are no pending updates after installation. - $this->drupalLogin($this->rootUser); - $this->drupalGet('update.php/selection'); - $this->assertText('No pending updates.'); - - // Ensure that there are no pending entity updates after installation. - $this->assertFalse($this->container->get('entity.definition_update_manager')->needsUpdates(), 'After installation, entity schema is up to date.'); - } - -} diff --git a/core/profiles/minimal/tests/src/Functional/MinimalTest.php b/core/profiles/minimal/tests/src/Functional/MinimalTest.php new file mode 100644 index 0000000..b5f170d --- /dev/null +++ b/core/profiles/minimal/tests/src/Functional/MinimalTest.php @@ -0,0 +1,42 @@ +drupalGet(''); + // Check the login block is present. + $this->assertLink(t('Create new account')); + $this->assertResponse(200); + + // Create a user to test tools and navigation blocks for logged in users + // with appropriate permissions. + $user = $this->drupalCreateUser(['access administration pages', 'administer content types']); + $this->drupalLogin($user); + $this->drupalGet(''); + $this->assertText(t('Tools')); + $this->assertText(t('Administration')); + + // Ensure that there are no pending updates after installation. + $this->drupalLogin($this->rootUser); + $this->drupalGet('update.php/selection'); + $this->assertText('No pending updates.'); + + // Ensure that there are no pending entity updates after installation. + $this->assertFalse($this->container->get('entity.definition_update_manager')->needsUpdates(), 'After installation, entity schema is up to date.'); + } + +} diff --git a/core/profiles/testing_inherited/config/install/block.block.stable_login.yml b/core/profiles/testing_inherited/config/install/block.block.stable_login.yml new file mode 100644 index 0000000..3650c6c --- /dev/null +++ b/core/profiles/testing_inherited/config/install/block.block.stable_login.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + module: + - user + theme: + - stable +id: stable_login +theme: stable +region: sidebar_first +weight: 0 +provider: null +plugin: user_login_block +settings: + id: user_login_block + label: 'User login' + provider: user + label_display: visible +visibility: { } diff --git a/core/profiles/testing_inherited/config/install/system.theme.yml b/core/profiles/testing_inherited/config/install/system.theme.yml new file mode 100644 index 0000000..67aeeee --- /dev/null +++ b/core/profiles/testing_inherited/config/install/system.theme.yml @@ -0,0 +1,2 @@ +# @todo: Remove this file in https://www.drupal.org/node/2352949 +default: stable diff --git a/core/profiles/testing_inherited/testing_inherited.info.yml b/core/profiles/testing_inherited/testing_inherited.info.yml new file mode 100644 index 0000000..6f584ae --- /dev/null +++ b/core/profiles/testing_inherited/testing_inherited.info.yml @@ -0,0 +1,20 @@ +name: Testing Inherited +type: profile +description: 'Profile for testing base profile inheritance.' +version: VERSION +core: 8.x +hidden: true + +base profile: + name: testing + excluded_dependencies: + - page_cache + excluded_themes: + - classy + +dependencies: + - block + - config + +themes: + - stable diff --git a/core/profiles/testing_inherited/tests/src/Functional/InheritedProfileTest.php b/core/profiles/testing_inherited/tests/src/Functional/InheritedProfileTest.php new file mode 100644 index 0000000..dba4580 --- /dev/null +++ b/core/profiles/testing_inherited/tests/src/Functional/InheritedProfileTest.php @@ -0,0 +1,40 @@ +assertInstanceOf(BlockInterface::class, Block::load('stable_login')); + + // Check that stable is the default theme. + $this->assertEquals('stable', $this->config('system.theme')->get('default')); + + // Check the excluded_dependencies flag on installation profiles. + $this->assertTrue(\Drupal::moduleHandler()->moduleExists('config')); + $this->assertFalse(\Drupal::moduleHandler()->moduleExists('page_cache')); + + // Check that all themes were installed, except excluded ones. + $this->assertTrue(\Drupal::service('theme_handler')->themeExists('stable')); + $this->assertFalse(\Drupal::service('theme_handler')->themeExists('classy')); + } + +} diff --git a/core/scripts/run-tests.sh b/core/scripts/run-tests.sh index 9e832a5..714cf3c 100755 --- a/core/scripts/run-tests.sh +++ b/core/scripts/run-tests.sh @@ -17,6 +17,7 @@ use Drupal\simpletest\Form\SimpletestResultsForm; use Drupal\simpletest\TestBase; use Drupal\simpletest\TestDiscovery; +use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; $autoloader = require_once __DIR__ . '/../../autoload.php'; @@ -36,7 +37,7 @@ const SIMPLETEST_SCRIPT_EXIT_FAILURE = 1; const SIMPLETEST_SCRIPT_EXIT_EXCEPTION = 2; -if (!class_exists('\PHPUnit_Framework_TestCase')) { +if (!class_exists(TestCase::class)) { echo "\nrun-tests.sh requires the PHPUnit testing framework. Please use 'composer install --dev' to ensure that it is present.\n\n"; exit(SIMPLETEST_SCRIPT_EXIT_FAILURE); } @@ -783,7 +784,7 @@ function simpletest_script_run_one_test($test_id, $test_class) { $methods = array(); } $test = new $class_name($test_id); - if (is_subclass_of($test_class, '\PHPUnit_Framework_TestCase')) { + if (is_subclass_of($test_class, TestCase::class)) { $status = simpletest_script_run_phpunit($test_id, $test_class); } else { @@ -865,7 +866,7 @@ function simpletest_script_command($test_id, $test_class) { * @see simpletest_script_run_one_test() */ function simpletest_script_cleanup($test_id, $test_class, $exitcode) { - if (is_subclass_of($test_class, '\PHPUnit_Framework_TestCase')) { + if (is_subclass_of($test_class, TestCase::class)) { // PHPUnit test, move on. return; } @@ -1020,7 +1021,7 @@ function simpletest_script_get_test_list() { else { foreach ($matches[1] as $class_name) { $namespace_class = $namespace . '\\' . $class_name; - if (is_subclass_of($namespace_class, '\Drupal\simpletest\TestBase') || is_subclass_of($namespace_class, '\PHPUnit_Framework_TestCase')) { + if (is_subclass_of($namespace_class, '\Drupal\simpletest\TestBase') || is_subclass_of($namespace_class, TestCase::class)) { $test_list[] = $namespace_class; } } @@ -1074,7 +1075,7 @@ function simpletest_script_get_test_list() { else { foreach ($matches[1] as $class_name) { $namespace_class = $namespace . '\\' . $class_name; - if (is_subclass_of($namespace_class, '\Drupal\simpletest\TestBase') || is_subclass_of($namespace_class, '\PHPUnit_Framework_TestCase')) { + if (is_subclass_of($namespace_class, '\Drupal\simpletest\TestBase') || is_subclass_of($namespace_class, TestCase::class)) { $test_list[] = $namespace_class; } } diff --git a/core/tests/Drupal/KernelTests/AssertLegacyTrait.php b/core/tests/Drupal/KernelTests/AssertLegacyTrait.php index 1d1816a..f63c4bf 100644 --- a/core/tests/Drupal/KernelTests/AssertLegacyTrait.php +++ b/core/tests/Drupal/KernelTests/AssertLegacyTrait.php @@ -6,7 +6,7 @@ * Translates Simpletest assertion methods to PHPUnit. * * Protected methods are custom. Public static methods override methods of - * \PHPUnit_Framework_Assert. + * \PHPUnit\Framework\Assert. * * @deprecated Scheduled for removal in Drupal 9.0.0. Use PHPUnit's native * assert methods instead. diff --git a/core/tests/Drupal/KernelTests/Config/TypedConfigTest.php b/core/tests/Drupal/KernelTests/Config/TypedConfigTest.php new file mode 100644 index 0000000..5881a14 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Config/TypedConfigTest.php @@ -0,0 +1,146 @@ +installConfig('config_test'); + } + + /** + * Verifies that the Typed Data API is implemented correctly. + */ + public function testTypedDataAPI() { + /** @var \Drupal\Core\Config\TypedConfigManagerInterface $typed_config_manager */ + $typed_config_manager = \Drupal::service('config.typed'); + /** @var \Drupal\Core\Config\Schema\TypedConfigInterface $typed_config */ + $typed_config = $typed_config_manager->get('config_test.validation'); + + // Test a primitive. + $string_data = $typed_config->get('llama'); + $this->assertInstanceOf(StringInterface::class, $string_data); + $this->assertEquals('llama', $string_data->getValue()); + + // Test complex data. + $mapping = $typed_config->get('cat'); + /** @var \Drupal\Core\TypedData\ComplexDataInterface $mapping */ + $this->assertInstanceOf(ComplexDataInterface::class, $mapping); + $this->assertInstanceOf(StringInterface::class, $mapping->get('type')); + $this->assertEquals('kitten', $mapping->get('type')->getValue()); + $this->assertInstanceOf(IntegerInterface::class, $mapping->get('count')); + $this->assertEquals(2, $mapping->get('count')->getValue()); + // Verify the item metadata is available. + $this->assertInstanceOf(ComplexDataDefinitionInterface::class, $mapping->getDataDefinition()); + $this->assertArrayHasKey('type', $mapping->getProperties()); + $this->assertArrayHasKey('count', $mapping->getProperties()); + + // Test accessing sequences. + $sequence = $typed_config->get('giraffe'); + /** @var \Drupal\Core\TypedData\ListInterface $sequence */ + $this->assertInstanceOf(ComplexDataInterface::class, $sequence); + $this->assertInstanceOf(StringInterface::class, $sequence->get('hum1')); + $this->assertEquals('hum1', $sequence->get('hum1')->getValue()); + $this->assertEquals('hum2', $sequence->get('hum2')->getValue()); + $this->assertEquals(2, count($sequence->getIterator())); + // Verify the item metadata is available. + $this->assertInstanceOf(SequenceDataDefinition::class, $sequence->getDataDefinition()); + } + + /** + * Tests config validation via the Typed Data API. + */ + public function testSimpleConfigValidation() { + $config = \Drupal::configFactory()->getEditable('config_test.validation'); + /** @var \Drupal\Core\Config\TypedConfigManagerInterface $typed_config_manager */ + $typed_config_manager = \Drupal::service('config.typed'); + /** @var \Drupal\Core\Config\Schema\TypedConfigInterface $typed_config */ + $typed_config = $typed_config_manager->get('config_test.validation'); + + $result = $typed_config->validate(); + $this->assertInstanceOf(ConstraintViolationListInterface::class, $result); + $this->assertEmpty($result); + + // Test constraints on primitive types. + $config->set('llama', 'elephant'); + $config->save(); + + $typed_config = $typed_config_manager->get('config_test.validation'); + $result = $typed_config->validate(); + // Its not a valid llama anymore. + $this->assertCount(1, $result); + $this->assertEquals('no valid llama', $result->get(0)->getMessage()); + + // Test constraints on mapping. + $config->set('llama', 'llama'); + $config->set('cat.type', 'nyans'); + $config->save(); + + $typed_config = $typed_config_manager->get('config_test.validation'); + $result = $typed_config->validate(); + $this->assertEmpty($result); + + // Test constrains on nested mapping. + $config->set('cat.type', 'miaus'); + $config->save(); + + $typed_config = $typed_config_manager->get('config_test.validation'); + $result = $typed_config->validate(); + $this->assertCount(1, $result); + $this->assertEquals('no valid cat', $result->get(0)->getMessage()); + + // Test constrains on sequences elements. + $config->set('cat.type', 'nyans'); + $config->set('giraffe', ['muh', 'hum2']); + $config->save(); + $typed_config = $typed_config_manager->get('config_test.validation'); + $result = $typed_config->validate(); + $this->assertCount(1, $result); + $this->assertEquals('Giraffes just hum', $result->get(0)->getMessage()); + + // Test constrains on the sequence itself. + $config->set('giraffe', ['hum', 'hum2', 'invalid-key' => 'hum']); + $config->save(); + + $typed_config = $typed_config_manager->get('config_test.validation'); + $result = $typed_config->validate(); + $this->assertCount(1, $result); + $this->assertEquals('giraffe', $result->get(0)->getPropertyPath()); + $this->assertEquals('Invalid giraffe key.', $result->get(0)->getMessage()); + + // Validates mapping. + $typed_config = $typed_config_manager->get('config_test.validation'); + $value = $typed_config->getValue(); + unset($value['giraffe']); + $value['elephant'] = 'foo'; + $typed_config->setValue($value); + $result = $typed_config->validate(); + $this->assertCount(1, $result); + $this->assertEquals('', $result->get(0)->getPropertyPath()); + $this->assertEquals('Missing giraffe.', $result->get(0)->getMessage()); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigInstallTest.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigInstallTest.php index 8203b31..8ba0ae1 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigInstallTest.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigInstallTest.php @@ -202,6 +202,15 @@ public function testDependencyChecking() { $this->assertEqual($e->getConfigObjects(), ['config_test.dynamic.other_module_test_with_dependency' => ['config_other_module_config_test', 'config_test.dynamic.dotted.english']]); $this->assertEqual($e->getMessage(), 'Configuration objects provided by config_install_dependency_test have unmet dependencies: config_test.dynamic.other_module_test_with_dependency (config_other_module_config_test, config_test.dynamic.dotted.english)'); } + try { + $this->installModules(['config_install_double_dependency_test']); + $this->fail('Expected UnmetDependenciesException not thrown.'); + } + catch (UnmetDependenciesException $e) { + $this->assertEquals('config_install_double_dependency_test', $e->getExtension()); + $this->assertEquals(['config_test.dynamic.other_module_test_with_dependency' => ['config_other_module_config_test', 'config_test.dynamic.dotted.english']], $e->getConfigObjects()); + $this->assertEquals('Configuration objects provided by config_install_double_dependency_test have unmet dependencies: config_test.dynamic.other_module_test_with_dependency (config_other_module_config_test, config_test.dynamic.dotted.english)', $e->getMessage()); + } $this->installModules(['config_test_language']); try { $this->installModules(['config_install_dependency_test']); diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigSchemaTest.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigSchemaTest.php index 1cd1414..fb3573e 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigSchemaTest.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigSchemaTest.php @@ -47,6 +47,7 @@ public function testSchemaMapping() { $expected['class'] = Undefined::class; $expected['type'] = 'undefined'; $expected['definition_class'] = '\Drupal\Core\TypedData\DataDefinition'; + $expected['unwrap_for_canonical_representation'] = TRUE; $this->assertEqual($definition, $expected, 'Retrieved the right metadata for nonexistent configuration.'); // Configuration file without schema will return Undefined as well. @@ -67,6 +68,7 @@ public function testSchemaMapping() { $expected['mapping']['testlist'] = ['label' => 'Test list']; $expected['type'] = 'config_schema_test.someschema'; $expected['definition_class'] = '\Drupal\Core\TypedData\MapDataDefinition'; + $expected['unwrap_for_canonical_representation'] = TRUE; $this->assertEqual($definition, $expected, 'Retrieved the right metadata for configuration with only some schema.'); // Check type detection on elements with undefined types. @@ -77,6 +79,7 @@ public function testSchemaMapping() { $expected['class'] = Undefined::class; $expected['type'] = 'undefined'; $expected['definition_class'] = '\Drupal\Core\TypedData\DataDefinition'; + $expected['unwrap_for_canonical_representation'] = TRUE; $this->assertEqual($definition, $expected, 'Automatic type detected for a scalar is undefined.'); $definition = $config->get('testlist')->getDataDefinition()->toArray(); $expected = []; @@ -84,6 +87,7 @@ public function testSchemaMapping() { $expected['class'] = Undefined::class; $expected['type'] = 'undefined'; $expected['definition_class'] = '\Drupal\Core\TypedData\DataDefinition'; + $expected['unwrap_for_canonical_representation'] = TRUE; $this->assertEqual($definition, $expected, 'Automatic type detected for a list is undefined.'); $definition = $config->get('testnoschema')->getDataDefinition()->toArray(); $expected = []; @@ -91,6 +95,7 @@ public function testSchemaMapping() { $expected['class'] = Undefined::class; $expected['type'] = 'undefined'; $expected['definition_class'] = '\Drupal\Core\TypedData\DataDefinition'; + $expected['unwrap_for_canonical_representation'] = TRUE; $this->assertEqual($definition, $expected, 'Automatic type detected for an undefined integer is undefined.'); // Simple case, straight metadata. @@ -109,6 +114,7 @@ public function testSchemaMapping() { $expected['mapping']['_core']['type'] = '_core_config_info'; $expected['type'] = 'system.maintenance'; $expected['definition_class'] = '\Drupal\Core\TypedData\MapDataDefinition'; + $expected['unwrap_for_canonical_representation'] = TRUE; $this->assertEqual($definition, $expected, 'Retrieved the right metadata for system.maintenance'); // Mixed schema with ignore elements. @@ -139,6 +145,7 @@ public function testSchemaMapping() { 'type' => 'integer', ]; $expected['type'] = 'config_schema_test.ignore'; + $expected['unwrap_for_canonical_representation'] = TRUE; $this->assertEqual($definition, $expected); @@ -149,6 +156,7 @@ public function testSchemaMapping() { $expected['label'] = 'Irrelevant'; $expected['class'] = Ignore::class; $expected['definition_class'] = '\Drupal\Core\TypedData\DataDefinition'; + $expected['unwrap_for_canonical_representation'] = TRUE; $this->assertEqual($definition, $expected); $definition = \Drupal::service('config.typed')->get('config_schema_test.ignore')->get('indescribable')->getDataDefinition()->toArray(); $expected['label'] = 'Indescribable'; @@ -160,6 +168,7 @@ public function testSchemaMapping() { $expected['label'] = 'Image style'; $expected['class'] = Mapping::class; $expected['definition_class'] = '\Drupal\Core\TypedData\MapDataDefinition'; + $expected['unwrap_for_canonical_representation'] = TRUE; $expected['mapping']['name']['type'] = 'string'; $expected['mapping']['uuid']['type'] = 'string'; $expected['mapping']['uuid']['label'] = 'UUID'; @@ -193,6 +202,7 @@ public function testSchemaMapping() { $expected['label'] = 'Image scale'; $expected['class'] = Mapping::class; $expected['definition_class'] = '\Drupal\Core\TypedData\MapDataDefinition'; + $expected['unwrap_for_canonical_representation'] = TRUE; $expected['mapping']['width']['type'] = 'integer'; $expected['mapping']['width']['label'] = 'Width'; $expected['mapping']['height']['type'] = 'integer'; @@ -220,6 +230,7 @@ public function testSchemaMapping() { $expected['label'] = 'Mapping'; $expected['class'] = Mapping::class; $expected['definition_class'] = '\Drupal\Core\TypedData\MapDataDefinition'; + $expected['unwrap_for_canonical_representation'] = TRUE; $expected['mapping'] = [ 'integer' => ['type' => 'integer'], 'string' => ['type' => 'string'], @@ -241,6 +252,7 @@ public function testSchemaMapping() { $expected['mapping']['testdescription']['label'] = 'Description'; $expected['type'] = 'config_schema_test.someschema.somemodule.*.*'; $expected['definition_class'] = '\Drupal\Core\TypedData\MapDataDefinition'; + $expected['unwrap_for_canonical_representation'] = TRUE; $this->assertEqual($definition, $expected, 'Retrieved the right metadata for config_schema_test.someschema.somemodule.section_one.subsection'); @@ -263,6 +275,7 @@ public function testSchemaMappingWithParents() { 'label' => 'Test item nested one level', 'class' => StringData::class, 'definition_class' => '\Drupal\Core\TypedData\DataDefinition', + 'unwrap_for_canonical_representation' => TRUE, ]; $this->assertEqual($definition, $expected); @@ -274,6 +287,7 @@ public function testSchemaMappingWithParents() { 'label' => 'Test item nested two levels', 'class' => StringData::class, 'definition_class' => '\Drupal\Core\TypedData\DataDefinition', + 'unwrap_for_canonical_representation' => TRUE, ]; $this->assertEqual($definition, $expected); @@ -285,6 +299,7 @@ public function testSchemaMappingWithParents() { 'label' => 'Test item nested three levels', 'class' => StringData::class, 'definition_class' => '\Drupal\Core\TypedData\DataDefinition', + 'unwrap_for_canonical_representation' => TRUE, ]; $this->assertEqual($definition, $expected); } @@ -396,6 +411,76 @@ public function testConfigSaveWithSchema() { } /** + * Tests configuration sequence sorting using schemas. + */ + public function testConfigSaveWithSequenceSorting() { + $data = [ + 'keyed_sort' => [ + 'b' => '1', + 'a' => '2', + ], + 'no_sort' => [ + 'b' => '2', + 'a' => '1', + ], + ]; + // Save config which has a schema that enforces sorting. + $this->config('config_schema_test.schema_sequence_sort') + ->setData($data) + ->save(); + $this->assertSame(['a' => '2', 'b' => '1'], $this->config('config_schema_test.schema_sequence_sort')->get('keyed_sort')); + $this->assertSame(['b' => '2', 'a' => '1'], $this->config('config_schema_test.schema_sequence_sort')->get('no_sort')); + + $data = [ + 'value_sort' => ['b', 'a'], + 'no_sort' => ['b', 'a'], + ]; + // Save config which has a schema that enforces sorting. + $this->config('config_schema_test.schema_sequence_sort') + ->setData($data) + ->save(); + + $this->assertSame(['a', 'b'], $this->config('config_schema_test.schema_sequence_sort')->get('value_sort')); + $this->assertSame(['b', 'a'], $this->config('config_schema_test.schema_sequence_sort')->get('no_sort')); + + // Value sort does not preserve keys - this is intentional. + $data = [ + 'value_sort' => [1 => 'b', 2 => 'a'], + 'no_sort' => [1 => 'b', 2 => 'a'], + ]; + // Save config which has a schema that enforces sorting. + $this->config('config_schema_test.schema_sequence_sort') + ->setData($data) + ->save(); + + $this->assertSame(['a', 'b'], $this->config('config_schema_test.schema_sequence_sort')->get('value_sort')); + $this->assertSame([1 => 'b', 2 => 'a'], $this->config('config_schema_test.schema_sequence_sort')->get('no_sort')); + + // Test sorts do not destroy complex values. + $data = [ + 'complex_sort_value' => [['foo' => 'b', 'bar' => 'b'] , ['foo' => 'a', 'bar' => 'a']], + 'complex_sort_key' => ['b' => ['foo' => '1', 'bar' => '1'] , 'a' => ['foo' => '2', 'bar' => '2']], + ]; + $this->config('config_schema_test.schema_sequence_sort') + ->setData($data) + ->save(); + $this->assertSame([['foo' => 'a', 'bar' => 'a'], ['foo' => 'b', 'bar' => 'b']], $this->config('config_schema_test.schema_sequence_sort')->get('complex_sort_value')); + $this->assertSame(['a' => ['foo' => '2', 'bar' => '2'], 'b' => ['foo' => '1', 'bar' => '1']], $this->config('config_schema_test.schema_sequence_sort')->get('complex_sort_key')); + + // Swap the previous test scenario around. + $data = [ + 'complex_sort_value' => ['b' => ['foo' => '1', 'bar' => '1'] , 'a' => ['foo' => '2', 'bar' => '2']], + 'complex_sort_key' => [['foo' => 'b', 'bar' => 'b'] , ['foo' => 'a', 'bar' => 'a']], + ]; + $this->config('config_schema_test.schema_sequence_sort') + ->setData($data) + ->save(); + $this->assertSame([['foo' => '1', 'bar' => '1'], ['foo' => '2', 'bar' => '2']], $this->config('config_schema_test.schema_sequence_sort')->get('complex_sort_value')); + $this->assertSame([['foo' => 'b', 'bar' => 'b'], ['foo' => 'a', 'bar' => 'a']], $this->config('config_schema_test.schema_sequence_sort')->get('complex_sort_key')); + + } + + /** * Tests fallback to a greedy wildcard. */ public function testSchemaFallback() { @@ -405,6 +490,7 @@ public function testSchemaFallback() { $expected['label'] = 'Schema wildcard fallback test'; $expected['class'] = Mapping::class; $expected['definition_class'] = '\Drupal\Core\TypedData\MapDataDefinition'; + $expected['unwrap_for_canonical_representation'] = TRUE; $expected['mapping']['langcode']['type'] = 'string'; $expected['mapping']['langcode']['label'] = 'Language code'; $expected['mapping']['_core']['type'] = '_core_config_info'; diff --git a/core/tests/Drupal/KernelTests/Core/Database/SelectComplexTest.php b/core/tests/Drupal/KernelTests/Core/Database/SelectComplexTest.php index 34ca74c..cd3ec4a 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/SelectComplexTest.php +++ b/core/tests/Drupal/KernelTests/Core/Database/SelectComplexTest.php @@ -3,6 +3,7 @@ namespace Drupal\KernelTests\Core\Database; use Drupal\Core\Database\Database; +use Drupal\Core\Database\Query\Condition; use Drupal\Core\Database\RowCountException; use Drupal\user\Entity\User; @@ -312,7 +313,7 @@ public function testNestedConditions() { $query = db_select('test'); $query->addField('test', 'job'); $query->condition('name', 'Paul'); - $query->condition(db_or()->condition('age', 26)->condition('age', 27)); + $query->condition((new Condition('OR'))->condition('age', 26)->condition('age', 27)); $job = $query->execute()->fetchField(); $this->assertEqual($job, 'Songwriter', 'Correct data retrieved.'); @@ -395,7 +396,7 @@ public function testSelectWithRowCount() { public function testJoinConditionObject() { // Same test as testDefaultJoin, but with a Condition object. $query = db_select('test_task', 't'); - $join_cond = db_and()->where('t.pid = p.id'); + $join_cond = (new Condition('AND'))->where('t.pid = p.id'); $people_alias = $query->join('test', 'p', $join_cond); $name_field = $query->addField($people_alias, 'name', 'name'); $query->addField('t', 'task', 'task'); @@ -418,7 +419,7 @@ public function testJoinConditionObject() { // Test a condition object that creates placeholders. $t1_name = 'John'; $t2_name = 'George'; - $join_cond = db_and() + $join_cond = (new Condition('AND')) ->condition('t1.name', $t1_name) ->condition('t2.name', $t2_name); $query = db_select('test', 't1'); diff --git a/core/tests/Drupal/KernelTests/Core/Database/UpdateComplexTest.php b/core/tests/Drupal/KernelTests/Core/Database/UpdateComplexTest.php index 3920826..69d5d5d 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/UpdateComplexTest.php +++ b/core/tests/Drupal/KernelTests/Core/Database/UpdateComplexTest.php @@ -2,6 +2,8 @@ namespace Drupal\KernelTests\Core\Database; +use Drupal\Core\Database\Query\Condition; + /** * Tests the Update query builder, complex queries. * @@ -15,7 +17,7 @@ class UpdateComplexTest extends DatabaseTestBase { public function testOrConditionUpdate() { $update = db_update('test') ->fields(['job' => 'Musician']) - ->condition(db_or() + ->condition((new Condition('OR')) ->condition('name', 'John') ->condition('name', 'Paul') ); diff --git a/core/tests/Drupal/KernelTests/Core/DrupalKernel/DrupalKernelTest.php b/core/tests/Drupal/KernelTests/Core/DrupalKernel/DrupalKernelTest.php index 7370491..722bdd2 100644 --- a/core/tests/Drupal/KernelTests/Core/DrupalKernel/DrupalKernelTest.php +++ b/core/tests/Drupal/KernelTests/Core/DrupalKernel/DrupalKernelTest.php @@ -184,6 +184,11 @@ public function testPreventChangeOfSitePath() { $pass = TRUE; } $this->assertTrue($pass, 'Throws LogicException if DrupalKernel::setSitePath() is called after boot'); + + // Ensure no LogicException if DrupalKernel::setSitePath() is called with + // identical path after boot. + $path = $kernel->getSitePath(); + $kernel->setSitePath($path); } } diff --git a/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityCloneTest.php b/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityCloneTest.php index 245a333..0f77535 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityCloneTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityCloneTest.php @@ -251,4 +251,87 @@ public function testFieldValuesAfterSerialize() { $this->assertEquals('clone', $clone->getName()); } + /** + * Tests changing the default revision flag. + */ + public function testDefaultRevision() { + // Create a test entity with a translation, which will internally trigger + // entity cloning for the new translation and create references for some of + // the entity properties. + $entity = EntityTestMulRev::create([ + 'name' => 'original', + 'language' => 'en', + ]); + $entity->addTranslation('de'); + $entity->save(); + + // Assert that the entity is in the default revision. + $this->assertTrue($entity->isDefaultRevision()); + + // Clone the entity and modify its default revision flag. + $clone = clone $entity; + $clone->isDefaultRevision(FALSE); + + // Assert that the clone is not in default revision, but the original entity + // is still in the default revision. + $this->assertFalse($clone->isDefaultRevision()); + $this->assertTrue($entity->isDefaultRevision()); + } + + /** + * Tests references of entity properties after entity cloning. + */ + public function testEntityPropertiesModifications() { + // Create a test entity with a translation, which will internally trigger + // entity cloning for the new translation and create references for some of + // the entity properties. + $entity = EntityTestMulRev::create([ + 'name' => 'original', + 'language' => 'en', + ]); + $translation = $entity->addTranslation('de'); + $entity->save(); + + // Clone the entity. + $clone = clone $entity; + + // Retrieve the entity properties. + $reflection = new \ReflectionClass($entity); + $properties = $reflection->getProperties(~\ReflectionProperty::IS_STATIC); + $translation_unique_properties = ['activeLangcode', 'translationInitialize', 'fieldDefinitions', 'languages', 'langcodeKey', 'defaultLangcode', 'defaultLangcodeKey', 'validated', 'validationRequired', 'entityTypeId', 'typedData', 'cacheContexts', 'cacheTags', 'cacheMaxAge', '_serviceIds']; + + foreach ($properties as $property) { + // Modify each entity property on the clone and assert that the change is + // not propagated to the original entity. + $property->setAccessible(TRUE); + $property->setValue($entity, 'default-value'); + $property->setValue($translation, 'default-value'); + $property->setValue($clone, 'test-entity-cloning'); + $this->assertEquals('default-value', $property->getValue($entity), (string) new FormattableMarkup('Entity property %property_name is not cloned properly.', ['%property_name' => $property->getName()])); + $this->assertEquals('default-value', $property->getValue($translation), (string) new FormattableMarkup('Entity property %property_name is not cloned properly.', ['%property_name' => $property->getName()])); + $this->assertEquals('test-entity-cloning', $property->getValue($clone), (string) new FormattableMarkup('Entity property %property_name is not cloned properly.', ['%property_name' => $property->getName()])); + + // Modify each entity property on the translation entity object and assert + // that the change is propagated to the default translation entity object + // except for the properties that are unique for each entity translation + // object. + $property->setValue($translation, 'test-translation-cloning'); + // Using assertEquals or assertNotEquals here is dangerous as if the + // assertion fails and the property for some reasons contains the entity + // object e.g. the "typedData" property then the property will be + // serialized, but this will cause exceptions because the entity is + // modified in a non-consistent way and ContentEntityBase::__sleep() will + // not be able to properly access all properties and this will cause + // exceptions without a proper backtrace. + if (in_array($property->getName(), $translation_unique_properties)) { + $this->assertEquals('default-value', $property->getValue($entity), (string) new FormattableMarkup('Entity property %property_name is not cloned properly.', ['%property_name' => $property->getName()])); + $this->assertEquals('test-translation-cloning', $property->getValue($translation), (string) new FormattableMarkup('Entity property %property_name is not cloned properly.', ['%property_name' => $property->getName()])); + } + else { + $this->assertEquals('test-translation-cloning', $property->getValue($entity), (string) new FormattableMarkup('Entity property %property_name is not cloned properly.', ['%property_name' => $property->getName()])); + $this->assertEquals('test-translation-cloning', $property->getValue($translation), (string) new FormattableMarkup('Entity property %property_name is not cloned properly.', ['%property_name' => $property->getName()])); + } + } + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityRevisionTranslationTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityRevisionTranslationTest.php index 676a98a..5c0ef7b 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityRevisionTranslationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityRevisionTranslationTest.php @@ -132,4 +132,29 @@ public function testTranslationValuesWhenSavingForwardRevisions() { $this->assertEquals($forward_revision->getTranslation('de')->name->value, 'forward revision - de'); } + /** + * Tests changing the default revision flag is propagated to all translations. + */ + public function testDefaultRevision() { + // Create a test entity with a translation, which will internally trigger + // entity cloning for the new translation and create references for some of + // the entity properties. + $entity = EntityTestMulRev::create([ + 'name' => 'original', + 'language' => 'en', + ]); + $translation = $entity->addTranslation('de'); + $entity->save(); + + // Assert that the entity is in the default revision. + $this->assertTrue($entity->isDefaultRevision()); + $this->assertTrue($translation->isDefaultRevision()); + + // Change the default revision flag on one of the entity translations and + // assert that the change is propagated to all entity translation objects. + $translation->isDefaultRevision(FALSE); + $this->assertFalse($entity->isDefaultRevision()); + $this->assertFalse($translation->isDefaultRevision()); + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityTypedDataDefinitionTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityTypedDataDefinitionTest.php index 9d5095d..5e755d9 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityTypedDataDefinitionTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityTypedDataDefinitionTest.php @@ -101,8 +101,8 @@ public function testEntities() { // Test that the definition factory creates the right definitions for all // entity data types variants. - $this->assertEqual($this->typedDataManager->createDataDefinition('entity'), EntityDataDefinition::create()); - $this->assertEqual($this->typedDataManager->createDataDefinition('entity:node'), EntityDataDefinition::create('node')); + $this->assertEqual(serialize($this->typedDataManager->createDataDefinition('entity')), serialize(EntityDataDefinition::create())); + $this->assertEqual(serialize($this->typedDataManager->createDataDefinition('entity:node')), serialize(EntityDataDefinition::create('node'))); // Config entities don't support typed data. $entity_definition = EntityDataDefinition::create('node_type'); @@ -123,7 +123,7 @@ public function testEntityReferences() { // Test that the definition factory creates the right definition object. $reference_definition2 = $this->typedDataManager->createDataDefinition('entity_reference'); $this->assertTrue($reference_definition2 instanceof DataReferenceDefinitionInterface); - $this->assertEqual($reference_definition2, $reference_definition); + $this->assertEqual(serialize($reference_definition2), serialize($reference_definition)); } } diff --git a/core/tests/Drupal/KernelTests/Core/Entity/RevisionableContentEntityBaseTest.php b/core/tests/Drupal/KernelTests/Core/Entity/RevisionableContentEntityBaseTest.php index 1fd873c..b78a588 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/RevisionableContentEntityBaseTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/RevisionableContentEntityBaseTest.php @@ -2,7 +2,7 @@ namespace Drupal\KernelTests\Core\Entity; -use Drupal\entity_test\Entity\EntityTestWithRevisionLog; +use Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog; use Drupal\KernelTests\KernelTestBase; use Drupal\user\Entity\User; @@ -15,7 +15,7 @@ class RevisionableContentEntityBaseTest extends KernelTestBase { /** * {@inheritdoc} */ - public static $modules = ['entity_test', 'system', 'user']; + public static $modules = ['entity_test_revlog', 'system', 'user']; /** * {@inheritdoc} @@ -31,7 +31,7 @@ protected function setUp() { public function testRevisionableContentEntity() { $user = User::create(['name' => 'test name']); $user->save(); - /** @var \Drupal\entity_test\Entity\EntityTestWithRevisionLog $entity */ + /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */ $entity = EntityTestWithRevisionLog::create([ 'type' => 'entity_test_revlog', ]); diff --git a/core/tests/Drupal/KernelTests/Core/Extension/ProfileHandlerTest.php b/core/tests/Drupal/KernelTests/Core/Extension/ProfileHandlerTest.php new file mode 100644 index 0000000..ebb20f6 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Extension/ProfileHandlerTest.php @@ -0,0 +1,117 @@ +container->get('profile_handler'); + $info = $profile_handler->getProfileInfo('testing_inherited'); + $this->assertNotEmpty($info); + $this->assertEquals($info['name'], 'Testing Inherited'); + $this->assertEquals($info['base profile']['name'], 'testing'); + $this->assertEquals($info['base profile']['excluded_dependencies'], ['page_cache']); + $this->assertTrue(in_array('config', $info['dependencies'], 'config should be found in dependencies')); + $this->assertFalse(in_array('page_cache', $info['dependencies'], 'page_cache should not be found in dependencies')); + $this->assertTrue($info['hidden'], 'Profiles should be hidden'); + $this->assertNotEmpty($info['profile_list']); + $profile_list = $info['profile_list']; + // Testing order of profile list. + $this->assertEquals($profile_list, [ + 'testing' => 'testing', + 'testing_inherited' => 'testing_inherited' + ]); + + // Test that profiles without any base return normalized info. + $info = $profile_handler->getProfileInfo('minimal'); + $this->assertInternalType('array', $info['base profile']); + + $this->assertArrayHasKey('name', $info['base profile']); + $this->assertEmpty($info['base profile']['name']); + + $this->assertArrayHasKey('excluded_dependencies', $info['base profile']); + $this->assertInternalType('array', $info['base profile']['excluded_dependencies']); + $this->assertEmpty($info['base profile']['excluded_dependencies']); + + $this->assertArrayHasKey('excluded_themes', $info['base profile']); + $this->assertInternalType('array', $info['base profile']['excluded_themes']); + $this->assertEmpty($info['base profile']['excluded_themes']); + } + + /** + * Tests getting profile dependency list. + * + * @covers ::getProfiles + */ + public function testGetProfiles() { + $profile_handler = $this->container->get('profile_handler'); + $profiles = $profile_handler->getProfiles('testing_inherited'); + $this->assertCount(2, $profiles); + + $first_profile = current($profiles); + $this->assertEquals(get_class($first_profile), 'Drupal\Core\Extension\Extension'); + $this->assertEquals($first_profile->getName(), 'testing'); + $this->assertEquals($first_profile->weight, 1000); + $this->assertObjectHasAttribute('origin', $first_profile); + + $second_profile = next($profiles); + $this->assertEquals(get_class($second_profile), 'Drupal\Core\Extension\Extension'); + $this->assertEquals($second_profile->getName(), 'testing_inherited'); + $this->assertEquals($second_profile->weight, 1001); + $this->assertObjectHasAttribute('origin', $second_profile); + } + + /** + * @covers ::selectDistribution + * @covers ::setProfileInfo + */ + public function testSelectDistribution() { + /** @var \Drupal\Core\Extension\ProfileHandler $profile_handler */ + $profile_handler = $this->container->get('profile_handler'); + $profiles = ['testing', 'testing_inherited']; + $base_info = $profile_handler->getProfileInfo('minimal'); + $profile_info = $profile_handler->getProfileInfo('testing_inherited'); + + // Neither profile has distribution set + $distribution = $profile_handler->selectDistribution($profiles); + $this->assertEmpty($distribution, 'No distribution should be selected'); + + // Set base profile distribution + $base_info['distribution']['name'] = 'Minimal'; + $profile_handler->setProfileInfo('minimal', $base_info); + // Base profile distribution should not be selected + $distribution = $profile_handler->selectDistribution($profiles); + $this->assertEmpty($distribution, 'Base profile distribution should not be selected'); + + // Set main profile distribution + $profile_info['distribution']['name'] = 'Testing Inherited'; + $profile_handler->setProfileInfo('testing_inherited', $profile_info); + // Main profile distribution should be selected + $distribution = $profile_handler->selectDistribution($profiles); + $this->assertEquals($distribution, 'testing_inherited'); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Form/FormValidationMessageOrderTest.php b/core/tests/Drupal/KernelTests/Core/Form/FormValidationMessageOrderTest.php new file mode 100644 index 0000000..1d20340 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Form/FormValidationMessageOrderTest.php @@ -0,0 +1,92 @@ + 'textfield', + '#title' => 'One', + '#required' => TRUE, + '#weight' => 40, + ]; + $form['two'] = [ + '#type' => 'textfield', + '#title' => 'Two', + '#required' => TRUE, + '#weight' => 30, + ]; + $form['three'] = [ + '#type' => 'textfield', + '#title' => 'Three', + '#required' => TRUE, + '#weight' => 10, + ]; + $form['four'] = [ + '#type' => 'textfield', + '#title' => 'Four', + '#required' => TRUE, + '#weight' => 20, + ]; + $form['actions'] = [ + '#type' => 'actions', + 'submit' => [ + '#type' => 'submit', + '#value' => 'Submit', + ], + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + } + + /** + * Tests that fields validation messages are sorted in the fields order. + */ + public function testLimitValidationErrors() { + $form_state = new FormState(); + $form_builder = $this->container->get('form_builder'); + $form_builder->submitForm($this, $form_state); + + $messages = drupal_get_messages(); + $this->assertTrue(isset($messages['error'])); + $error_messages = $messages['error']; + $this->assertEqual($error_messages[0], 'Three field is required.'); + $this->assertEqual($error_messages[1], 'Four field is required.'); + $this->assertEqual($error_messages[2], 'Two field is required.'); + $this->assertEqual($error_messages[3], 'One field is required.'); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Routing/ExceptionHandlingTest.php b/core/tests/Drupal/KernelTests/Core/Routing/ExceptionHandlingTest.php index 8564948..a791454 100644 --- a/core/tests/Drupal/KernelTests/Core/Routing/ExceptionHandlingTest.php +++ b/core/tests/Drupal/KernelTests/Core/Routing/ExceptionHandlingTest.php @@ -155,7 +155,7 @@ public function testBacktraceEscaping() { $kernel = \Drupal::getContainer()->get('http_kernel'); $response = $kernel->handle($request)->prepare($request); $this->assertEqual($response->getStatusCode(), Response::HTTP_INTERNAL_SERVER_ERROR); - $this->assertEqual($response->headers->get('Content-type'), 'text/html; charset=UTF-8'); + $this->assertEqual($response->headers->get('Content-type'), 'text/plain; charset=UTF-8'); // Test both that the backtrace is properly escaped, and that the unescaped // string is not output at all. @@ -178,7 +178,7 @@ public function testExceptionEscaping() { $kernel = \Drupal::getContainer()->get('http_kernel'); $response = $kernel->handle($request)->prepare($request); $this->assertEqual($response->getStatusCode(), Response::HTTP_INTERNAL_SERVER_ERROR); - $this->assertEqual($response->headers->get('Content-type'), 'text/html; charset=UTF-8'); + $this->assertEqual($response->headers->get('Content-type'), 'text/plain; charset=UTF-8'); // Test message is properly escaped, and that the unescaped string is not // output at all. @@ -192,10 +192,11 @@ public function testExceptionEscaping() { $kernel = \Drupal::getContainer()->get('http_kernel'); $response = $kernel->handle($request)->prepare($request); // As the Content-type is text/plain the fact that the raw string is - // contained in the output does not matter. + // contained in the output would not matter, but because it is output by the + // final exception subscriber, it is printed as partial HTML, and hence + // escaped. $this->assertEqual($response->headers->get('Content-type'), 'text/plain; charset=UTF-8'); - $this->setRawContent($response->getContent()); - $this->assertRaw($string); + $this->assertStringStartsWith('The website encountered an unexpected error. Please try again later.

Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException: Not acceptable format: json<script>alert(123);</script> in ', $response->getContent()); } } diff --git a/core/tests/Drupal/KernelTests/Core/TypedData/TypedDataDefinitionTest.php b/core/tests/Drupal/KernelTests/Core/TypedData/TypedDataDefinitionTest.php index 8aeab57..ad266c2 100644 --- a/core/tests/Drupal/KernelTests/Core/TypedData/TypedDataDefinitionTest.php +++ b/core/tests/Drupal/KernelTests/Core/TypedData/TypedDataDefinitionTest.php @@ -77,7 +77,7 @@ public function testMaps() { $map_definition2->setPropertyDefinition('one', DataDefinition::create('string')) ->setPropertyDefinition('two', DataDefinition::create('string')) ->setPropertyDefinition('three', DataDefinition::create('string')); - $this->assertEqual($map_definition, $map_definition2); + $this->assertEqual(serialize($map_definition), serialize($map_definition2)); } /** @@ -93,7 +93,7 @@ public function testDataReferences() { // Test using the definition factory. $language_reference_definition2 = $this->typedDataManager->createDataDefinition('language_reference'); $this->assertTrue($language_reference_definition2 instanceof DataReferenceDefinitionInterface); - $this->assertEqual($language_reference_definition, $language_reference_definition2); + $this->assertEqual(serialize($language_reference_definition), serialize($language_reference_definition2)); } } diff --git a/core/tests/Drupal/KernelTests/KernelTestBase.php b/core/tests/Drupal/KernelTests/KernelTestBase.php index 04926e0..7bbb774 100644 --- a/core/tests/Drupal/KernelTests/KernelTestBase.php +++ b/core/tests/Drupal/KernelTests/KernelTestBase.php @@ -22,6 +22,7 @@ use Drupal\Tests\ConfigTestTrait; use Drupal\Tests\RandomGeneratorTrait; use Drupal\simpletest\TestServiceProvider; +use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpFoundation\Request; use org\bovigo\vfs\vfsStream; @@ -49,7 +50,7 @@ * @todo Extend ::setRequirementsFromAnnotation() and ::checkRequirements() to * account for '@requires module'. */ -abstract class KernelTestBase extends \PHPUnit_Framework_TestCase implements ServiceProviderInterface { +abstract class KernelTestBase extends TestCase implements ServiceProviderInterface { use AssertLegacyTrait; use AssertContentTrait; diff --git a/core/tests/Drupal/Tests/BrowserTestBase.php b/core/tests/Drupal/Tests/BrowserTestBase.php index 754f9d9..5751368 100644 --- a/core/tests/Drupal/Tests/BrowserTestBase.php +++ b/core/tests/Drupal/Tests/BrowserTestBase.php @@ -27,6 +27,7 @@ use Drupal\simpletest\BlockCreationTrait; use Drupal\simpletest\NodeCreationTrait; use Drupal\simpletest\UserCreationTrait; +use PHPUnit\Framework\TestCase; use Symfony\Component\CssSelector\CssSelectorConverter; use Symfony\Component\HttpFoundation\Request; use Psr\Http\Message\RequestInterface; @@ -41,7 +42,7 @@ * * @ingroup testing */ -abstract class BrowserTestBase extends \PHPUnit_Framework_TestCase { +abstract class BrowserTestBase extends TestCase { use FunctionalTestSetupTrait; use TestSetupTrait; diff --git a/core/tests/Drupal/Tests/Component/Assertion/InspectorTest.php b/core/tests/Drupal/Tests/Component/Assertion/InspectorTest.php index 1299cd6..208440b 100644 --- a/core/tests/Drupal/Tests/Component/Assertion/InspectorTest.php +++ b/core/tests/Drupal/Tests/Component/Assertion/InspectorTest.php @@ -7,14 +7,14 @@ namespace Drupal\Tests\Component\Assertion; -use PHPUnit_Framework_TestCase; +use PHPUnit\Framework\TestCase; use Drupal\Component\Assertion\Inspector; /** * @coversDefaultClass \Drupal\Component\Assertion\Inspector * @group Assertion */ -class InspectorTest extends PHPUnit_Framework_TestCase { +class InspectorTest extends TestCase { /** * Tests asserting argument is an array or traversable object. diff --git a/core/tests/Drupal/Tests/Component/DependencyInjection/ContainerTest.php b/core/tests/Drupal/Tests/Component/DependencyInjection/ContainerTest.php index 6cd5575..cec466b 100644 --- a/core/tests/Drupal/Tests/Component/DependencyInjection/ContainerTest.php +++ b/core/tests/Drupal/Tests/Component/DependencyInjection/ContainerTest.php @@ -8,6 +8,7 @@ namespace Drupal\Tests\Component\DependencyInjection; use Drupal\Component\Utility\Crypt; +use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Exception\LogicException; @@ -21,7 +22,7 @@ * @coversDefaultClass \Drupal\Component\DependencyInjection\Container * @group DependencyInjection */ -class ContainerTest extends \PHPUnit_Framework_TestCase { +class ContainerTest extends TestCase { /** * The tested container. diff --git a/core/tests/Drupal/Tests/Component/DependencyInjection/Dumper/OptimizedPhpArrayDumperTest.php b/core/tests/Drupal/Tests/Component/DependencyInjection/Dumper/OptimizedPhpArrayDumperTest.php index 9da4c27..0a5d58b 100644 --- a/core/tests/Drupal/Tests/Component/DependencyInjection/Dumper/OptimizedPhpArrayDumperTest.php +++ b/core/tests/Drupal/Tests/Component/DependencyInjection/Dumper/OptimizedPhpArrayDumperTest.php @@ -8,6 +8,7 @@ namespace Drupal\Tests\Component\DependencyInjection\Dumper { use Drupal\Component\Utility\Crypt; + use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\Parameter; @@ -21,7 +22,7 @@ * @coversDefaultClass \Drupal\Component\DependencyInjection\Dumper\OptimizedPhpArrayDumper * @group DependencyInjection */ - class OptimizedPhpArrayDumperTest extends \PHPUnit_Framework_TestCase { + class OptimizedPhpArrayDumperTest extends TestCase { /** * The container builder instance. diff --git a/core/tests/Drupal/Tests/Component/Diff/DiffFormatterTest.php b/core/tests/Drupal/Tests/Component/Diff/DiffFormatterTest.php index 70fb0dc..c5fab90 100644 --- a/core/tests/Drupal/Tests/Component/Diff/DiffFormatterTest.php +++ b/core/tests/Drupal/Tests/Component/Diff/DiffFormatterTest.php @@ -4,6 +4,7 @@ use Drupal\Component\Diff\Diff; use Drupal\Component\Diff\DiffFormatter; +use PHPUnit\Framework\TestCase; /** * Test DiffFormatter classes. @@ -12,7 +13,7 @@ * * @group Diff */ -class DiffFormatterTest extends \PHPUnit_Framework_TestCase { +class DiffFormatterTest extends TestCase { /** * @return array diff --git a/core/tests/Drupal/Tests/Component/Diff/Engine/DiffEngineTest.php b/core/tests/Drupal/Tests/Component/Diff/Engine/DiffEngineTest.php index ade1527..69f92da 100644 --- a/core/tests/Drupal/Tests/Component/Diff/Engine/DiffEngineTest.php +++ b/core/tests/Drupal/Tests/Component/Diff/Engine/DiffEngineTest.php @@ -7,6 +7,7 @@ use Drupal\Component\Diff\Engine\DiffOpCopy; use Drupal\Component\Diff\Engine\DiffOpChange; use Drupal\Component\Diff\Engine\DiffOpDelete; +use PHPUnit\Framework\TestCase; /** * Test DiffEngine class. @@ -15,7 +16,7 @@ * * @group Diff */ -class DiffEngineTest extends \PHPUnit_Framework_TestCase { +class DiffEngineTest extends TestCase { /** * @return array diff --git a/core/tests/Drupal/Tests/Component/Diff/Engine/DiffOpTest.php b/core/tests/Drupal/Tests/Component/Diff/Engine/DiffOpTest.php index 4275bb7..1a649ae 100644 --- a/core/tests/Drupal/Tests/Component/Diff/Engine/DiffOpTest.php +++ b/core/tests/Drupal/Tests/Component/Diff/Engine/DiffOpTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\Component\Diff\Engine; use Drupal\Component\Diff\Engine\DiffOp; +use PHPUnit\Framework\TestCase; /** * Test DiffOp base class. @@ -15,7 +16,7 @@ * * @group Diff */ -class DiffOpTest extends \PHPUnit_Framework_TestCase { +class DiffOpTest extends TestCase { /** * DiffOp::reverse() always throws an error. diff --git a/core/tests/Drupal/Tests/Component/Diff/Engine/HWLDFWordAccumulatorTest.php b/core/tests/Drupal/Tests/Component/Diff/Engine/HWLDFWordAccumulatorTest.php index ccf468d..194f37f 100644 --- a/core/tests/Drupal/Tests/Component/Diff/Engine/HWLDFWordAccumulatorTest.php +++ b/core/tests/Drupal/Tests/Component/Diff/Engine/HWLDFWordAccumulatorTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\Component\Diff\Engine; use Drupal\Component\Diff\Engine\HWLDFWordAccumulator; +use PHPUnit\Framework\TestCase; /** * Test HWLDFWordAccumulator. @@ -11,7 +12,7 @@ * * @group Diff */ -class HWLDFWordAccumulatorTest extends \PHPUnit_Framework_TestCase { +class HWLDFWordAccumulatorTest extends TestCase { /** * Verify that we only get back a NBSP from an empty accumulator. diff --git a/core/tests/Drupal/Tests/Component/Serialization/YamlTestBase.php b/core/tests/Drupal/Tests/Component/Serialization/YamlTestBase.php index d2b504e..2f8f23e 100644 --- a/core/tests/Drupal/Tests/Component/Serialization/YamlTestBase.php +++ b/core/tests/Drupal/Tests/Component/Serialization/YamlTestBase.php @@ -2,10 +2,12 @@ namespace Drupal\Tests\Component\Serialization; +use PHPUnit\Framework\TestCase; + /** * Provides standard data to validate different YAML implementations. */ -abstract class YamlTestBase extends \PHPUnit_Framework_TestCase { +abstract class YamlTestBase extends TestCase { /** * Some data that should be able to be serialized. diff --git a/core/tests/Drupal/Tests/Core/Cache/Context/SessionCacheContextTest.php b/core/tests/Drupal/Tests/Core/Cache/Context/SessionCacheContextTest.php index b621b2d..9538bd2 100644 --- a/core/tests/Drupal/Tests/Core/Cache/Context/SessionCacheContextTest.php +++ b/core/tests/Drupal/Tests/Core/Cache/Context/SessionCacheContextTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\Core\Cache\Context; use Drupal\Core\Cache\Context\SessionCacheContext; +use Drupal\Tests\UnitTestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -10,7 +11,7 @@ * @coversDefaultClass \Drupal\Core\Cache\Context\SessionCacheContext * @group Cache */ -class SessionCacheContextTest extends \PHPUnit_Framework_TestCase { +class SessionCacheContextTest extends UnitTestCase { /** * The request stack. diff --git a/core/tests/Drupal/Tests/Core/Config/ConfigFactoryOverrideBaseTest.php b/core/tests/Drupal/Tests/Core/Config/ConfigFactoryOverrideBaseTest.php index 7386ac9..f81352f 100644 --- a/core/tests/Drupal/Tests/Core/Config/ConfigFactoryOverrideBaseTest.php +++ b/core/tests/Drupal/Tests/Core/Config/ConfigFactoryOverrideBaseTest.php @@ -6,12 +6,13 @@ use Drupal\Core\Config\ConfigCrudEvent; use Drupal\Core\Config\ConfigFactoryOverrideBase; use Drupal\Core\Config\ConfigRenameEvent; +use Drupal\Tests\UnitTestCase; /** * @coversDefaultClass \Drupal\Core\Config\ConfigFactoryOverrideBase * @group config */ -class ConfigFactoryOverrideBaseTest extends \PHPUnit_Framework_TestCase { +class ConfigFactoryOverrideBaseTest extends UnitTestCase { /** * @dataProvider providerTestFilterNestedArray diff --git a/core/tests/Drupal/Tests/Core/DependencyInjection/Compiler/AuthenticationProviderPassTest.php b/core/tests/Drupal/Tests/Core/DependencyInjection/Compiler/AuthenticationProviderPassTest.php index 6f99d09..9fe058a 100644 --- a/core/tests/Drupal/Tests/Core/DependencyInjection/Compiler/AuthenticationProviderPassTest.php +++ b/core/tests/Drupal/Tests/Core/DependencyInjection/Compiler/AuthenticationProviderPassTest.php @@ -4,6 +4,7 @@ use Drupal\Core\DependencyInjection\Compiler\AuthenticationProviderPass; use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Tests\UnitTestCase; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\Serializer\Serializer; @@ -11,7 +12,7 @@ * @coversDefaultClass \Drupal\Core\DependencyInjection\Compiler\AuthenticationProviderPass * @group DependencyInjection */ -class AuthenticationProviderPassTest extends \PHPUnit_Framework_TestCase { +class AuthenticationProviderPassTest extends UnitTestCase { /** * @covers ::process diff --git a/core/tests/Drupal/Tests/Core/DependencyInjection/YamlFileLoaderTest.php b/core/tests/Drupal/Tests/Core/DependencyInjection/YamlFileLoaderTest.php index 1a9dc66..97b2c94 100644 --- a/core/tests/Drupal/Tests/Core/DependencyInjection/YamlFileLoaderTest.php +++ b/core/tests/Drupal/Tests/Core/DependencyInjection/YamlFileLoaderTest.php @@ -5,13 +5,14 @@ use Drupal\Component\FileCache\FileCacheFactory; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\DependencyInjection\YamlFileLoader; +use Drupal\Tests\UnitTestCase; use org\bovigo\vfs\vfsStream; /** * @coversDefaultClass \Drupal\Core\DependencyInjection\YamlFileLoader * @group DependencyInjection */ -class YamlFileLoaderTest extends \PHPUnit_Framework_TestCase { +class YamlFileLoaderTest extends UnitTestCase { /** * {@inheritdoc} diff --git a/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageTest.php b/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageTest.php index 27d5154..b3e339b 100644 --- a/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageTest.php +++ b/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageTest.php @@ -534,6 +534,9 @@ public function testGetTableMappingRevisionable(array $entity_keys) { ['bundle', $entity_keys['bundle']], ['revision', $entity_keys['revision']], ])); + $this->entityType->expects($this->any()) + ->method('getRevisionMetadataKeys') + ->will($this->returnValue([])); $this->setUpEntityStorage(); @@ -574,13 +577,13 @@ public function testGetTableMappingRevisionableWithFields(array $entity_keys) { // PHPUnit does not allow for multiple data providers. $test_cases = [ [], - ['revision_timestamp'], - ['revision_uid'], - ['revision_log'], - ['revision_timestamp', 'revision_uid'], - ['revision_timestamp', 'revision_log'], - ['revision_uid', 'revision_log'], - ['revision_timestamp', 'revision_uid', 'revision_log'], + ['revision_created' => 'revision_timestamp'], + ['revision_user' => 'revision_uid'], + ['revision_log_message' => 'revision_log'], + ['revision_created' => 'revision_timestamp', 'revision_user' => 'revision_uid'], + ['revision_created' => 'revision_timestamp', 'revision_log_message' => 'revision_log'], + ['revision_user' => 'revision_uid', 'revision_log_message' => 'revision_log'], + ['revision_created' => 'revision_timestamp', 'revision_user' => 'revision_uid', 'revision_log_message' => 'revision_log'], ]; foreach ($test_cases as $revision_metadata_field_names) { $this->setUp(); @@ -591,7 +594,7 @@ public function testGetTableMappingRevisionableWithFields(array $entity_keys) { $revisionable_field_names = ['description', 'owner']; $field_names = array_merge($field_names, $revisionable_field_names); - $this->fieldDefinitions += $this->mockFieldDefinitions(array_merge($revisionable_field_names, $revision_metadata_field_names), ['isRevisionable' => TRUE]); + $this->fieldDefinitions += $this->mockFieldDefinitions(array_merge($revisionable_field_names, array_values($revision_metadata_field_names)), ['isRevisionable' => TRUE]); $this->entityType->expects($this->exactly(2)) ->method('isRevisionable') @@ -605,6 +608,10 @@ public function testGetTableMappingRevisionableWithFields(array $entity_keys) { ['revision', $entity_keys['revision']], ])); + $this->entityType->expects($this->any()) + ->method('getRevisionMetadataKeys') + ->will($this->returnValue($revision_metadata_field_names)); + $this->setUpEntityStorage(); $mapping = $this->entityStorage->getTableMapping(); @@ -616,7 +623,7 @@ public function testGetTableMappingRevisionableWithFields(array $entity_keys) { $expected = array_merge( [$entity_keys['id'], $entity_keys['revision']], $revisionable_field_names, - $revision_metadata_field_names + array_values($revision_metadata_field_names) ); $this->assertEquals($expected, $mapping->getFieldNames('entity_test_revision')); @@ -761,6 +768,11 @@ public function testGetTableMappingRevisionableTranslatable(array $entity_keys) 'uuid' => $entity_keys['uuid'], 'langcode' => 'langcode', ]; + $revision_metadata_keys = [ + 'revision_created' => 'revision_timestamp', + 'revision_user' => 'revision_uid', + 'revision_log_message' => 'revision_log' + ]; $this->entityType->expects($this->atLeastOnce()) ->method('isRevisionable') @@ -780,6 +792,9 @@ public function testGetTableMappingRevisionableTranslatable(array $entity_keys) ['revision', $entity_keys['revision']], ['langcode', $entity_keys['langcode']], ])); + $this->entityType->expects($this->any()) + ->method('getRevisionMetadataKeys') + ->will($this->returnValue($revision_metadata_keys)); $this->setUpEntityStorage(); @@ -810,6 +825,7 @@ public function testGetTableMappingRevisionableTranslatable(array $entity_keys) $entity_keys['revision'], $entity_keys['langcode'], ])); + $expected = array_merge($expected, array_values($revision_metadata_keys)); $actual = $mapping->getFieldNames('entity_test_revision'); $this->assertEquals($expected, $actual); // The UUID is not stored on the data table. @@ -865,13 +881,13 @@ public function testGetTableMappingRevisionableTranslatableWithFields(array $ent // PHPUnit does not allow for multiple data providers. $test_cases = [ [], - ['revision_timestamp'], - ['revision_uid'], - ['revision_log'], - ['revision_timestamp', 'revision_uid'], - ['revision_timestamp', 'revision_log'], - ['revision_uid', 'revision_log'], - ['revision_timestamp', 'revision_uid', 'revision_log'], + ['revision_created' => 'revision_timestamp'], + ['revision_user' => 'revision_uid'], + ['revision_log_message' => 'revision_log'], + ['revision_created' => 'revision_timestamp', 'revision_user' => 'revision_uid'], + ['revision_created' => 'revision_timestamp', 'revision_log_message' => 'revision_log'], + ['revision_user' => 'revision_uid', 'revision_log_message' => 'revision_log'], + ['revision_created' => 'revision_timestamp', 'revision_user' => 'revision_uid', 'revision_log_message' => 'revision_log'], ]; foreach ($test_cases as $revision_metadata_field_names) { $this->setUp(); @@ -881,7 +897,7 @@ public function testGetTableMappingRevisionableTranslatableWithFields(array $ent $this->fieldDefinitions = $this->mockFieldDefinitions($field_names); $revisionable_field_names = ['description', 'owner']; - $this->fieldDefinitions += $this->mockFieldDefinitions(array_merge($revisionable_field_names, $revision_metadata_field_names), ['isRevisionable' => TRUE]); + $this->fieldDefinitions += $this->mockFieldDefinitions(array_merge($revisionable_field_names, array_values($revision_metadata_field_names)), ['isRevisionable' => TRUE]); $this->entityType->expects($this->atLeastOnce()) ->method('isRevisionable') @@ -901,6 +917,9 @@ public function testGetTableMappingRevisionableTranslatableWithFields(array $ent ['revision', $entity_keys['revision']], ['langcode', $entity_keys['langcode']], ])); + $this->entityType->expects($this->any()) + ->method('getRevisionMetadataKeys') + ->will($this->returnValue($revision_metadata_field_names)); $this->setUpEntityStorage(); @@ -938,7 +957,7 @@ public function testGetTableMappingRevisionableTranslatableWithFields(array $ent $entity_keys['id'], $entity_keys['revision'], $entity_keys['langcode'], - ]), $revision_metadata_field_names); + ]), array_values($revision_metadata_field_names)); $actual = $mapping->getFieldNames('entity_test_revision'); $this->assertEquals($expected, $actual); // The UUID is not stored on the data table. diff --git a/core/tests/Drupal/Tests/Core/EventSubscriber/DefaultExceptionSubscriberTest.php b/core/tests/Drupal/Tests/Core/EventSubscriber/DefaultExceptionSubscriberTest.php deleted file mode 100644 index 6582500..0000000 --- a/core/tests/Drupal/Tests/Core/EventSubscriber/DefaultExceptionSubscriberTest.php +++ /dev/null @@ -1,42 +0,0 @@ -getConfigFactoryStub(); - - $kernel = $this->prophesize(HttpKernelInterface::class); - $request = Request::create('/test?_format=bananas'); - $e = new MethodNotAllowedHttpException(['POST', 'PUT'], 'test message'); - $event = new GetResponseForExceptionEvent($kernel->reveal(), $request, 'GET', $e); - $subscriber = new DefaultExceptionSubscriber($config_factory); - $subscriber->onException($event); - $response = $event->getResponse(); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals('test message', $response->getContent()); - $this->assertEquals(405, $response->getStatusCode()); - $this->assertEquals('POST, PUT', $response->headers->get('Allow')); - // Also check that that text/plain content type was added. - $this->assertEquals('text/plain', $response->headers->get('Content-Type')); - } - -} diff --git a/core/tests/Drupal/Tests/Core/EventSubscriber/FinalExceptionSubscriberTest.php b/core/tests/Drupal/Tests/Core/EventSubscriber/FinalExceptionSubscriberTest.php new file mode 100644 index 0000000..09b7dd3 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/EventSubscriber/FinalExceptionSubscriberTest.php @@ -0,0 +1,58 @@ +getConfigFactoryStub(); + + $kernel = $this->prophesize(HttpKernelInterface::class); + $request = Request::create('/test?_format=bananas'); + $e = new MethodNotAllowedHttpException(['POST', 'PUT'], 'test message'); + $event = new GetResponseForExceptionEvent($kernel->reveal(), $request, 'GET', $e); + $subscriber = new TestDefaultExceptionSubscriber($config_factory); + $subscriber->setStringTranslation($this->getStringTranslationStub()); + $subscriber->onException($event); + $response = $event->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->stringStartsWith('The website encountered an unexpected error. Please try again later.

Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException: test message in ', $response->getContent()); + $this->assertEquals(405, $response->getStatusCode()); + $this->assertEquals('POST, PUT', $response->headers->get('Allow')); + // Also check that that text/plain content type was added. + $this->assertEquals('text/plain', $response->headers->get('Content-Type')); + } + +} + +class TestDefaultExceptionSubscriber extends FinalExceptionSubscriber { + + protected function isErrorDisplayable($error) { + return TRUE; + } + + protected function simplifyFileInError($error) { + return $error; + } + + protected function isErrorLevelVerbose() { + return TRUE; + } + +} diff --git a/core/tests/Drupal/Tests/Core/EventSubscriber/OptionsRequestSubscriberTest.php b/core/tests/Drupal/Tests/Core/EventSubscriber/OptionsRequestSubscriberTest.php index 46a3317..e995616 100644 --- a/core/tests/Drupal/Tests/Core/EventSubscriber/OptionsRequestSubscriberTest.php +++ b/core/tests/Drupal/Tests/Core/EventSubscriber/OptionsRequestSubscriberTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\Core\EventSubscriber; use Drupal\Core\EventSubscriber\OptionsRequestSubscriber; +use Drupal\Tests\UnitTestCase; use Symfony\Cmf\Component\Routing\RouteProviderInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\GetResponseEvent; @@ -14,7 +15,7 @@ * @coversDefaultClass \Drupal\Core\EventSubscriber\OptionsRequestSubscriber * @group EventSubscriber */ -class OptionsRequestSubscriberTest extends \PHPUnit_Framework_TestCase { +class OptionsRequestSubscriberTest extends UnitTestCase { /** * @covers ::onRequest diff --git a/core/tests/Drupal/Tests/Core/Menu/LocalTaskManagerTest.php b/core/tests/Drupal/Tests/Core/Menu/LocalTaskManagerTest.php index f3b156e..3d3d501 100644 --- a/core/tests/Drupal/Tests/Core/Menu/LocalTaskManagerTest.php +++ b/core/tests/Drupal/Tests/Core/Menu/LocalTaskManagerTest.php @@ -413,7 +413,7 @@ public function testGetTasksBuildWithCacheabilityMetadata() { ->method('getDefinitions') ->will($this->returnValue($definitions)); - // Set up some cacheablity metadata and ensure its merged together. + // Set up some cacheability metadata and ensure its merged together. $definitions['menu_local_task_test_tasks_settings']['cache_tags'] = ['tag.example1']; $definitions['menu_local_task_test_tasks_settings']['cache_contexts'] = ['context.example1']; $definitions['menu_local_task_test_tasks_edit']['cache_tags'] = ['tag.example2']; diff --git a/core/tests/Drupal/Tests/Core/Plugin/DefaultSingleLazyPluginCollectionTest.php b/core/tests/Drupal/Tests/Core/Plugin/DefaultSingleLazyPluginCollectionTest.php index 22f3a88..21bbde8 100644 --- a/core/tests/Drupal/Tests/Core/Plugin/DefaultSingleLazyPluginCollectionTest.php +++ b/core/tests/Drupal/Tests/Core/Plugin/DefaultSingleLazyPluginCollectionTest.php @@ -58,6 +58,17 @@ public function testAddInstanceId() { $this->assertEquals(['id' => 'banana', 'key' => 'other_value'], $this->defaultPluginCollection->get('banana')->getConfiguration()); } + /** + * @covers ::getInstanceIds + */ + public function testGetInstanceIds() { + $this->setupPluginCollection($this->any()); + $this->assertEquals(['apple' => 'apple'], $this->defaultPluginCollection->getInstanceIds()); + + $this->defaultPluginCollection->addInstanceId('banana', ['id' => 'banana', 'key' => 'other_value']); + $this->assertEquals(['banana' => 'banana'], $this->defaultPluginCollection->getInstanceIds()); + } + } class ConfigurablePlugin extends PluginBase implements ConfigurablePluginInterface { diff --git a/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php b/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php index 9dac5dd..7188a51 100644 --- a/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php +++ b/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php @@ -671,7 +671,7 @@ public function testAddCacheableDependency(BubbleableMetadata $a, $b, Bubbleable * * @return array */ - public function providerTestAddCachableDependency() { + public function providerTestAddCacheableDependency() { return [ // Merge in a cacheable metadata. 'merge-cacheable-metadata' => [ diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTest.php b/core/tests/Drupal/Tests/Core/Render/RendererTest.php index 962ba1e..7ac4a90 100644 --- a/core/tests/Drupal/Tests/Core/Render/RendererTest.php +++ b/core/tests/Drupal/Tests/Core/Render/RendererTest.php @@ -511,7 +511,7 @@ public function testRenderWithAccessControllerResolved($access) { * @covers ::render * @covers ::doRender */ - public function testRenderAccessCacheablityDependencyInheritance() { + public function testRenderAccessCacheabilityDependencyInheritance() { $build = [ '#access' => AccessResult::allowed()->addCacheContexts(['user']), ]; diff --git a/core/tests/Drupal/Tests/Core/Routing/MethodFilterTest.php b/core/tests/Drupal/Tests/Core/Routing/MethodFilterTest.php index 1b72b75..8a7554d 100644 --- a/core/tests/Drupal/Tests/Core/Routing/MethodFilterTest.php +++ b/core/tests/Drupal/Tests/Core/Routing/MethodFilterTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\Core\Routing; use Drupal\Core\Routing\MethodFilter; +use Drupal\Tests\UnitTestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Exception\MethodNotAllowedException; use Symfony\Component\Routing\Route; @@ -12,7 +13,7 @@ * @coversDefaultClass \Drupal\Core\Routing\MethodFilter * @group Routing */ -class MethodFilterTest extends \PHPUnit_Framework_TestCase { +class MethodFilterTest extends UnitTestCase { /** * @covers ::applies diff --git a/core/tests/Drupal/Tests/Listeners/DrupalStandardsListener.php b/core/tests/Drupal/Tests/Listeners/DrupalStandardsListener.php index 7f8396f..fa25418 100644 --- a/core/tests/Drupal/Tests/Listeners/DrupalStandardsListener.php +++ b/core/tests/Drupal/Tests/Listeners/DrupalStandardsListener.php @@ -2,23 +2,26 @@ namespace Drupal\Tests\Listeners; +use PHPUnit\Framework\BaseTestListener; +use PHPUnit\Framework\TestCase; + /** * Listens for PHPUnit tests and fails those with invalid coverage annotations. * * Enforces various coding standards within test runs. */ -class DrupalStandardsListener extends \PHPUnit_Framework_BaseTestListener { +class DrupalStandardsListener extends BaseTestListener { /** * Signals a coding standards failure to the user. * - * @param \PHPUnit_Framework_TestCase $test + * @param \PHPUnit\Framework\TestCase $test * The test where we should insert our test failure. * @param string $message * The message to add to the failure notice. The test class name and test * name will be appended to this message automatically. */ - protected function fail(\PHPUnit_Framework_TestCase $test, $message) { + protected function fail(TestCase $test, $message) { // Add the report to the test's results. $message .= ': ' . get_class($test) . '::' . $test->getName(); $fail = new \PHPUnit_Framework_AssertionFailedError($message); @@ -44,10 +47,10 @@ protected function classExists($class) { * * This method is called from $this::endTest(). * - * @param \PHPUnit_Framework_TestCase $test + * @param \PHPUnit\Framework\TestCase $test * The test to examine. */ - public function checkValidCoversForTest(\PHPUnit_Framework_TestCase $test) { + public function checkValidCoversForTest(TestCase $test) { // If we're generating a coverage report already, don't do anything here. if ($test->getTestResultObject() && $test->getTestResultObject()->getCollectCodeCoverageInformation()) { return; @@ -144,7 +147,7 @@ public function endTest(\PHPUnit_Framework_Test $test, $time) { // \PHPUnit_Framework_Test does not have any useful methods of its own for // our purpose, so we have to distinguish between the different known // subclasses. - if ($test instanceof \PHPUnit_Framework_TestCase) { + if ($test instanceof TestCase) { $this->checkValidCoversForTest($test); } elseif ($test instanceof \PHPUnit_Framework_TestSuite) { diff --git a/core/tests/Drupal/Tests/TestSuites/TestSuiteBaseTest.php b/core/tests/Drupal/Tests/TestSuites/TestSuiteBaseTest.php index 0ba8143..5b1d546 100644 --- a/core/tests/Drupal/Tests/TestSuites/TestSuiteBaseTest.php +++ b/core/tests/Drupal/Tests/TestSuites/TestSuiteBaseTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\TestSuites; use org\bovigo\vfs\vfsStream; +use PHPUnit\Framework\TestCase; // The test suite class is not part of the autoloader, we need to include it // manually. @@ -13,7 +14,7 @@ * * @group TestSuite */ -class TestSuiteBaseTest extends \PHPUnit_Framework_TestCase { +class TestSuiteBaseTest extends TestCase { /** * Helper method to set up the file system. diff --git a/core/tests/Drupal/Tests/UnitTestCase.php b/core/tests/Drupal/Tests/UnitTestCase.php index f96b510..8aaeb85 100644 --- a/core/tests/Drupal/Tests/UnitTestCase.php +++ b/core/tests/Drupal/Tests/UnitTestCase.php @@ -8,6 +8,7 @@ use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\StringTranslation\PluralTranslatableMarkup; +use PHPUnit\Framework\TestCase; /** @@ -15,7 +16,7 @@ * * @ingroup testing */ -abstract class UnitTestCase extends \PHPUnit_Framework_TestCase { +abstract class UnitTestCase extends TestCase { /** * The random generator. diff --git a/core/tests/README.md b/core/tests/README.md index 566fa23..5dd6cc6 100644 --- a/core/tests/README.md +++ b/core/tests/README.md @@ -18,9 +18,9 @@ Note: functional tests have to be invoked with a user in the same group as the web server user. You can either configure Apache (or nginx) to run as your own system user or run tests as a privileged user instead. -To develop locally, a straigtforward - but also less secure - approach is to run -tests as your own system user. To achieve that, change the default Apache user -to run as your system user. Typically, you'd need to modify +To develop locally, a straightforward - but also less secure - approach is to +run tests as your own system user. To achieve that, change the default Apache +user to run as your system user. Typically, you'd need to modify `/etc/apache2/envvars` on Linux or `/etc/apache2/httpd.conf` on Mac. Example for Linux: diff --git a/core/themes/seven/css/components/jquery.ui/theme.css b/core/themes/seven/css/components/jquery.ui/theme.css index 02dc83d..dd81ec2 100644 --- a/core/themes/seven/css/components/jquery.ui/theme.css +++ b/core/themes/seven/css/components/jquery.ui/theme.css @@ -33,8 +33,6 @@ .ui-state-active, .ui-widget-content .ui-state-active { color: #840; - background: #fe6; - border: solid 1px #ed5; } .ui-state-error, .ui-widget-content .ui-state-error { diff --git a/example.gitignore b/example.gitignore index c292c2e..d1e35ac 100644 --- a/example.gitignore +++ b/example.gitignore @@ -37,7 +37,3 @@ sites/simpletest # Ignore SimpleTest multi-site environment. # simpletest - -# Ignore core phpcs.xml and phpunit.xml. -core/phpcs.xml -core/phpunit.xml diff --git a/sites/default/default.settings.php b/sites/default/default.settings.php index 25d498e..2b2bdb7 100644 --- a/sites/default/default.settings.php +++ b/sites/default/default.settings.php @@ -86,7 +86,7 @@ * ); * @endcode */ - $databases = array(); +$databases = array(); /** * Customizing database settings. @@ -750,6 +750,16 @@ ]; /** + * The default number of entities to update in a batch process. + * + * This is used by update and post-update functions that need to go through and + * change all the entities on a site, so it is useful to increase this number + * if your hosting configuration (i.e. RAM allocation, CPU speed) allows for a + * larger number of entities to be processed in a single batch run. + */ +$settings['entity_update_batch_size'] = 50; + +/** * Load local development override configuration, if available. * * Use settings.local.php to override variables on secondary (staging,