diff --git a/core/includes/config.inc b/core/includes/config.inc index 00c7fa6..4d04b9a 100644 --- a/core/includes/config.inc +++ b/core/includes/config.inc @@ -3,6 +3,8 @@ use Drupal\Core\Config\Config; use Drupal\Core\Config\FileStorage; use Drupal\Core\Config\NullStorage; +use Drupal\Core\Config\Metadata\TypedConfig; +use Drupal\Core\Config\Metadata\MetadataLookup; use Drupal\Core\Config\StorageInterface; /** @@ -169,6 +171,209 @@ function config_sync_get_changes(StorageInterface $source_storage, StorageInterf } /** + * Retrieves metadata for a configuration object or key. + * + * The metadata contains information about the structure of the configuration + * object and for each element of the data, the data type definition to be + * passed to typed_data()->create() + * or \Drupal\Core\TypedData\TypedDataManager::create(). + * + * The metadata has the same structure as the configuration object itself but + * the configuration values are replaced by an array containing some special + * elements, whose keys are prefixed by a dot: + * - '.type', the 'type' property for the data type definition of the element. + * - '.label', the 'label' property for the data type definition of the element. + * - '.definition', array containing any other property for the data type + * definition of the element. + * - '.include', name of additional metadata file to be added. The current + * metadata will then be merged on top of it using + * NestedArray::mergeRecursive() + * - '.list', contains an array of metadata to be used for each of the children + * elements when there's no other metadata for them. This will also imply + * that the type definition's 'list' property for the parent element will be + * set to TRUE. + * + * For example, for the config file core/modules/system/config/system.site.yml: + * @code + * name: Drupal + * mail: '' + * slogan: '' + * page: + * 403: '' + * 404: '' + * front: user + * @endcode + * + * The metadata in core/modules/system/meta/system.site.yml: + * @code + * name: + * .label: 'Site name' + * .type: text + * mail: + * .label: 'Site mail' + * slogan: + * .label: 'Site slogan' + * .type: text + * page: + * .label: 'Default pages' + * 403: + * .label: 'Default 403 (access denied) page' + * 404: + * .label: 'Default 404 (not found) page' + * front: + * .label: 'Default front page' + * @endcode + * + * Elements that don't have an explicit type will default to the type 'string' + * unless they have children elements (like 'page' in the example) or the + * 'list' property set to true, in which case they will default to the type + * 'config_element'. + * + * Nested lists of elements, that contain additional metadata for each of the + * elements in a different file are also supported. + * + * Here are parts of core/modules/user/config/user.mail.yml: + * @code + * status_blocked: + * body: "[user:name],\n\nYour account on [site:name] has been blocked.\n\n-- [site:name] team" + * subject: 'Account details for [user:name] at [site:name] (blocked)' + * status_canceled: + * body: "[user:name],\n\nYour account on [site:name] has been canceled.\n\n-- [site:name] team" + * subject: 'Account details for [user:name] at [site:name] (canceled)' + * @endcode + * + * The metadata is in core/modules/user/meta/user.mail.yml: + * @code + * .label: 'User mails' + * .list: + * .label: 'Mail text' + * subject: + * .label: 'Subject' + * .type: 'text' + * body: + * .label: 'Body' + * .type: 'text' + + * @endcode + * + * For every element in the list of user mails, the metadata is specified by + * the '.list' key which contains an array of metadata to be used for each of + * the nested configuration elements. + * + * As said above, the type for list elements is config_element, so the + * $definition array for the status_blocked array is: + * @code + * array( + * 'type' => 'config_element', + * 'label' => 'Mail text', + * ); + * @endcode + * + * While the $definition array for the 'subject' nested property of it is: + * @code + * array( + * 'label' => 'Subject', + * 'type' => 'text', + * ); + * @endcode + * + * Other lists use a property inside a the '.include' element for this key. + * For example, core/modules/image/config/image.style.medium.yml: + * @code + * name: medium + * label: Medium (220x220) + * effects: + * bddf0d06-42f9-4c75-a700-a33cafa25ea0: + * name: image_scale + * data: + * width: '220' + * height: '220' + * upscale: '1' + * weight: '0' + * ieid: bddf0d06-42f9-4c75-a700-a33cafa25ea0 + * @endcode + * + * The metadata is in core/modules/image/meta/image.style.%.yml: + * @code + * name: + * .label: 'Machine name' + * label: + * .label: 'Label' + * .type: text + * effects: + * .label: 'Style effects' + * .list: + * .include: 'image.effect.[name]' + * .label: 'Image style effect' + * weight: + * .label: 'Weight' + * .type: integer + * ieid: + * .label: 'IEID' + * @endcode + * + * For this example, for each image effect, we have the metadata under the + * '.list' key. Each of the effects will have: + * - Some common properties that depend on the image style (weight, ieid) and + * since they are specific of image styles (not image effects) they are + * defined right in this element. + * - Some properties specific of a given image effect, like the ones in + * image.effect.image_scale.yml + * + * In this case we want to reuse the metadata for 'image_scale' every time + * that effect is used. So the '[name]' variable in the include is replaced + * by the element's 'name' value, which in this example will indeed be + * 'image_scale' so to find the metadata for the + * bddf0d06-42f9-4c75-a700-a33cafa25ea0 list element, + * meta/image.effect.image_scale.yml is used if it exists and + * meta/image.effect.%.yml if it does not. + * + * For example here is meta/image.effect.image_scale.yml: + * @code + * .label: 'Image scale' + * data: + * width: + * .label: 'Width' + * .type: 'integer' + * height: + * .label: 'Height' + * .type: 'integer' + * upscale: + * .label: 'Upscale' + * .type: 'boolean' + * @endcode + * + * @see \Drupal\Core\TypedData\TypedDataManager::create() + * @see \Drupal\Core\Config\Metadata\ElementBase + * @see \Drupal\Core\Config\Metadata\ListElement + * + * @param string $name + * The name or key of the configuration object, the same as passed to + * config(). + * + * @return \Drupal\Core\Config\Metadata\MetadataLookup + * A metadata array. + * + * @see \Drupal\Core\Config\Metadata\ElementInterface::getMetadata() + */ +function config_metadata($name) { + return drupal_container()->get('config.metadata')->offsetGet($name); +} + +/** + * Retrieves a configuration wrapper object to access data as typed properties. + * + * @param string $name + * The name of the configuration object to retrieve. + * + * @return \Drupal\Core\Config\Metadata\TypedConfig + * A configuration wrapper object. + */ +function config_wrapper($name) { + return drupal_container()->get('config.typed')->get($name); +} + +/** * Writes an array of config file changes from a source storage to a target storage. * * @param array $config_changes diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc index ef50d04..2f14f1e 100644 --- a/core/includes/install.core.inc +++ b/core/includes/install.core.inc @@ -694,6 +694,12 @@ function install_tasks($install_state) { 'type' => 'batch', 'run' => $needs_translations ? INSTALL_TASK_RUN_IF_NOT_COMPLETED : INSTALL_TASK_SKIP, ), + 'install_update_configuration_translations' => array( + 'display_name' => st('Translate configuration'), + 'display' => $needs_translations, + 'type' => 'batch', + 'run' => $needs_translations ? INSTALL_TASK_RUN_IF_NOT_COMPLETED : INSTALL_TASK_SKIP, + ), 'install_finished' => array( 'display_name' => st('Finished'), ), @@ -1668,6 +1674,20 @@ function install_import_translations_remaining(&$install_state) { } /** + * Creates configuration translations. + * + * @param array $install_state + * An array of information about the current installation state. + * + * @return array + * The batch definition, if there are configuration objects to update. + */ +function install_update_configuration_translations(&$install_state) { + include_once drupal_get_path('module', 'locale') . '/locale.bulk.inc'; + return locale_config_batch_update_components(array('langcode' => $install_state['parameters']['langcode'])); +} + +/** * Performs final installation steps and displays a 'finished' page. * * @param $install_state diff --git a/core/lib/Drupal/Core/Config/Config.php b/core/lib/Drupal/Core/Config/Config.php index 6fc2a87..f07c798 100644 --- a/core/lib/Drupal/Core/Config/Config.php +++ b/core/lib/Drupal/Core/Config/Config.php @@ -323,7 +323,7 @@ public function clear($key) { /** * Loads configuration data into this object. * - * @return Drupal\Core\Config\Config + * @return \Drupal\Core\Config\Config * The configuration object. */ public function load() { diff --git a/core/lib/Drupal/Core/Config/ConfigFactory.php b/core/lib/Drupal/Core/Config/ConfigFactory.php index ca36ce7..c959673 100644 --- a/core/lib/Drupal/Core/Config/ConfigFactory.php +++ b/core/lib/Drupal/Core/Config/ConfigFactory.php @@ -58,7 +58,7 @@ public function __construct(StorageInterface $storage, EventDispatcher $event_di * @param string $name * The name of the configuration object to construct. * - * @return Drupal\Core\Config\Config + * @return \Drupal\Core\Config\Config * A configuration object with the given $name. */ public function get($name) { diff --git a/core/lib/Drupal/Core/Config/InstallStorage.php b/core/lib/Drupal/Core/Config/InstallStorage.php index eceef01..f4344de 100644 --- a/core/lib/Drupal/Core/Config/InstallStorage.php +++ b/core/lib/Drupal/Core/Config/InstallStorage.php @@ -15,6 +15,13 @@ class InstallStorage extends FileStorage { /** + * Folder map indexed by configuration name. + * + * @var array + */ + protected $folders; + + /** * Overrides Drupal\Core\Config\FileStorage::__construct(). */ public function __construct() { @@ -38,17 +45,9 @@ public function __construct() { * afterwards check for a corresponding module or theme. */ public function getFilePath($name) { - // Extract the owner. - $owner = strtok($name, '.'); - // Determine the path to the owner. - $path = FALSE; - foreach (array('profile', 'module', 'theme') as $type) { - if ($path = drupal_get_path($type, $owner)) { - $file = $path . '/config/' . $name . '.' . static::getFileExtension(); - if (file_exists($file)) { - return $file; - } - } + $folders = $this->getAllFolders(); + if (isset($folders[$name])) { + return $folders[$name] . '/' . $name . '.' . $this->getFileExtension(); } // If any code in the early installer requests a configuration object that // does not exist anywhere as default config, then that must be mistake. @@ -90,6 +89,76 @@ public function rename($name, $new_name) { * @throws Drupal\Core\Config\StorageException */ public function listAll($prefix = '') { - throw new StorageException('List operation is not allowed during install.'); + $names = array_keys($this->getAllFolders()); + if (!$prefix) { + return $names; + } + else { + $return = array(); + foreach ($names as $index => $name) { + if (strpos($name, $prefix) === 0 ) { + $return[$index] = $names[$index]; + } + } + return $return; + } + } + + /** + * Returns a map of all metadata names and the module they belong to. + * + * @return array + * An array mapping metadata names with directories. + */ + protected function getAllFolders() { + if (!isset($this->folders)) { + $this->folders = $this->getComponentNames('profile', array(drupal_get_profile())); + $this->folders += $this->getComponentNames('module', array_keys(drupal_system_listing('/^' . DRUPAL_PHP_FUNCTION_PATTERN . '\.module$/', 'modules', 'name', 0))); + $this->folders += $this->getComponentNames('theme', array_keys(drupal_system_listing('/^' . DRUPAL_PHP_FUNCTION_PATTERN . '\.info$/', 'themes'))); + } + return $this->folders; } + + /** + * Get all configuration names and folders for a list of modules or themes. + * + * @param string $type + * Type of components: 'module' | 'theme' | 'profile' + * @param array $list + * Array of theme or module names. + * + * @return array + * Folders indexed by configuration name. + */ + public function getComponentNames($type, $list) { + $extension = '.' . $this->getFileExtension(); + $folders = array(); + foreach ($list as $name) { + $directory = $this->getComponentFolder($type, $name); + if (file_exists($directory)) { + $files = glob($directory . '/*' . $extension); + foreach ($files as $filename) { + $name = basename($filename, $extension); + $folders[$name] = $directory; + } + } + } + return $folders; + } + + /** + * Get folder inside each component that contains the files. + * + * @param string $type + * Component type: 'module' | 'theme' | 'profile' + * @param string $name + * Component name. + * + * @return string + * The configuration folder name for this component. + */ + protected function getComponentFolder($type, $name) { + return drupal_get_path($type, $name) . '/config'; + } + } diff --git a/core/lib/Drupal/Core/Config/Metadata/ConfigElement.php b/core/lib/Drupal/Core/Config/Metadata/ConfigElement.php new file mode 100644 index 0000000..b3c4479 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Metadata/ConfigElement.php @@ -0,0 +1,109 @@ +getElement($property_name)) { + return $property; + } + else { + throw new InvalidArgumentException(format_string("The configuration property @key doesn't exist.", array( + '@key' => $property_name, + ))); + } + } + + /** + * Implements Drupal\Core\TypedData\ComplexDataInterface::set(). + */ + public function set($property_name, $value) { + // Set the data into the configuration array but behave according to the + // interface specification when we've got a null value. + if (isset($value)) { + $this->value[$property_name] = $value; + return $this->get($property_name); + } + else { + // In these objects, when clearing the value, the property is gone. + // As this needs to return a property, we get it before we delete it. + $property = $this->get($property_name); + unset($this->value[$property_name]); + $property->setValue($value); + return $property; + } + } + + /** + * Implements Drupal\Core\TypedData\ComplexDataInterface::getProperties(). + */ + public function getProperties($include_computed = FALSE) { + return $this->toArray(); + } + + /** + * Implements Drupal\Core\TypedData\ComplexDataInterface::getPropertyValues(). + */ + public function getPropertyValues() { + return $this->value; + } + + /** + * Implements Drupal\Core\TypedData\ComplexDataInterface::setPropertyValues(). + */ + public function setPropertyValues($values) { + foreach ($values as $name => $value) { + $this->value[$name] = $value; + } + return $this; + } + + /** + * Implements Drupal\Core\TypedData\ComplexDataInterface::getPropertyDefinition(). + */ + public function getPropertyDefinition($name) { + $value = isset($this->value[$name]) ? $this->value[$name] : NULL; + $metadata = $this->getElementMetadata($key); + $definition = ElementBase::buildDefinition($value, $metadata); + return $definition ? $definition : FALSE; + } + + /** + * Implements Drupal\Core\TypedData\ComplexDataInterface::getPropertyDefinitions(). + */ + public function getPropertyDefinitions() { + $list = array(); + foreach ($this->getAllKeys() as $key) { + $list[$key] = $this->getPropertyDefinition($key); + } + return $list; + } + + /** + * Implements Drupal\Core\TypedData\ComplexDataInterface::isEmpty(). + */ + public function isEmpty() { + return empty($this->value); + } + +} diff --git a/core/lib/Drupal/Core/Config/Metadata/ElementBase.php b/core/lib/Drupal/Core/Config/Metadata/ElementBase.php new file mode 100644 index 0000000..12e0900 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Metadata/ElementBase.php @@ -0,0 +1,412 @@ +value[$key])) { + return $this->buildElement($this->value[$key], $this->getElementMetadata($key), $key, $this); + } + else { + return NULL; + } + } + + /** + * Gets the built metadata array for a child element. + * + * @param string $key + * Key from the configuration data array. + * + * @return array + * The metadata for the element, defaults to an empty array. + */ + protected function getElementMetadata($key) { + // Metadata for nested elements must be built before. + if (isset($this->metadata[$key])) { + $metadata = $this->metadata[$key]; + } + elseif (isset($this->metadata['.list'])) { + $metadata = $this->metadata['.list']; + } + else { + $metadata = array(); + } + if ($metadata) { + $data = isset($this->value[$key]) && is_array($this->value[$key]) ? $this->value[$key] : array(); + $data['%parent'] = $this->value; + $data['%key'] = $key; + $metadata = $this->buildMetadata($data, $metadata); + } + return $metadata; + } + + /** + * Gets valid configuration data keys. + * + * @return array + * Array of valid configuration data keys. + */ + protected function getAllKeys() { + return is_array($this->value) ? array_keys($this->value) : array(); + } + + /** + * Gets typed data properties as array. + * + * @return array + * List of TypedDataInterface objects indexed by key. + */ + protected function toArray() { + $array = array(); + foreach ($this->getAllKeys() as $key) { + $array[$key] = $this->getElement($key); + } + return array_filter($array); + } + + /** + * Implements Drupal\Core\Config\Metadata\ElementInterface::getKey(). + */ + public function getKey() { + $parent = isset($this->parent) ? $this->parent->getKey() : NULL; + return $parent || is_numeric($parent) ? $parent . '.' . $this->key : $this->key; + } + + /** + * Implements Drupal\Core\Config\Metadata\ElementInterface::getLabel(). + */ + public function getLabel() { + return isset($this->definition['label']) ? $this->definition['label'] : $this->getKey(); + } + + /** + * Implements Drupal\Core\Config\Metadata\ElementInterface::getMetadata(). + */ + public function getMetadata() { + return $this->metadata; + } + + /** + * Implements Drupal\Core\Config\Metadata\ElementInterface::setMetadata(). + */ + public function setMetadata($metadata) { + $this->metadata = $metadata; + } + + /** + * Implements Drupal\Core\TypedData\TypedDataInterface::validate(). + */ + public function validate() { + // This will be ok if we have any config data at all. + return isset($this->value); + } + + /** + * Implements Drupal\Core\TypedData\ContextAwareInterface::getName(). + */ + public function getName() { + return $this->key; + } + + /** + * Implements Drupal\Core\TypedData\ContextAwareInterface::setName(). + */ + public function setName($name) { + $this->key = $name; + } + + /** + * Implements Drupal\Core\TypedData\ContextAwareInterface::getParent(). + */ + public function getParent() { + return $this->parent; + } + + /** + * Implements Drupal\Core\TypedData\ContextAwareInterface::setParent(). + */ + public function setParent($parent) { + $this->parent = $parent; + } + + /** + * Implements ArrayAccess::offsetExists(). + */ + public function offsetExists($offset) { + return isset($this->value[$offset]); + } + + /** + * Implements ArrayAccess::offsetGet(). + */ + public function offsetGet($offset) { + return $this->getElement($offset); + } + + /** + * Implements ArrayAccess::offsetSet(). + */ + public function offsetSet($offset, $value) { + if ($value instanceof TypedDataInterface) { + $value = $value->getValue(); + } + $this->value[$offset] = $value; + } + + /** + * Implements ArrayAccess::offsetUnset(). + */ + public function offsetUnset($offset) { + unset($this->value[$offset]); + } + + /** + * Implements Countable::count(). + */ + public function count() { + return count($this->getValue()); + } + + /** + * Implements IteratorAggregate::getIterator(). + */ + public function getIterator() { + return new ArrayIterator($this->toArray()); + } + + /** + * Builds a typed data element for a piece of configuration data. + * + * @param mixed $value + * Configuration data to be used as value. + * @param array $metadata + * Metadata for the element. + * @param string $key + * Nested configuration key to be used as element's name. + * @param \Drupal\Core\Config\Metadata\ElementInterface $parent + * (optional) Element to be used as parent of this one. Defaults to NULL. + * + * @return \Drupal\Core\TypedData\TypedDataInterface + * A TypedDataInterface object containing the configuration value. + */ + public static function buildElement($value, $metadata, $key, $parent = NULL) { + $definition = self::buildDefinition($value, $metadata); + $context = array('name' => $key, 'parent' => $parent); + $element = typed_data()->create($definition, $value, $context); + if ($element instanceof ElementInterface) { + // If this is a nested ConfigElement we need to set the metadata for it. + $element->setMetadata($metadata); + } + return $element; + } + + /** + * Builds element definition from configuration metadata. + * + * Though the configuration metadata contains basically the typed data + * element's definition, we need to take out the dot prefix from property + * names and add some default data types. + * + * @param mixed $data + * The configuration data for the element. + * @param array $metadata + * The raw metadata array for the element. + * + * @return array + * The elemement's data definition. + */ + public static function buildDefinition($data, $metadata) { + $definition = isset($metadata['.definition']) ? $metadata['.definition'] : array(); + // If we have a '.list' directive we assume this is a list. + if (isset($metadata['.list'])) { + $definition += array('list' => TRUE); + } + // The default type will depend on whether data is an array. + if (!isset($definition['type'])) { + if (is_array($data) || !empty($definition['list']) || !empty($definition['list settings'])) { + $definition['type'] = 'config_element'; + } + else { + $definition['type'] = 'string'; + } + } + return $definition; + } + + /** + * Prepares metadata using actual configuration data. + * + * @param mixed $data + * The configuration data for the element. + * @param array $metadata + * The raw metadata array for the element. + * + * @return array + * The preprocessed metadata array. + */ + public static function buildMetadata($data, $metadata) { + $metadata = self::processInclude($data, $metadata); + // Process shorthand '.type', '.label' + foreach (array('type', 'label') as $key) { + if (isset($metadata['.' . $key])) { + $metadata['.definition'][$key] = $metadata['.' . $key]; + unset($metadata['.' . $key]); + } + } + return $metadata; + } + + /** + * Processes '.include' keyword applying variable replacements. + * + * The include name may contain variables referencing configuration values. + * They will be replaced as defined in the replaceName() method. + * + * This method is called recursively in case the included metadata has also + * an '.include' keyword. + * + * @param array $metadata + * Metadata array for the element. + */ + protected static function processInclude($data, $metadata) { + if (isset($metadata['.include'])) { + // Parse the include string for variable names in squae brackets. + $name = self::replaceName($metadata['.include'], $data); + if ($include = config_metadata($name)) { + // There may be nested '.include' directives, we need to process them too. + $include = self::processInclude($data, $include); + $metadata = NestedArray::mergeDeep($include, $metadata); + } + unset($metadata['.include']); + } + return $metadata; + } + + /** + * Replaces variables in configuration name. + * + * The configuration name may contain one or more variables to be replaced, + * enclosed in square brackets like '[name]' and will follow the replacement + * rules defined by the replaceVariable() method. + * + * @param string $name + * Configuration name with variables in square brackets. + * @param mixed $data + * Configuration data for the element. + * @return string + * Configuration name with variables replaced. + */ + protected static function replaceName($name, $data) { + if (preg_match_all("/\[(.*)\]/U", $name, $matches)) { + // Build our list of '[value]' => replacement. + foreach (array_combine($matches[0], $matches[1]) as $key => $value) { + $replace[$key] = self::replaceVariable($value, $data); + } + return strtr($name, $replace); + } + else { + return $name; + } + } + + /** + * Replaces variable values in included names with configuration data. + * + * Variable values are nested configuration keys that will be replaced by + * their value or some of these special strings: + * - '%key', will be replaced by the element's key. + * - '%parent', to reference the parent element. + * + * There may be nested configuration keys separated by dots or more complex + * patterns like '%parent.name' which references the 'name' value of the + * parent element. + * + * Example patterns: + * - 'name.subkey', indicates a nested value of the current element. + * - '%parent.name', will be replaced by the 'name' value of the parent. + * - '%parent.%key', will be replaced by the parent element's key. + * + * @param string $value + * Variable value to be replaced. + * + * @return string + * The replaced value if a replacement found or the original value if not. + */ + protected static function replaceVariable($value, $data) { + $parts = explode('.', $value); + // Process each value part, one at a time. + while ($name = array_shift($parts)) { + if (!is_array($data) || !isset($data[$name])) { + // Key not found, return original value + return $value; + } + elseif (!$parts) { + // If no more parts left, this is the final property. + return (string)$data[$name]; + } + else { + // Get nested value and continue processing. + $data = $data[$name]; + } + } + } + +} diff --git a/core/lib/Drupal/Core/Config/Metadata/ElementInterface.php b/core/lib/Drupal/Core/Config/Metadata/ElementInterface.php new file mode 100644 index 0000000..c1d5276 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Metadata/ElementInterface.php @@ -0,0 +1,57 @@ +data); + } +} diff --git a/core/lib/Drupal/Core/Config/Metadata/MetadataLookup.php b/core/lib/Drupal/Core/Config/Metadata/MetadataLookup.php new file mode 100644 index 0000000..779005b --- /dev/null +++ b/core/lib/Drupal/Core/Config/Metadata/MetadataLookup.php @@ -0,0 +1,82 @@ +metadataStorage = $metadata_storage; + parent::__construct('config:metadata', 'cache'); + } + + /** + * Overrides DrupalCacheArray::resolveCacheMiss(). + */ + protected function resolveCacheMiss($offset) { + $metadata = $this->metadataStorage->read($offset); + // If no metadata with this exact name, try the fallback name. + if ($metadata === FALSE && ($basename = $this->getFallbackName($offset))) { + $metadata = $this->offsetGet($basename); + } + $this->storage[$offset] = $metadata ? $metadata : array(); + return $this->storage[$offset]; + } + + /** + * Gets fallback metadata name. + * + * @param string $name + * Configuration name or key. + * + * @return string + * Same name with the last part replaced by the filesystem marker. + */ + protected static function getFallbackName($name) { + $replaced = preg_replace('/\.[^.]+$/', '.' . self::BASE_MARK, $name); + if ($replaced != $name) { + return $replaced; + } + } + +} diff --git a/core/lib/Drupal/Core/Config/Metadata/MetadataStorage.php b/core/lib/Drupal/Core/Config/Metadata/MetadataStorage.php new file mode 100644 index 0000000..5f16f41 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Metadata/MetadataStorage.php @@ -0,0 +1,31 @@ +getAllFolders()); + } + + /** + * Overrides Drupal\Core\Config\InstallStorage::getComponentFolder(). + */ + protected function getComponentFolder($type, $name) { + return drupal_get_path($type, $name) . '/meta'; + } + +} diff --git a/core/lib/Drupal/Core/Config/Metadata/TypedConfig.php b/core/lib/Drupal/Core/Config/Metadata/TypedConfig.php new file mode 100644 index 0000000..42288cf --- /dev/null +++ b/core/lib/Drupal/Core/Config/Metadata/TypedConfig.php @@ -0,0 +1,143 @@ +name = $name; + $this->data = $data; + } + + /** + * Gets data from this config object. + * + * @return mixed + * The data that was requested. + */ + public function getData() { + return $this->data; + } + + /** + * Overrides Drupal\Core\Config\Config::setData(). + */ + public function setData(array $data) { + $this->data = $data; + $this->setBase(); + return $this; + } + + /** + * Overrides Drupal\Core\Config::get(). + * + * Unlike the get() method of the base configuration object, this one returns + * a typed data object instead of raw configuration data. + * + * @return \Drupal\Core\TypedData\TypedDataInterface|null + * The data that was requested or NULL if the base was not a ListInterface + * or a ComplexDataInterface. + */ + public function get($key = '') { + $element = $this->getBase(); + if (!empty($key)) { + // Get nested element recursively. + $parts = explode('.', $key); + while ($element && $name = array_shift($parts)) { + if ($element instanceof ListInterface) { + $element = $element[$name]; + } + elseif ($element instanceof ComplexDataInterface) { + $element = $element->get($name); + } + else { + $element = NULL; + } + } + + } + return $element; + } + + /** + * Overrides Drupal\Core\Config\Config::set(). + */ + public function set($key, $value) { + parent::set($key, $value); + $this->setBase(); + return $this; + } + + /** + * Overrides Drupal\Core\Config\Config::clear(). + */ + public function clear($key) { + parent::clear($key); + $this->setBase(); + return $this; + } + + /** + * Gets the configuration language. + * + * @return \Drupal\Core\Language\Language + * The language object. + */ + public function getLanguage() { + // The default language will be English as this is hardcoded information. + $langcode = isset($this->data['language']) ? $this->data['language'] : 'en'; + return new Language(array('langcode' => $langcode)); + } + + /** + * Gets the base configuration element. + */ + protected function getBase() { + if (!isset($this->base)) { + $metadata = ElementBase::buildMetadata($this->data, config_metadata($this->name)); + $this->base = ElementBase::buildElement($this->data, $metadata, ''); + } + return $this->base; + } + + /** + * Resets the base element's data. + */ + protected function setBase() { + if (isset($this->base)) { + $this->base->setValue($this->data); + } + } + +} diff --git a/core/lib/Drupal/Core/Config/Metadata/TypedConfigFactory.php b/core/lib/Drupal/Core/Config/Metadata/TypedConfigFactory.php new file mode 100644 index 0000000..aa416ad --- /dev/null +++ b/core/lib/Drupal/Core/Config/Metadata/TypedConfigFactory.php @@ -0,0 +1,46 @@ +configFactory = $config_factory; + } + + /** + * Returns configuration wrapper object to access data as typed properties. + * + * @param string $name + * Name of the data. + * + * @return TypedConfig + * The configuration wrapper as an object. + */ + function get($name) { + return new TypedConfig($name, $this->configFactory->get($name)->load()->get()); + } + +} diff --git a/core/lib/Drupal/Core/CoreBundle.php b/core/lib/Drupal/Core/CoreBundle.php index e1147ac..a7c3693 100644 --- a/core/lib/Drupal/Core/CoreBundle.php +++ b/core/lib/Drupal/Core/CoreBundle.php @@ -128,6 +128,13 @@ public function build(ContainerBuilder $container) { $container->register('entity.query', 'Drupal\Core\Entity\Query\QueryFactory') ->addArgument(new Reference('service_container')); + // Add the config metadata service. + $container->register('config.metadata.storage', 'Drupal\Core\Config\Metadata\MetadataStorage'); + $container->register('config.metadata', 'Drupal\Core\Config\Metadata\MetadataLookup') + ->addArgument(new Reference('config.metadata.storage')); + $container->register('config.typed', 'Drupal\Core\Config\Metadata\TypedConfigFactory') + ->addArgument(new Reference('config.factory')); + $container->register('router.dumper', 'Drupal\Core\Routing\MatcherDumper') ->addArgument(new Reference('database')); $container->register('router.builder', 'Drupal\Core\Routing\RouteBuilder') diff --git a/core/modules/config/lib/Drupal/config/Tests/ConfigMetadataTest.php b/core/modules/config/lib/Drupal/config/Tests/ConfigMetadataTest.php new file mode 100644 index 0000000..5c78096 --- /dev/null +++ b/core/modules/config/lib/Drupal/config/Tests/ConfigMetadataTest.php @@ -0,0 +1,127 @@ + 'Configuration metadata', + 'description' => 'Tests Metadata for configuration objects.', + 'group' => 'Configuration', + ); + } + + /** + * Tests the basic metadata retrieval layer. + */ + function testBasicMetadata() { + // Simple case, straight metadata. + $metadata = config_metadata('system.maintenance'); + $expected = array(); + $expected['enabled'] = array( + '.label' => 'Put site into maintenance mode', + '.type' => 'boolean' + ); + $expected['message'] = array( + '.label' => 'Message to display when in maintenance mode', + '.type' => 'text', + ); + $this->assertEqual($metadata, $expected, 'Retrieved the right metadata for system.maintenance'); + // More complex case. Metadata for image style effect. + $metadata = config_metadata('image.effect.image_scale'); + $expected = array(); + $expected['width'] = array( + '.label' => 'Width', + '.type' => 'integer', + ); + $expected['height'] = array( + '.label' => 'Height', + '.type' => 'integer', + ); + $expected['upscale'] = array( + '.label' => 'Upscale', + '.type' => 'boolean', + ); + $this->assertEqual($metadata, $expected, 'Retrieved the right metadata for image.style.large'); + + // Most complex case, metadata from actual configuration element using includes. + $metadata = config_wrapper('image.style.medium')->get('effects.bddf0d06-42f9-4c75-a700-a33cafa25ea0.data')->getMetadata(); + // This first element is processed metadata so all the definition will be in + // '.definition' array + $expected['.definition']['label'] = 'Data'; + $this->assertEqual($metadata, $expected, 'Retrieved the right metadata for the first effect of image.style.medium'); + } + + /** + * Tests metadata applied to configuration objects. + */ + function testConfigMetadata() { + // Try some simple properties. + $meta = config_wrapper('system.site'); + $property = $meta->get('name'); + $this->assertTrue(is_a($property, 'Drupal\Core\TypedData\Type\String'), 'Got the right wrapper fo the site name property.'); + $this->assertEqual($property->getType(), 'text', 'Got the right string type for site name data.'); + $this->assertEqual($property->getValue(), 'Drupal', 'Got the right string value for site name data.'); + + $property = $meta->get('page.front'); + $this->assertTrue(is_a($property, 'Drupal\Core\TypedData\Type\String'), 'Got the right wrapper fo the page.front property.'); + $this->assertEqual($property->getType(), 'string', 'Got the right type for page.front data (undefined).'); + $this->assertEqual($property->getValue(), 'user', 'Got the right value for page.front data.'); + + // Check nested array of properties. + $list = $meta->get('page'); + $this->assertEqual(count($list), 3, 'Got a list with the right number of properties for site page data'); + $this->assertTrue(isset($list['front']) && isset($list['403']) && isset($list['404']), 'Got a list with the right properties for site page data.'); + $this->assertEqual($list['front']->getValue(), 'user', 'Got the right value for page.front data from the list.'); + // And test some ComplexDataInterface methods. + $properties = $list->getProperties(); + $this->assertTrue(count($properties) == 3 && $properties['front'] == $list['front'], 'Got the right properties for site page.'); + $values = $list->getPropertyValues(); + $this->assertTrue(count($values) == 3 && $values['front'] == 'user', 'Got the right property values for site page.'); + + // Now let's try something more complex, with nested objects. + $wrapper = config_wrapper('image.style.large'); + $effects = $wrapper->get('effects'); + // The function is_array() doesn't work with ArrayAccess, so we use count(). + $this->assertTrue(count($effects) == 1, 'Got an array with effects for image.style.large data'); + $ieid = key($effects->getValue()); + $effect = $wrapper->get('effects.' . $ieid); + $this->assertTrue(count($effect['data']) && $effect['name']->getValue() == 'image_scale', 'Got data for the image scale effect from metadata.'); + $this->assertEqual($effect['data']['width']->getType(), 'integer', 'Got the right type for the scale effect width.'); + $this->assertEqual($effect['data']['width']->getValue(), 480, 'Got the right value for the scale effect width.' ); + // Try config key and getting nested properties. + $key = 'effects.' . $ieid . '.data'; + $this->assertEqual($effect['data']->getKey(), $key, 'Got the right configuration key for a nested image styple effect'); + $object = $wrapper->get($key); + $this->assertEqual(get_class($effect['data']), get_class($object), 'Got the same object class using different keys with dot notation.'); + $this->assertEqual($effect['data']->getValue(), $object->getValue(), 'Got the same object values using different keys with dot notation.'); + + // Finally update some object using a configuration wrapper. + $new_slogan = 'Site slogan for testing configuration metadata'; + $wrapper = config_wrapper('system.site'); + $wrapper->set('slogan', $new_slogan); + $site_slogan = $wrapper->get('slogan'); + $this->assertEqual($site_slogan->getValue(), $new_slogan, 'Successfully updated the contained configuration data'); + } + +} + diff --git a/core/modules/contact/meta/contact.category.%.yml b/core/modules/contact/meta/contact.category.%.yml new file mode 100644 index 0000000..6406ff3 --- /dev/null +++ b/core/modules/contact/meta/contact.category.%.yml @@ -0,0 +1,14 @@ +id: + .label: 'Id' +label: + .label: 'Label' + .type: text +recipients: + .label: 'Recipients' +reply: + .label: 'Reply' + .type: text +weight: + .label: 'Weight' + .type: integer + diff --git a/core/modules/image/meta/image.effect.image_crop.yml b/core/modules/image/meta/image.effect.image_crop.yml new file mode 100644 index 0000000..9954268 --- /dev/null +++ b/core/modules/image/meta/image.effect.image_crop.yml @@ -0,0 +1,9 @@ +width: + .label: 'Width' + .type: integer +height: + .label: 'Height' + .type: integer +anchor: + .label: 'Anchor' + diff --git a/core/modules/image/meta/image.effect.image_resize.yml b/core/modules/image/meta/image.effect.image_resize.yml new file mode 100644 index 0000000..bde4e33 --- /dev/null +++ b/core/modules/image/meta/image.effect.image_resize.yml @@ -0,0 +1,7 @@ +width: + .label: 'Width' + .type: integer +height: + .label: 'Height' + .type: integer + diff --git a/core/modules/image/meta/image.effect.image_rotate.yml b/core/modules/image/meta/image.effect.image_rotate.yml new file mode 100644 index 0000000..7c3a29c --- /dev/null +++ b/core/modules/image/meta/image.effect.image_rotate.yml @@ -0,0 +1,8 @@ +degrees: + .label: 'Rotation angle' +bgcolor: + .label: 'Background color' +random: + .label: 'Randomize' + .type: boolean + diff --git a/core/modules/image/meta/image.effect.image_scale.yml b/core/modules/image/meta/image.effect.image_scale.yml new file mode 100644 index 0000000..2d3ec8f --- /dev/null +++ b/core/modules/image/meta/image.effect.image_scale.yml @@ -0,0 +1,10 @@ +width: + .label: 'Width' + .type: integer +height: + .label: 'Height' + .type: integer +upscale: + .label: 'Upscale' + .type: boolean + diff --git a/core/modules/image/meta/image.effect.image_scale_and_crop.yml b/core/modules/image/meta/image.effect.image_scale_and_crop.yml new file mode 100644 index 0000000..bde4e33 --- /dev/null +++ b/core/modules/image/meta/image.effect.image_scale_and_crop.yml @@ -0,0 +1,7 @@ +width: + .label: 'Width' + .type: integer +height: + .label: 'Height' + .type: integer + diff --git a/core/modules/image/meta/image.style.%.yml b/core/modules/image/meta/image.style.%.yml new file mode 100644 index 0000000..92bd370 --- /dev/null +++ b/core/modules/image/meta/image.style.%.yml @@ -0,0 +1,20 @@ +name: + .label: 'Machine name' +label: + .label: 'Label' + .type: text +effects: + .label: 'Style effects' + .list: + .label: 'Image effect' + weight: + .label: 'Weight' + .type: integer + ieid: + .label: 'IEID' + name: + .label: 'Machine name' + data: + .include: 'image.effect.[%parent.name]' + .label: 'Data' + diff --git a/core/modules/locale/lib/Drupal/locale/LocaleConfigSubscriber.php b/core/modules/locale/lib/Drupal/locale/LocaleConfigSubscriber.php index 3d2fd4a..fb09c0b 100644 --- a/core/modules/locale/lib/Drupal/locale/LocaleConfigSubscriber.php +++ b/core/modules/locale/lib/Drupal/locale/LocaleConfigSubscriber.php @@ -18,6 +18,7 @@ * $config is always a DrupalConfig object. */ class LocaleConfigSubscriber implements EventSubscriberInterface { + /** * Override configuration values with localized data. * diff --git a/core/modules/locale/lib/Drupal/locale/LocaleTypedConfig.php b/core/modules/locale/lib/Drupal/locale/LocaleTypedConfig.php new file mode 100644 index 0000000..1e72c64 --- /dev/null +++ b/core/modules/locale/lib/Drupal/locale/LocaleTypedConfig.php @@ -0,0 +1,261 @@ +localeStorage = $localeStorage; + } + + /** + * Sets translation parameters. + * + * @param string $langcode + * The language code for the translation. + * @param \Drupal\locale\LocaleTypedConfig $source + * Configuration wrapper used as translation source. + */ + public function setTranslation($langcode, LocaleTypedConfig $source) { + $this->translationLangcode = $langcode; + $this->translationSource = $source; + return $this; + } + + /** + * Checks whether we can translate these languages. + * + * @param string $from_langcode + * Source language code. + * @param string $to_langcode + * Destination language code. + * + * @return boolean + * TRUE if this translator supports translations for these languages. + */ + protected function canTranslate($from_langcode, $to_langcode) { + if ($from_langcode == 'en') { + return TRUE; + } + return FALSE; + } + + /** + * Implements Drupal\Core\TypedData\TranslatableInterface::getTranslationLanguages(). + */ + public function getTranslationLanguages($include_default = TRUE) { + $languages = locale_translatable_language_list(); + $default = $this->language(); + if ($include_default) { + $languages[$default->langcode] = $default; + } + else { + unset($languages[$default->langcode]); + } + return $languages; + } + + /** + * Implements Drupal\Core\TypedData\TranslatableInterface::getTranslation(). + */ + public function getTranslation($langcode, $strict = TRUE) { + if (isset($this->translationSource)) { + return $this->translationSource->getTranslation($langcode, $strict); + } + else { + $options = array( + 'source' => $this->language()->langcode, + 'target' => $langcode, + 'strict' => $strict, + ); + $data = $this->getTranslatedData($this->get(), $options); + $translation = new self($this->name, $data); + $translation->setTranslation($langcode, $this); + return $translation; + } + } + + /** + * Implements Drupal\Core\TypedData\TranslatableInterface::language(). + */ + public function language() { + if (isset($this->translationLangcode)) { + return new Language(array('langcode' => $this->translationLangcode)); + } + else { + return $this->getLanguage(); + } + } + + /** + * Get translated configuration data. + * + * @param \Traversable $elements + * Configuration elements. + * @param array $options + * Array with options that will depend on the translator used. + * + * @return array + * Configuration data translated to the requested language. + */ + protected function getTranslatedData($elements, $options) { + $strict = !empty($options['strict']); + $translation = array(); + foreach ($elements as $key => $element) { + $value = NULL; + if ($element instanceof ElementInterface) { + $value = $this->getTranslatedData($element, $options); + } + elseif ($this->translateElement($element, $options)) { + $value = $element->getValue(); + } + elseif (!$strict) { + $value = $element->getValue(); + } + if ($value || !$strict) { + $translation[$key] = $value; + } + } + return $translation; + } + + /** + * Translates element if it fits our translation criteria. + * + * For an element to be translatable by locale module it needs to be of type + * 'text'. Translatable elements may use these additional keys in their data + * definition: + * - 'translatable', FALSE to opt out of translation. + * - 'locale context', to define the string context. + * + * @param \Drupal\Core\TypedData\TypedDataInterface $element + * Configuration element. + * @param array $options + * Array with translation options that are dependent on the translator. + * + * @return bool + * Whether the element fits the translation criteria. + */ + public function translateElement($element, $options) { + if ($this->canTranslate($options['source'], $options['target'])) { + $definition = $element->getDefinition(); + $value = $element->getValue(); + if ($value && is_string($value) && $element->getType() == 'text' && (!isset($definition['translatable']) || $definition['translatable'])) { + $context = isset($definition['locale context']) ? $definition['locale context'] : ''; + if ($translation = $this->translateString($options['target'], $value, $context)) { + $element->setValue($translation); + return TRUE; + } + } + } + // The element doesn't have a translation, if strict mode we drop it. + return empty($options['strict']); + } + + /** + * Translates string using the localization system. + * + * So far we only know how to translate strings from English so we check + * whether the source data is English. + * Unlike regular t() translations, strings will be added to the source + * tables only if this is marked as default data. + * + * @param string $langcode + * Language code to translate to. + * @param string $source + * The source string. + * @param string $context + * The string context. + * + * @return string|bool + * Translated string if there is a translation, FALSE if not. + */ + protected function translateString($langcode, $source, $context) { + if ($source) { + // Preload all translations for this configuration name and language. + if (!isset($this->translations[$langcode])) { + $this->translations[$langcode] = array(); + foreach ($this->localeStorage->getTranslations(array('language' => $langcode, 'type' => 'configuration', 'name' => $this->name)) as $string){ + $this->translations[$langcode][$string->context][$string->source] = $string; + } + } + if (!isset($this->translations[$langcode][$context][$source])) { + if ($translation = $this->localeStorage->findTranslation(array('source' => $source, 'context' => $context, 'language' => $langcode))) { + // The translation was there but the location was missing. + // Convert to SourceString because it may not have translation. + $string = $this->localeStorage->createString((array) $translation) + ->addLocation('configuration', $this->name) + ->save(); + } + else { + // Add missing source string with the right location. + $translation = $this->localeStorage + ->createString(array('source' => $source, 'context' => $context)) + ->addLocation('configuration', $this->name) + ->save(); + } + $this->translations[$langcode][$context][$source] = $translation; + } + $translation = $this->translations[$langcode][$context][$source]; + return $translation->isTranslation() ? $translation->getString() : FALSE; + } + return FALSE; + } + +} diff --git a/core/modules/locale/lib/Drupal/locale/StringBase.php b/core/modules/locale/lib/Drupal/locale/StringBase.php index e2d9bcc..2d86cb8 100644 --- a/core/modules/locale/lib/Drupal/locale/StringBase.php +++ b/core/modules/locale/lib/Drupal/locale/StringBase.php @@ -52,7 +52,7 @@ /** * The locale storage this string comes from or is to be saved to. * - * @var Drupal\locale\StringStorageInterface + * @var \Drupal\locale\StringStorageInterface */ protected $storage; @@ -189,7 +189,7 @@ public function save() { } else { throw new StringStorageException(format_string('The string cannot be saved because its not bound to a storage: @string', array( - '@string' => $string->getString() + '@string' => $this->getString() ))); } return $this; diff --git a/core/modules/locale/lib/Drupal/locale/StringStorageInterface.php b/core/modules/locale/lib/Drupal/locale/StringStorageInterface.php index d02774f..0fb26ad 100644 --- a/core/modules/locale/lib/Drupal/locale/StringStorageInterface.php +++ b/core/modules/locale/lib/Drupal/locale/StringStorageInterface.php @@ -79,7 +79,7 @@ public function getLocations(array $conditions = array()); * (optional) Array with conditions that will be used to filter the strings * returned and may include all of the conditions defined by getStrings(). * - * @return Drupal\locale\SourceString|null + * @return \Drupal\locale\SourceString|null * Minimal TranslationString object if found, NULL otherwise. */ public function findString(array $conditions); @@ -95,7 +95,7 @@ public function findString(array $conditions); * (optional) Array with conditions that will be used to filter the strings * returned and may include all of the conditions defined by getStrings(). * - * @return Drupal\locale\TranslationString|null + * @return \Drupal\locale\TranslationString|null * Minimal TranslationString object if found, NULL otherwise. */ public function findTranslation(array $conditions); @@ -103,13 +103,13 @@ public function findTranslation(array $conditions); /** * Save string object to storage. * - * @param Drupal\locale\StringInterface $string + * @param \Drupal\locale\StringInterface $string * The string object. * - * @return Drupal\locale\StringStorageInterface + * @return \Drupal\locale\StringStorageInterface * The called object. * - * @throws Drupal\locale\StringStorageException + * @throws \Drupal\locale\StringStorageException * In case of failures, an exception is thrown. */ public function save($string); @@ -117,13 +117,13 @@ public function save($string); /** * Delete string from storage. * - * @param Drupal\locale\StringInterface $string + * @param \Drupal\locale\StringInterface $string * The string object. * - * @return Drupal\locale\StringStorageInterface + * @return \Drupal\locale\StringStorageInterface * The called object. * - * @throws Drupal\locale\StringStorageException + * @throws \Drupal\locale\StringStorageException * In case of failures, an exception is thrown. */ public function delete($string); @@ -166,7 +166,7 @@ public function countTranslations(); * @param array $values * (optional) Array with initial values. Defaults to empty array. * - * @return Drupal\locale\SourceString + * @return \Drupal\locale\SourceString * New source string object. */ public function createString($values = array()); @@ -177,7 +177,7 @@ public function createString($values = array()); * @param array $values * (optional) Array with initial values. Defaults to empty array. * - * @return Drupal\locale\TranslationString + * @return \Drupal\locale\TranslationString * New string translation object. */ public function createTranslation($values = array()); diff --git a/core/modules/locale/lib/Drupal/locale/Tests/LocaleConfigTranslationTest.php b/core/modules/locale/lib/Drupal/locale/Tests/LocaleConfigTranslationTest.php new file mode 100644 index 0000000..31a58ba --- /dev/null +++ b/core/modules/locale/lib/Drupal/locale/Tests/LocaleConfigTranslationTest.php @@ -0,0 +1,154 @@ + 'Configuration translation', + 'description' => 'Tests translation of configuration strings.', + 'group' => 'Locale', + ); + } + + function setUp() { + parent::setUp(); + // Add a default locale storage for all these tests. + $this->storage = locale_storage(); + } + + /** + * Tests basic configuration translation. + */ + function testConfigTranslation() { + // Add custom language + $langcode = 'xx'; + $admin_user = $this->drupalCreateUser(array('administer languages', 'access administration pages', 'translate interface', 'administer modules')); + $this->drupalLogin($admin_user); + $name = $this->randomName(16); + $edit = array( + 'predefined_langcode' => 'custom', + 'langcode' => $langcode, + 'name' => $name, + 'direction' => '0', + ); + $this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language')); + $language = new Language(array('langcode' => $langcode)); + // Set path prefix. + $edit = array( "prefix[$langcode]" => $langcode ); + $this->drupalPost('admin/config/regional/language/detection/url', $edit, t('Save configuration')); + + // Check site name string exists and create translation for it. + $string = $this->storage->findString(array('source' => 'Drupal', 'context' => '', 'type' => 'configuration')); + $this->assertTrue($string, 'Configuration strings have been created upon installation.'); + + // Translate using the UI so configuration is refreshed. + $site_name = $this->randomName(20); + $search = array( + 'string' => $string->source, + 'langcode' => $langcode, + 'translation' => 'all', + ); + $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter')); + $textareas = $this->xpath('//textarea'); + $textarea = current($textareas); + $lid = (string) $textarea[0]['name']; + $edit = array( + $lid => $site_name, + ); + $this->drupalPost('admin/config/regional/translate/translate', $edit, t('Save translations')); + + $data = config('system.site')->get(); + $wrapper = new LocaleTypedConfig('system.site', $data, $this->storage); + + // Get strict translation and check we've got only the site name. + $translation = $wrapper->getTranslation($langcode, TRUE); + $properties = $translation->get()->getProperties(); + $this->assertEqual(count($properties), 1, 'Got the right number of properties with strict translation'); + $this->assertEqual($properties['name']->getValue(), $site_name, 'Got the right translation for site name with strict translation'); + // Get non strict translation and check we've got all properties. + $translation = $wrapper->getTranslation($langcode, FALSE); + $properties = $translation->get()->getProperties(); + $this->assertTrue(count($properties) == 7 && count($wrapper->get('page')) == 3, 'Got the right number of properties with non strict translation'); + $this->assertEqual($properties['name']->getValue(), $site_name, 'Got the right translation for site name with non strict translation'); + + // Check the translated site name is displayed. + $this->drupalGet($langcode); + $this->assertText($site_name, 'The translated site name is displayed after translations refreshed.'); + + // Assert strings from image module config are not available. + $string = $this->storage->findString(array('source' => 'Medium (220x220)', 'context' => '', 'type' => 'configuration')); + $this->assertFalse($string, 'Configuration strings have been created upon installation.'); + + // Enable the image module + $this->drupalPost('admin/modules', array('modules[Core][image][enable]' => "1"), t('Save configuration')); + $this->resetAll(); + + $string = $this->storage->findString(array('source' => 'Medium (220x220)', 'context' => '', 'type' => 'configuration')); + $this->assertTrue($string, 'Configuration strings have been created upon installation.'); + $locations = $string->getLocations(); + $this->assertTrue(isset($locations['configuration']) && isset($locations['configuration']['image.style.medium']), 'Configuration string has been created with the right location'); + // Check the string is unique and has no translation yet. + $translations = $this->storage->getTranslations(array('language' => $langcode, 'type' => 'configuration', 'name' => 'image.style.medium')); + $translation = reset($translations); + $this->assertTrue(count($translations) == 1 && $translation->source == $string->source && empty($translation->translation), 'Got only one string for image configuration and has no translation.'); + + // Translate using the UI so configuration is refreshed. + $image_style_label = $this->randomName(20); + $search = array( + 'string' => $string->source, + 'langcode' => $langcode, + 'translation' => 'all', + ); + $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter')); + $textarea = current($this->xpath('//textarea')); + $lid = (string) $textarea[0]['name']; + $edit = array( + $lid => $image_style_label, + ); + $this->drupalPost('admin/config/regional/translate/translate', $edit, t('Save translations')); + // Check the right single translation has been created. + $translations = $this->storage->getTranslations(array('language' => $langcode, 'type' => 'configuration', 'name' => 'image.style.medium')); + $translation = reset($translations); + $this->assertTrue(count($translations) == 1 && $translation->source == $string->source && $translation->translation == $image_style_label, 'Got only one translation for image configuration.'); + + // This really should be testing entity_load_multiple. + $data = config('image.style.medium')->get(); + $wrapper = new LocaleTypedConfig('image.style.medium', $data, $this->storage); + $translation = $wrapper->getTranslation($langcode, TRUE); + $property = $translation->get('label'); + $this->assertEqual($property->getValue(), $image_style_label, 'Got the right translation for image style name with strict translation'); + + // Quick test to ensure translation file exists. + $this->assertEqual(config('locale.config.xx.image.style.medium')->get('label'), $image_style_label); + + // Disable and uninstall the module. + $this->drupalPost('admin/modules', array('modules[Core][image][enable]' => FALSE), t('Save configuration')); + $this->drupalPost('admin/modules/uninstall', array('uninstall[image]' => "image"), t('Uninstall')); + $this->drupalPost(NULL, array(), t('Uninstall')); + + // Ensure that the translated configuration has been removed. + $this->assertFalse(config('locale.config.xx.image.style.medium')->get('label'), 'Translated configuration for image module removed.'); + } + +} diff --git a/core/modules/locale/locale.bulk.inc b/core/modules/locale/locale.bulk.inc index 5f03304..f1556b8 100644 --- a/core/modules/locale/locale.bulk.inc +++ b/core/modules/locale/locale.bulk.inc @@ -6,10 +6,13 @@ */ use Drupal\Component\Gettext\PoStreamWriter; +use Drupal\Component\Utility\NestedArray; +use Drupal\locale\LocaleTypedConfig; use Drupal\locale\Gettext; use Drupal\locale\PoDatabaseReader; use Drupal\Core\Language\Language; - +use Drupal\Core\Config\InstallStorage; +use Drupal\Core\Config\StorageException; /** * Form constructor for the translation import screen. @@ -123,6 +126,7 @@ function locale_translate_import_form_submit($form, &$form_state) { 'langcode' => $form_state['values']['langcode'], 'overwrite_options' => $form_state['values']['overwrite_options'], 'customized' => $form_state['values']['customized'] ? LOCALE_CUSTOMIZED : LOCALE_NOT_CUSTOMIZED, + 'refresh_configuration' => TRUE, ); $file = locale_translate_file_attach_properties($file, $options); $batch = locale_translate_batch_build(array($file->uri => $file), $options); @@ -265,6 +269,7 @@ function locale_translate_export_form_submit($form, &$form_state) { } } + /** * Prepare a batch to import all translations. * @@ -379,8 +384,12 @@ function locale_translate_get_interface_translation_files($projects = array(), $ * are customized translations or come from a community source. Use * LOCALE_CUSTOMIZED or LOCALE_NOT_CUSTOMIZED. Optional, defaults to * LOCALE_NOT_CUSTOMIZED. + * - 'refresh_configuration': Whether or not to refresh Configuration strings + * after the import. Optional, defaults to FALSE. * - 'finish_feedback': Whether or not to give feedback to the user when the * batch is finished. Optional, defaults to TRUE. + * - 'components': Array of arrays of components to refresh the configuration + * indexed by type ('module' or 'theme'). Optional, defaults to none. * * @return * A batch structure or FALSE if $files was empty. @@ -390,6 +399,7 @@ function locale_translate_batch_build($files, $options) { 'overwrite_options' => array(), 'customized' => LOCALE_NOT_CUSTOMIZED, 'finish_feedback' => TRUE, + 'refresh_configuration' => FALSE, ); $t = get_t(); if (count($files)) { @@ -400,6 +410,8 @@ function locale_translate_batch_build($files, $options) { } // Save the translation status of all files. $operations[] = array('locale_translate_batch_import_save', array()); + // Add a final step to refresh JavaScript and configuration strings. + $operations[] = array('locale_translate_batch_refresh', array($options)); $batch = array( 'operations' => $operations, @@ -536,11 +548,65 @@ function locale_translate_batch_import_save($context) { } /** + * Refresh translations after importing strings. + * + * @param array $options + * An array with options that can have the following elements: + * - 'refresh_configuration': Whether or not to refresh Configuration strings + * after the import. Optional, defaults to FALSE. + * + * @param array $context + * Contains a list of strings updated and information about the progress. + */ +function locale_translate_batch_refresh($options, &$context) { + if (!isset($context['sandbox']['refresh'])) { + $strings = $langcodes = array(); + if (isset($context['results']['stats'])) { + // Get list of unique string identifiers and language codes updated. + $langcodes = array_unique(array_values($context['results']['languages'])); + foreach ($context['results']['stats'] as $filepath => $report) { + $strings = array_merge($strings, $report['strings']); + } + } + if ($strings) { + $context['message'] = t('Updating translations for JavaScript and Configuration strings.'); + $strings = array_unique($strings); + // Clear cache and force refresh of JavaScript translations. + _locale_refresh_translations($langcodes, $strings); + // Check whether we need to refresh configuration objects. + if ($options['refresh_configuration'] && $names = _locale_config_string_names($strings)) { + $context['sandbox']['refresh']['names'] = $names; + $context['sandbox']['refresh']['languages'] = $langcodes; + $context['sandbox']['refresh']['count'] = count($names); + $context['results']['stats']['configuration'] = 0; + } + } + if (isset($context['sandbox']['refresh'])) { + // We will update configuration on next steps. + $context['finished'] = 1 / $context['sandbox']['refresh']['count']; + } + else { + $context['finished'] = 1; + } + } + elseif ($name = array_shift($context['sandbox']['refresh']['names'])) { + // Refresh all languages for one object at a time. + $count = locale_config_update_multiple(array($name), $context['sandbox']['refresh']['languages']); + $context['results']['stats']['configuration'] += $count; + // Not perfect but will give some idea of progress. + $context['finished'] = 1 - count($context['sandbox']['refresh']['names']) / $context['sandbox']['refresh']['count']; + } + else { + $context['finished'] = 1; + } +} + +/** * Finished callback of system page locale import batch. */ function locale_translate_batch_finished($success, $results) { if ($success) { - $additions = $updates = $deletes = $skips = 0; + $additions = $updates = $deletes = $skips = $configuration = 0; $strings = $langcodes = array(); if (isset($results['failed_files'])) { if (module_exists('dblog')) { @@ -591,6 +657,10 @@ function locale_translate_batch_finished($success, $results) { if ($strings) { // Clear cache and force refresh of JavaScript translations. _locale_refresh_translations($langcodes, $strings); + // Merge feedback about configuration updates too. + if (isset($results['stats']['configuration'])) { + locale_config_batch_finished($success, $results); + } } } } @@ -688,3 +758,252 @@ function locale_translate_delete_translation_files($projects = array(), $langcod } return !$fail; } + +/** + * Build a locale batch to refresh configuration. + * + * @param array $options + * An array with options that can have the following elements: + * - 'finish_feedback': Whether or not to give feedback to the user when the + * batch is finished. Optional, defaults to TRUE. + * @param array $langcodes + * Array of language codes. Defaults to all translatable languages. + * @param array $components + * (optional) Array of component lists indexed by type. If not present or it + * is an empty array, it will update all components. + * + * @return array + * The batch definition. + */ +function locale_config_batch_update_components($options, $langcodes = array(), $components = array()) { + $langcodes = $langcodes ? $langcodes : array_keys(locale_translatable_language_list()); + if ($langcodes && $names = _locale_config_component_names($components)) { + return locale_config_batch_build($names, $langcodes, $options); + } +} + +/** + * Creates a locale batch to refresh specific configuration. + * + * @param array $names + * List of configuration object names to update. + * @param array $langcodes + * List of language codes to refresh. + * @param array $options + * An array with options that can have the following elements: + * - 'finish_feedback': Whether or not to give feedback to the user when the + * batch is finished. Optional, defaults to TRUE. + * + * @return array + * The batch definition. + */ +function locale_config_batch_build($names, $langcodes, $options = array()) { + $options += array('finish_feedback' => TRUE); + $t = get_t(); + foreach ($names as $name) { + $operations[] = array('locale_config_batch_refresh_name', array($name, $langcodes)); + } + $batch = array( + 'operations' => $operations, + 'title' => $t('Updating configuration translations'), + 'init_message' => $t('Starting update'), + 'error_message' => $t('Error updating configuration translations'), + 'file' => drupal_get_path('module', 'locale') . '/locale.bulk.inc', + ); + if (!empty($options['finish_feedback'])) { + $batch['completed'] = 'locale_config_batch_finished'; + } + return $batch; +} + +/** + * Perform configuration translation refresh as a batch step. + * + * @param array $name + * Name of configuration object to update. + * @param array $langcodes + * (optional) Array of language codes to update. Defaults to all languages. + * @param $context + * Contains a list of files imported. + */ +function locale_config_batch_refresh_name($name, $langcodes, &$context) { + if (!isset($context['result']['stats']['configuration'])) { + $context['result']['stats']['configuration'] = 0; + } + $context['result']['stats']['configuration'] += locale_config_update_multiple(array($name), $langcodes); + $context['result']['names'][] = $name; + $context['result']['langcodes'] = $langcodes; + $context['finished'] = 1; +} + +/** + * Finished callback of system page locale import batch. + * + * @param bool $success + * Information about the success of the batch import. + * @param array $results + * Information about the results of the batch import. + */ +function locale_config_batch_finished($success, $results) { + if ($success) { + $configuration = isset($results['stats']['configuration']) ? $results['stats']['configuration'] : 0; + if ($configuration) { + drupal_set_message(t('The configuration was successfully updated. There are %number configuration objects updated.', array('%number' => $configuration))); + watchdog('locale', 'The configuration was successfully updated. %number configuration objects updated.', array('%number' => $configuration)); + } + else { + drupal_set_message(t('No configuration objects have been updated.')); + watchdog('locale', 'No configuration objects have been updated.', array(), WATCHDOG_WARNING); + } + } +} + +/** + * Delete configuration for language. + * + * @param $langcode + * Language code to delete. + */ +function locale_config_delete_language($langcode) { + $locale_name = 'locale.config.' . $langcode; + $storage = drupal_container()->get('config.storage'); + foreach ($storage->listAll($locale_name) as $name) { + $storage->delete($name); + } +} + +/** + * Update all configuration for names / languages. + * + * @param array $names + * Array of names of configuration objects to update. + * @param array $langcodes + * (optional) Array of language codes to update. Defaults to all languages. + * @return int + * Number of configuration objects retranslated. + */ +function locale_config_update_multiple($names, $langcodes = array()) { + $default_storage = new InstallStorage(); + $config_storage = drupal_container()->get('config.storage'); + $langcodes = $langcodes ? $langcodes : array_keys(locale_translatable_language_list()); + $count = 0; + foreach ($names as $name) { + $wrapper = NULL; + try { + if ($data = $default_storage->read($name)) { + $updated = $config_storage->read($name); + if ($data = _locale_config_data_compare($data, $updated)) { + $wrapper = new LocaleTypedConfig($name, $data, locale_storage()); + } + } + foreach ($langcodes as $langcode) { + $locale_name = 'locale.config.' . $langcode . '.' . $name; + $translation = $wrapper ? $wrapper->getTranslation($langcode, TRUE)->getData() : NULL; + if ($translation) { + $config_storage->write($locale_name, $translation); + $count++; + } + else { + $config_storage->delete($locale_name); + } + } + } + catch (StorageException $e) { + // The configuration name does not exist, just continue. + } + } + return $count; +} + +/** + * Compare default configuration with updated data. + * + * @param array $default + * Default configuration data. + * @param array $updated + * Current configuration data. + * + * @return array + * The elements of default configuration that haven't changed. + */ +function _locale_config_data_compare($default, $updated) { + // Speed up comparison, specially for install operations. + if ($default === $updated) { + return $default; + } + $result = array(); + foreach ($default as $key => $value) { + if (isset($updated[$key])) { + if (is_array($value)) { + $result[$key] = _locale_config_data_compare($value, $updated[$key]); + } + elseif ($value === $updated[$key]) { + $result[$key] = $value; + } + } + } + return $result; +} + +/** + * Delete configuration for uninstalled components. + * + * @param array $components + * Array with string identifiers. + */ +function locale_config_delete_components($components) { + $names = _locale_config_component_names($components); + $langcodes = array_keys(locale_translatable_language_list()); + if ($names && $langcodes) { + $storage = drupal_container()->get('config.storage'); + foreach ($names as $name) { + foreach ($langcodes as $langcode) { + $storage->delete('locale.config.' . $langcode . '.' . $name); + } + } + } +} + +/** + * Get configuration names associated with components. + * + * @param array $components + * Array with string identifiers. + * + * @return array + * Array of configuration object names. + */ +function _locale_config_component_names($components) { + $storage = new InstallStorage(); + $components = array_filter($components); + if ($components) { + $names = array(); + foreach ($components as $type => $list) { + // InstallStorage::getComponentNames returns a list of folders keyed by + // config name. + $names = array_merge($names, array_keys($storage->getComponentNames($type, $list))); + } + return $names; + } + else { + return $storage->listAll(); + } +} + +/** + * Get configuration names associated with strings. + * + * @param array $lids + * Array with string identifiers. + * + * @return array + * Array of configuration object names. + */ +function _locale_config_string_names($lids) { + $names = array(); + $locations = locale_storage()->getLocations(array('sid' => $lids, 'type' => 'configuration')); + foreach ($locations as $location) { + $names[$location->name] = $location->name; + } + return $names; +} diff --git a/core/modules/locale/locale.module b/core/modules/locale/locale.module index dac0557..0bd250d 100644 --- a/core/modules/locale/locale.module +++ b/core/modules/locale/locale.module @@ -314,6 +314,9 @@ function locale_language_delete($language) { module_load_include('inc', 'locale', 'locale.bulk'); locale_translate_delete_translation_files(array(), array($language->langcode)); + // Remove translated configuration objects. + locale_config_delete_language($language->langcode); + // Changing the language settings impacts the interface: _locale_invalidate_js($language->langcode); cache('page')->deleteAll(); @@ -463,19 +466,20 @@ function locale_get_plural($count, $langcode = NULL) { return $plural_indexes[$langcode][$count]; } - /** * Implements hook_modules_installed(). */ function locale_modules_installed($modules) { - locale_system_update($modules); + $components['module'] = $modules; + locale_system_update($components); } /** * Implements hook_modules_uninstalled(). */ function locale_modules_uninstalled($modules) { - locale_system_remove($modules); + $components['module'] = $modules; + locale_system_remove($components); } /** @@ -485,14 +489,16 @@ function locale_modules_uninstalled($modules) { * initial installation. The theme system is missing an installation hook. */ function locale_themes_enabled($themes) { - locale_system_update($themes); + $components['theme'] = $themes; + locale_system_update($components); } /** * Implements hook_themes_disabled(). */ function locale_themes_disabled($themes) { - locale_system_remove($themes); + $components['theme'] = $themes; + locale_system_remove($components); } /** @@ -501,26 +507,31 @@ function locale_themes_disabled($themes) { * This function will start a batch to import translations for the added * components. * - * @param array $components - * An array of component (theme and/or module) names to import - * translations for. + * @param $components + * An array of arrays of component (theme and/or module) names to import + * translations for, indexed by type. */ function locale_system_update($components) { + $components += array('module' => array(), 'theme' => array()); + $list = array_merge($components['module'], $components['theme']); // Skip running the translation imports if in the installer, // because it would break out of the installer flow. We have // built-in support for translation imports in the installer. if (!drupal_installation_attempted() && locale_translatable_language_list()) { module_load_include('compare.inc', 'locale'); - // Update the list of translatable projects and start the import batch. // Only when new projects are added the update batch will be triggered. Not // each enabled module will introduce a new project. E.g. sub modules. $projects = array_keys(locale_translation_build_projects()); - if ($components = array_intersect($components, $projects)) { + if ($list = array_intersect($list, $projects)) { module_load_include('fetch.inc', 'locale'); // Get translation status of the projects, download and update translations. $options = _locale_translation_default_update_options(); - $batch = locale_translation_batch_update_build($components, array(), $options); + $batch = locale_translation_batch_update_build($list, array(), $options); + batch_set($batch); + } + module_load_include('bulk.inc', 'locale'); + if ($batch = locale_config_batch_update_components(array(), array(), $components)) { batch_set($batch); } } @@ -534,37 +545,40 @@ function locale_system_update($components) { * modules and we have no record of which string is used by which module. * * @param array $components - * An array of component (theme and/or module) names to remove - * translation history. + * An array of arrays of component (theme and/or module) names to import + * translations for, indexed by type. */ function locale_system_remove($components) { + $components += array('module' => array(), 'theme' => array()); + $list = array_merge($components['module'], $components['theme']); if (locale_translatable_language_list()) { module_load_include('compare.inc', 'locale'); - + module_load_include('bulk.inc', 'locale'); + // Delete configuration translations. + locale_config_delete_components($components); // Only when projects are removed, the translation files and records will be // deleted. Not each disabled module will remove a project. E.g. sub modules. $projects = array_keys(locale_translation_get_projects()); - if ($components = array_intersect($components, $projects)) { - locale_translation_file_history_delete($components); + if ($list = array_intersect($list, $projects)) { + locale_translation_file_history_delete($list); // Remove translation files. - module_load_include('inc', 'locale', 'locale.bulk'); - locale_translate_delete_translation_files($components, array()); + locale_translate_delete_translation_files($list, array()); // Remove translatable projects. // Followup issue http://drupal.org/node/1842362 to replace the // {locale_project} table. Then change this to a function call. db_delete('locale_project') - ->condition('name', $components) + ->condition('name', $list) ->execute(); // Clear the translation status. - locale_translation_status_delete_projects($components); + locale_translation_status_delete_projects($list); } + } } - /** * Implements hook_js_alter(). * @@ -761,6 +775,12 @@ function locale_form_language_admin_add_form_alter_submit($form, $form_state) { $options = _locale_translation_default_update_options(); $batch = locale_translation_batch_update_build(array(), array($langcode), $options); batch_set($batch); + + // Create or update all configuration translations for this language. + module_load_include('bulk.inc', 'locale'); + if ($batch = locale_config_batch_update_components($options, array($langcode))) { + batch_set($batch); + } } /** @@ -1034,6 +1054,27 @@ function _locale_refresh_translations($langcodes, $lids) { } /** + * Refresh configuration after string translations have been updated. + * + * The information that will be refreshed includes: + * - JavaScript translations. + * - Locale cache. + * + * @param array $langcodes + * Language codes for updated translations. + * @param array $lids + * List of string identifiers that have been updated / created. + */ +function _locale_refresh_configuration($langcodes, $lids) { + if ($lids && $langcodes) { + include_once drupal_get_path('module', 'locale') . '/locale.bulk.inc'; + if ($names = _locale_config_string_names($lids)) { + locale_config_update_multiple($names, $langcodes); + } + } +} + +/** * Parses a JavaScript file, extracts strings wrapped in Drupal.t() and * Drupal.formatPlural() and inserts them into the database. * diff --git a/core/modules/locale/locale.pages.inc b/core/modules/locale/locale.pages.inc index cf4edce..2752954 100644 --- a/core/modules/locale/locale.pages.inc +++ b/core/modules/locale/locale.pages.inc @@ -439,7 +439,8 @@ function locale_translate_edit_form_submit($form, &$form_state) { } if ($updated) { - // Clear cache and force refresh of JavaScript translations. + // Clear cache and refresh configuration and JavaScript translations. + _locale_refresh_configuration(array($langcode), $updated); _locale_refresh_translations(array($langcode), $updated); } diff --git a/core/modules/system/meta/system.maintenance.yml b/core/modules/system/meta/system.maintenance.yml new file mode 100644 index 0000000..2b0922a --- /dev/null +++ b/core/modules/system/meta/system.maintenance.yml @@ -0,0 +1,7 @@ +enabled: + .label: 'Put site into maintenance mode' + .type: boolean +message: + .label: 'Message to display when in maintenance mode' + .type: text + diff --git a/core/modules/system/meta/system.site.yml b/core/modules/system/meta/system.site.yml new file mode 100644 index 0000000..483e22a --- /dev/null +++ b/core/modules/system/meta/system.site.yml @@ -0,0 +1,17 @@ +name: + .label: 'Site name' + .type: text +mail: + .label: 'Site mail' +slogan: + .label: 'Site slogan' + .type: text +page: + .label: 'Default pages' + 403: + .label: 'Default 403 (access denied) page' + 404: + .label: 'Default 404 (not found) page' + front: + .label: 'Default front page' + diff --git a/core/modules/system/system.module b/core/modules/system/system.module index c3df4c1..ae9b050 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -2029,6 +2029,12 @@ function system_data_type_info() { 'class' => '\Drupal\Core\TypedData\Type\String', 'primitive type' => Primitive::STRING, ), + 'text' => array( + 'label' => t('Text'), + 'description' => t('Human readable string.'), + 'class' => '\Drupal\Core\TypedData\Type\String', + 'primitive type' => Primitive::STRING, + ), 'integer' => array( 'label' => t('Integer'), 'class' => '\Drupal\Core\TypedData\Type\Integer', @@ -2110,6 +2116,12 @@ function system_data_type_info() { 'class' => '\Drupal\Core\Entity\Field\Type\EntityReferenceItem', 'list class' => '\Drupal\Core\Entity\Field\Type\Field', ), + 'config_element' => array( + 'label' => t('Configuration element'), + 'description' => t('Configuration data'), + 'class' => 'Drupal\Core\Config\Metadata\ConfigElement', + 'list class' => 'Drupal\Core\Config\Metadata\ListElement', + ), ); } diff --git a/core/modules/user/meta/user.mail.yml b/core/modules/user/meta/user.mail.yml new file mode 100644 index 0000000..ad75311 --- /dev/null +++ b/core/modules/user/meta/user.mail.yml @@ -0,0 +1,10 @@ +.label: 'User mails' +.list: + .label: 'Mail text' + subject: + .label: 'Subject' + .type: text + body: + .label: 'Body' + .type: text +