From fd1ea018265f7bf249acbb201c7fbd4d768d1a80 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 27 Apr 2021 23:03:27 -0700 Subject: [PATCH] dev/translation#67 - Define "Translation" entity. Add during installation/upgrade. This creates an entity, `Translation` (`civicrm_translation`), to represent a single translated database value. Loosely speaking, any field in the database can be designated as translatable -- and then it will be permitted to store values like: ```sql INSERT INTO civicrm_translation (entity_table, entity_id, entity_field, language, string) VALUES ('civicrm_event', 100, 'title', 'fr_FR', 'La nouvelle chaine') ``` This is based on a `civi-data-translate` strings table, but with some changes: * Entity names are usually singular, but `String` is conflicted. I previously used hybrid String/Strings (depending on context), but we negotiated `Translation` on tcon. * The language only needs 5 characters (NN_nn). * Consolidated `bool is_active` and `bool is_default` into one `int status_id`. * Added indexing * Mark dynamic foreign key This commit includes the BAO with some of the backing-methods required for API exposure. However, the API won't really work until we have the validation-values event, so the API has been kicked to a subsequent PR. The list of translatable entities/fields will be signficant because it will determine when/how to redirect data in API calls. This patch does not commit to specific translatable fields - but it does provide a hook to determine them. When the API PR becomes unblocked, it will include test-coverage that hits the API, BAO, and hook. --- CRM/Core/BAO/Translation.php | 107 ++++++ CRM/Core/DAO/AllCoreTables.data.php | 5 + CRM/Core/DAO/Translation.php | 320 ++++++++++++++++++ .../Incremental/sql/5.39.alpha1.mysql.tpl | 13 + CRM/Utils/Hook.php | 22 ++ xml/schema/Core/Translation.xml | 115 +++++++ xml/schema/Core/files.xml | 1 + 7 files changed, 583 insertions(+) create mode 100644 CRM/Core/BAO/Translation.php create mode 100644 CRM/Core/DAO/Translation.php create mode 100644 xml/schema/Core/Translation.xml diff --git a/CRM/Core/BAO/Translation.php b/CRM/Core/BAO/Translation.php new file mode 100644 index 0000000000..c9db2cb98a --- /dev/null +++ b/CRM/Core/BAO/Translation.php @@ -0,0 +1,107 @@ + 1, 'name' => 'active', 'label' => ts('Active')], + ['id' => 2, 'name' => 'draft', 'label' => ts('Draft')], + ]; + return self::formatPsuedoconstant($context, $options); + } + + /** + * Get a list of tables with translatable strings. + * + * @return string[] + * Ex: ['civicrm_event' => 'civicrm_event'] + */ + public static function getEntityTables() { + if (!isset(Civi::$statics[__CLASS__]['allTables'])) { + $tables = array_keys(self::getTranslatedFields()); + Civi::$statics[__CLASS__]['allTables'] = array_combine($tables, $tables); + } + return Civi::$statics[__CLASS__]['allTables']; + } + + /** + * Get a list of fields with translatable strings. + * + * @return string[] + * Ex: ['title' => 'title', 'description' => 'description'] + */ + public static function getEntityFields() { + if (!isset(Civi::$statics[__CLASS__]['allFields'])) { + $allFields = []; + foreach (self::getTranslatedFields() as $columns) { + foreach ($columns as $column => $sqlExpr) { + $allFields[$column] = $column; + } + } + Civi::$statics[__CLASS__]['allFields'] = $allFields; + } + return Civi::$statics[__CLASS__]['allFields']; + } + + /** + * Given a constant list of of id/name/label options, convert to the + * format required by pseudoconstant. + * + * @param string|NULL $context + * @param array $options + * List of options, each as a record of id+name+label. + * Ex: [['id' => 123, 'name' => 'foo_bar', 'label' => 'Foo Bar']] + * + * @return array|false + */ + private static function formatPsuedoconstant($context, array $options) { + // https://docs.civicrm.org/dev/en/latest/framework/pseudoconstant/#context + $key = ($context === 'match') ? 'name' : 'id'; + $value = ($context === 'validate') ? 'name' : 'label'; + return array_combine(array_column($options, $key), array_column($options, $value)); + } + + /** + * @return array + * List of data fields to translate, organized by table and column. + * Omitted/unlisted fields are not translated. Any listed field may be translated. + * Values should be TRUE. + * Ex: $fields['civicrm_event']['summary'] = TRUE + */ + public static function getTranslatedFields() { + $key = 'translatedFields'; + $cache = Civi::cache('fields'); + if (($r = $cache->get($key)) !== NULL) { + return $r; + } + + $f = []; + \CRM_Utils_Hook::translateFields($f); + + // Future: Assimilate defaults originating in XML (incl extension-entities) + // e.g. CRM_Core_I18n_SchemaStructure::columns() will grab core fields + + $cache->set($key, $f); + return $f; + } + +} diff --git a/CRM/Core/DAO/AllCoreTables.data.php b/CRM/Core/DAO/AllCoreTables.data.php index fdbda3a704..5ea4a2ebc0 100644 --- a/CRM/Core/DAO/AllCoreTables.data.php +++ b/CRM/Core/DAO/AllCoreTables.data.php @@ -47,6 +47,11 @@ return [ 'class' => 'CRM_Core_DAO_SystemLog', 'table' => 'civicrm_system_log', ], + 'CRM_Core_DAO_Translation' => [ + 'name' => 'Translation', + 'class' => 'CRM_Core_DAO_Translation', + 'table' => 'civicrm_translation', + ], 'CRM_Core_DAO_Worldregion' => [ 'name' => 'Worldregion', 'class' => 'CRM_Core_DAO_Worldregion', diff --git a/CRM/Core/DAO/Translation.php b/CRM/Core/DAO/Translation.php new file mode 100644 index 0000000000..91fdd010ae --- /dev/null +++ b/CRM/Core/DAO/Translation.php @@ -0,0 +1,320 @@ +__table = 'civicrm_translation'; + parent::__construct(); + } + + /** + * Returns localized title of this entity. + * + * @param bool $plural + * Whether to return the plural version of the title. + */ + public static function getEntityTitle($plural = FALSE) { + return $plural ? ts('Translated Strings') : ts('Translated String'); + } + + /** + * Returns foreign keys and entity references. + * + * @return array + * [CRM_Core_Reference_Interface] + */ + public static function getReferenceColumns() { + if (!isset(Civi::$statics[__CLASS__]['links'])) { + Civi::$statics[__CLASS__]['links'] = static::createReferenceColumns(__CLASS__); + Civi::$statics[__CLASS__]['links'][] = new CRM_Core_Reference_Dynamic(self::getTableName(), 'entity_id', NULL, 'id', 'entity_table'); + CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'links_callback', Civi::$statics[__CLASS__]['links']); + } + return Civi::$statics[__CLASS__]['links']; + } + + /** + * Returns all the column names of this table + * + * @return array + */ + public static function &fields() { + if (!isset(Civi::$statics[__CLASS__]['fields'])) { + Civi::$statics[__CLASS__]['fields'] = [ + 'id' => [ + 'name' => 'id', + 'type' => CRM_Utils_Type::T_INT, + 'description' => ts('Unique String ID'), + 'required' => TRUE, + 'where' => 'civicrm_translation.id', + 'table_name' => 'civicrm_translation', + 'entity' => 'Translation', + 'bao' => 'CRM_Core_DAO_Translation', + 'localizable' => 0, + 'readonly' => TRUE, + 'add' => '5.39', + ], + 'entity_table' => [ + 'name' => 'entity_table', + 'type' => CRM_Utils_Type::T_STRING, + 'title' => ts('Entity Table'), + 'description' => ts('Table where referenced item is stored'), + 'required' => TRUE, + 'maxlength' => 64, + 'size' => CRM_Utils_Type::BIG, + 'where' => 'civicrm_translation.entity_table', + 'table_name' => 'civicrm_translation', + 'entity' => 'Translation', + 'bao' => 'CRM_Core_DAO_Translation', + 'localizable' => 0, + 'pseudoconstant' => [ + 'callback' => 'CRM_Core_BAO_Translation::getEntityTables', + ], + 'add' => '5.39', + ], + 'entity_field' => [ + 'name' => 'entity_field', + 'type' => CRM_Utils_Type::T_STRING, + 'title' => ts('Entity Field'), + 'description' => ts('Field where referenced item is stored'), + 'required' => TRUE, + 'maxlength' => 64, + 'size' => CRM_Utils_Type::BIG, + 'where' => 'civicrm_translation.entity_field', + 'table_name' => 'civicrm_translation', + 'entity' => 'Translation', + 'bao' => 'CRM_Core_DAO_Translation', + 'localizable' => 0, + 'pseudoconstant' => [ + 'callback' => 'CRM_Core_BAO_Translation::getEntityFields', + ], + 'add' => '5.39', + ], + 'entity_id' => [ + 'name' => 'entity_id', + 'type' => CRM_Utils_Type::T_INT, + 'description' => ts('ID of the relevant entity.'), + 'required' => TRUE, + 'where' => 'civicrm_translation.entity_id', + 'table_name' => 'civicrm_translation', + 'entity' => 'Translation', + 'bao' => 'CRM_Core_DAO_Translation', + 'localizable' => 0, + 'add' => '5.39', + ], + 'language' => [ + 'name' => 'language', + 'type' => CRM_Utils_Type::T_STRING, + 'title' => ts('Language'), + 'description' => ts('Relevant language'), + 'required' => TRUE, + 'maxlength' => 5, + 'size' => CRM_Utils_Type::SIX, + 'where' => 'civicrm_translation.language', + 'table_name' => 'civicrm_translation', + 'entity' => 'Translation', + 'bao' => 'CRM_Core_DAO_Translation', + 'localizable' => 0, + 'html' => [ + 'type' => 'Select', + ], + 'pseudoconstant' => [ + 'optionGroupName' => 'languages', + 'keyColumn' => 'name', + 'optionEditPath' => 'civicrm/admin/options/languages', + ], + 'add' => '5.39', + ], + 'status_id' => [ + 'name' => 'status_id', + 'type' => CRM_Utils_Type::T_INT, + 'description' => ts('Specify whether the string is active, draft, etc'), + 'required' => TRUE, + 'where' => 'civicrm_translation.status_id', + 'default' => '1', + 'table_name' => 'civicrm_translation', + 'entity' => 'Translation', + 'bao' => 'CRM_Core_DAO_Translation', + 'localizable' => 0, + 'pseudoconstant' => [ + 'callback' => 'CRM_Core_BAO_Translation::getStatuses', + ], + 'add' => '5.39', + ], + 'string' => [ + 'name' => 'string', + 'type' => CRM_Utils_Type::T_LONGTEXT, + 'title' => ts('String'), + 'description' => ts('Translated string'), + 'required' => TRUE, + 'where' => 'civicrm_translation.string', + 'table_name' => 'civicrm_translation', + 'entity' => 'Translation', + 'bao' => 'CRM_Core_DAO_Translation', + 'localizable' => 0, + 'add' => '5.39', + ], + ]; + CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']); + } + return Civi::$statics[__CLASS__]['fields']; + } + + /** + * Return a mapping from field-name to the corresponding key (as used in fields()). + * + * @return array + * Array(string $name => string $uniqueName). + */ + public static function &fieldKeys() { + if (!isset(Civi::$statics[__CLASS__]['fieldKeys'])) { + Civi::$statics[__CLASS__]['fieldKeys'] = array_flip(CRM_Utils_Array::collect('name', self::fields())); + } + return Civi::$statics[__CLASS__]['fieldKeys']; + } + + /** + * Returns the names of this table + * + * @return string + */ + public static function getTableName() { + return self::$_tableName; + } + + /** + * Returns if this table needs to be logged + * + * @return bool + */ + public function getLog() { + return self::$_log; + } + + /** + * Returns the list of fields that can be imported + * + * @param bool $prefix + * + * @return array + */ + public static function &import($prefix = FALSE) { + $r = CRM_Core_DAO_AllCoreTables::getImports(__CLASS__, 'translation', $prefix, []); + return $r; + } + + /** + * Returns the list of fields that can be exported + * + * @param bool $prefix + * + * @return array + */ + public static function &export($prefix = FALSE) { + $r = CRM_Core_DAO_AllCoreTables::getExports(__CLASS__, 'translation', $prefix, []); + return $r; + } + + /** + * Returns the list of indices + * + * @param bool $localize + * + * @return array + */ + public static function indices($localize = TRUE) { + $indices = [ + 'index_entity_lang' => [ + 'name' => 'index_entity_lang', + 'field' => [ + 0 => 'entity_id', + 1 => 'entity_table', + 2 => 'language', + ], + 'localizable' => FALSE, + 'sig' => 'civicrm_translation::0::entity_id::entity_table::language', + ], + ]; + return ($localize && !empty($indices)) ? CRM_Core_DAO_AllCoreTables::multilingualize(__CLASS__, $indices) : $indices; + } + +} diff --git a/CRM/Upgrade/Incremental/sql/5.39.alpha1.mysql.tpl b/CRM/Upgrade/Incremental/sql/5.39.alpha1.mysql.tpl index da338b180b..7e22d80264 100644 --- a/CRM/Upgrade/Incremental/sql/5.39.alpha1.mysql.tpl +++ b/CRM/Upgrade/Incremental/sql/5.39.alpha1.mysql.tpl @@ -1 +1,14 @@ {* file to handle db changes in 5.39.alpha1 during upgrade *} + +CREATE TABLE `civicrm_translation` ( + `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'Unique String ID', + `entity_table` varchar(64) NOT NULL COMMENT 'Table where referenced item is stored', + `entity_field` varchar(64) NOT NULL COMMENT 'Field where referenced item is stored', + `entity_id` int NOT NULL COMMENT 'ID of the relevant entity.', + `language` varchar(5) NOT NULL COMMENT 'Relevant language', + `status_id` tinyint NOT NULL DEFAULT 1 COMMENT 'Specify whether the string is active, draft, etc', + `string` longtext NOT NULL COMMENT 'Translated string', + PRIMARY KEY (`id`), + INDEX `index_entity_lang`(entity_id, entity_table, language) +) +ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; diff --git a/CRM/Utils/Hook.php b/CRM/Utils/Hook.php index 746290b023..93595de721 100644 --- a/CRM/Utils/Hook.php +++ b/CRM/Utils/Hook.php @@ -1805,6 +1805,28 @@ abstract class CRM_Utils_Hook { ); } + /** + * Define the list of fields supported in APIv4 data-translation. + * + * @param array $fields + * List of data fields to translate, organized by table and column. + * Omitted/unlisted fields are not translated. Any listed field may be translated. + * Values should be TRUE-ish. + * Ex: $fields['civicrm_event']['summary'] = TRUE + * Ex: $fields['civicrm_event']['summary'] = 'yesplease'; + * + * At time of writing, the `$fields` list x is prepopulated based on `` fields in core's `xml/schema`. + * In the future, it may also be prepopulated with `` fields in ext's `xml/schema`. + * For the interim, you may wish to fill-in `` fields from ext's. + */ + public static function translateFields(&$fields) { + return self::singleton()->invoke(['fields'], $fields, self::$_nullObject, + self::$_nullObject, self::$_nullObject, self::$_nullObject, + self::$_nullObject, + 'civicrm_translateFields' + ); + } + /** * This hook allows changes to the spec of which tables to log. * diff --git a/xml/schema/Core/Translation.xml b/xml/schema/Core/Translation.xml new file mode 100644 index 0000000000..8f35723b6f --- /dev/null +++ b/xml/schema/Core/Translation.xml @@ -0,0 +1,115 @@ + + + + 5.39 + CRM/Core + Translation + civicrm_translation + Translated String + Translated Strings + Each string record is an alternate translation of some displayable string in the database. + true + + + 5.39 + id + int unsigned + true + Unique String ID + + + id + true + + + + 5.39 + entity_table + varchar + 64 + true + + CRM_Core_BAO_Translation::getEntityTables + + Table where referenced item is stored + + + + 5.39 + entity_field + varchar + 64 + true + + CRM_Core_BAO_Translation::getEntityFields + + Field where referenced item is stored + + + + 5.39 + entity_id + int + 64 + true + ID of the relevant entity. + + + + 5.39 + language + varchar + 5 + true + Relevant language + + Select + + + languages + name + civicrm/admin/options/languages + + + + + 5.39 + status_id + tinyint + 3 + 1 + true + + CRM_Core_BAO_Translation::getStatuses + + Specify whether the string is active, draft, etc + + + + 5.39 + string + longtext + true + Translated string + + + + 5.39 + entity_id + entity_table + + + + 5.39 + + index_entity_lang + + + entity_id + entity_table + language + +
diff --git a/xml/schema/Core/files.xml b/xml/schema/Core/files.xml index 51a0ba7ebe..05ae0fac88 100644 --- a/xml/schema/Core/files.xml +++ b/xml/schema/Core/files.xml @@ -37,6 +37,7 @@ + -- 2.25.1