l10n_server-2.x-dev/l10n_community/src/Form/TranslateForm.php
l10n_community/src/Form/TranslateForm.php
<?php namespace Drupal\l10n_community\Form; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Messenger\MessengerInterface; use Drupal\Core\Render\Element; use Drupal\Core\Render\RendererInterface; use Drupal\Core\Routing\RouteMatch; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Security\TrustedCallbackInterface; use Drupal\Core\Session\AccountProxyInterface; use Drupal\Core\Url; use Drupal\l10n_community\L10nAccess; use Drupal\l10n_community\L10nTranslator; use Drupal\l10n_server\L10nPo; use Drupal\locale\PluralFormulaInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\RequestStack; /** * Translation form. */ class TranslateForm extends FormBase implements TrustedCallbackInterface { /** * Entity type manager service. * * @var \Drupal\Core\Entity\EntityTypeManagerInterface */ protected $entityTypeManager; /** * Translator service (l10n_community). * * @var \Drupal\l10n_community\L10nTranslator */ protected $translator; /** * L10N access manager. * * @var \Drupal\l10n_community\L10nAccess */ protected $accessManager; /** * Messenger service. * * @var \Drupal\Core\Messenger\MessengerInterface */ protected $messenger; /** * Current user. * * @var \Drupal\Core\Session\AccountProxyInterface */ protected $currentUser; /** * Plural formula service. * * @var \Drupal\locale\PluralFormulaInterface */ protected $pluralFormula; /** * Current route match service. * * @var \Drupal\Core\Routing\RouteMatchInterface */ protected $currentRouteMatch; /** * Renderer service. * * @var \Drupal\Core\Render\RendererInterface */ protected $renderer; /** * L10n helper. * * @var \Drupal\l10n_server\L10nPo */ protected $l10nPo; /** * Current request object. * * @var \Symfony\Component\HttpFoundation\Request */ protected $currentRequest; /** * Constructor. */ public function __construct(EntityTypeManagerInterface $entity_type_manager, L10nTranslator $translator, L10nAccess $access_manager, MessengerInterface $messenger, AccountProxyInterface $current_user, PluralFormulaInterface $plural_formula, RouteMatchInterface $current_route_match, RendererInterface $renderer, L10nPo $l10n_po, RequestStack $request_stack) { $this->entityTypeManager = $entity_type_manager; $this->translator = $translator; $this->accessManager = $access_manager; $this->messenger = $messenger; $this->currentUser = $current_user; $this->pluralFormula = $plural_formula; $this->currentRouteMatch = $current_route_match; $this->renderer = $renderer; $this->l10nPo = $l10n_po; $this->currentRequest = $request_stack->getCurrentRequest(); } /** * {@inheritdoc} */ public static function create(ContainerInterface $container) { return new static( $container->get('entity_type.manager'), $container->get('l10n_community.translator'), $container->get('l10n_community.access'), $container->get('messenger'), $container->get('current_user'), $container->get('locale.plural.formula'), $container->get('current_route_match'), $container->get('renderer'), $container->get('l10n_server.po'), $container->get('request_stack') ); } /** * {@inheritdoc} */ public function getFormId() { return 'l10n_community_translate'; } /** * Form callback: List translations and suggestions. * * @param array $form * Form. * @param \Drupal\Core\Form\FormStateInterface $form_state * The form state object. * @param \Drupal\Core\Language\LanguageInterface $language * A language object. * @param array $filters * An array of filters applied to the strings. * @param array $strings * An array of string records from database. * * @return array * Form render array. */ public function buildForm(array $form, FormStateInterface $form_state, ?LanguageInterface $language = NULL, array $filters = [], array $strings = []) { $langcode = $language->getId(); // The form will show but the submit buttons will only appear if the user // has permissions to submit suggestions. This allows the use of this form // to review strings in the database. $form = [ 'langcode' => [ '#type' => 'value', '#value' => $langcode, ], 'pager_top' => [ '#weight' => -10, '#type' => 'pager', ], 'strings' => [ '#tree' => TRUE, '#type' => 'table', '#header' => $this->getHeader(), '#attributes' => ['class' => ['l10n-table']], '#weight' => 0, ], 'submit' => [ '#type' => 'submit', '#value' => t('Save changes'), '#access' => $this->accessManager->check('submit suggestions'), ], 'pager_bottom' => [ '#weight' => 10, '#type' => 'pager', ], ]; $this->setRows($form, $form_state, $strings, $language, $filters); // Useful during validation. $form_state->set('source_strings', $this->extractSourceStrings($strings)); return $form; } /** * Get source strings. * * @param array $strings * An array of string records from database. * * @return array * Source strings, keyed by IDs. */ protected function extractSourceStrings(array $strings) { $source_strings = []; foreach ($strings as $string) { $source_strings[$string->sid] = $string->value[0]; } return $source_strings; } /** * Get header for translation form. * * @return array * Header. */ protected function getHeader() { return [ [ 'data' => $this->t('Source text'), 'colspan' => 2, ], $this->t('Translations'), ]; } /** * Generate rows for translation form. * * @param array $form * Form. * @param \Drupal\Core\Form\FormStateInterface $form_state * The form state object. * @param array $strings * Source strings retrieved from the database. * @param \Drupal\Core\Language\LanguageInterface $language * A language object. * @param array $filters * An array of filters applied to the strings. * * @return array * Rows. */ protected function setRows(array &$form, FormStateInterface &$form_state, array $strings, LanguageInterface $language, array $filters) { $rows = []; // Fill in string values for the editor. foreach ($strings as $string) { $form_item = $this->formItem($form_state, $string, $language, $filters); $sid = $form_item['#string']->sid; $form['strings'][$sid] = [ 'sid' => [ '#type' => 'link', '#title' => '#', '#url' => Url::fromRoute('l10n_community.language.translate.translate', ['group' => $this->currentRouteMatch->getRawParameter('group')], [ 'query' => ['sid' => $sid], 'attributes' => [ 'title' => $this->t('Direct and permanent link to this string.'), ], ] ), '#wrapper_attributes' => ['class' => ['sid']], ], 'source' => [ 'actions' => $this->translateActions($form_item['source'], TRUE), 'string' => [ '#type' => 'html_tag', '#tag' => 'label', '#attributes' => [ 'class' => [ 'l10n-string', $form_item['filters_match']['#value'] ? 'filter-match' : 'filter-no-match', ], ], 'inner' => $form_item['source']['string'], ], 'context' => empty($form_item['#string']->context) ? [] : [ '#type' => 'html_tag', '#tag' => 'div', '#value' => $this->t('in context: @context', ['@context' => $form_item['#string']->context]), '#attributes' => [ 'class' => ['string-context'], ], ], 'usage' => [ '#type' => 'container', '#attributes' => ['class' => ['l10n-usage']], 'link' => [ '#type' => 'link', '#title' => $this->t('Show related projects'), '#url' => Url::fromRoute('l10n_community.source_details', ['string' => $sid], [ 'attributes' => [ 'class' => ['l10n-more-link'], 'title' => $this->t('Show list of projects and releases where this text is used.'), ], ]), ], 'more_info' => [ '#type' => 'container', '#attributes' => ['class' => ['l10n-more-info']], ], ], '#wrapper_attributes' => ['class' => ['source']], ], 'translation' => $this->translationList($form_item) + [ '#wrapper_attributes' => ['class' => ['translation']], '#pre_render' => [[$this, 'renderList']], ], ]; } return $rows; } /** * Creates the form fragment for a source string. * * @param \Drupal\Core\Form\FormStateInterface $form_state * The form state object. * @param object $source * Source string retrieved from the database. * @param \Drupal\Core\Language\LanguageInterface $language * A language object. * @param array $filters * An array of filters applied to the strings. * * @return array * Form item. */ protected function formItem(FormStateInterface &$form_state, $source, LanguageInterface $language, array $filters) { $langcode = $language->getId(); if (!$source->translation) { // If there is no picked translation yet, simulate with a visible // placeholder. $source->tid = '0'; $source->translation = [$this->t('(not translated)')]; $source->status = '1'; $source->suggestion = '0'; } else { $source->translation = $this->unpackString($source->translation); } $source_unpacked = $source->value; $source->value = $this->unpackString($source->value); $form = [ '#string' => $source, '#langcode' => $langcode, 'filters_match' => [ '#type' => 'value', '#value' => ($source_match = (!empty($filters['search']) && (stripos($source_unpacked, $filters['search']) !== FALSE))), ], 'source' => [ 'string' => $this->renderTextarray($source->value), ], ]; if ($this->accessManager->check('submit suggestions')) { $form['source']['edit'] = [ '#type' => 'html_tag', '#tag' => 'label', '#value' => $this->t('Translate'), '#attributes' => [ 'title' => $this->t('Translate'), ], ]; } // Add the current string (either as approved translation or a mock object // for the "untranslated" string). $form[$source->tid] = $this->translateTranslation($form_state, $source, $source, $filters, $source_match); // Format suggestions. if (!empty($source->suggestions)) { foreach ($source->suggestions as $suggestion) { $suggestion->translation = $this->unpackString($suggestion->translation); // Add the suggestion to the list. $form[$suggestion->tid] = $this->translateTranslation($form_state, $suggestion, $source, $filters, $source_match); } } // If the user may add new suggestions, display a textarea. if ($this->accessManager->check('submit suggestions')) { $nplurals = $this->pluralFormula->getNumberOfPlurals($langcode); $textarea = $this->translateTranslationTextarea($source, $nplurals); $form[$textarea->tid] = $this->translateTranslation($form_state, $textarea, $source, $filters, $source_match); } return $form; } /** * Appropriate actions for the given string element. * * @param array $source * Source string data. * @param bool $theme_as_list * Whether to theme the actions as an item list. * * @return array * Render array */ protected function translateActions(array $source, bool $theme_as_list = FALSE) { $actions = []; foreach (['declined', 'edit'] as $type) { if (isset($source[$type])) { $actions[] = $source[$type] + [ '#wrapper_attributes' => [ 'class' => [$type], ], ]; } } if (!empty($actions)) { if ($theme_as_list) { $actions = [ '#theme' => 'item_list', '#items' => $actions, ]; } $actions['#attributes']['class'] = ['actions']; } return $actions; } /** * Generate a list of suggestions for a string. * * @param array $form_item * Form item generated by self::formItem(). * * @return array * Render array. */ protected function translationList(array $form_item) { $items = []; foreach (Element::children($form_item) as $child) { if (is_numeric($child) || $child == 'new') { $items[$child] = $this->translationListItem($form_item[$child]); } } return $items; } /** * Unpacks a string as retrieved from the database. * * Creates an array out of the string. If it was a single string, the array * will have one item. If the string was a plural string, the array will have * as many items as the language requires (two for source strings). * * @param string $string * The string with optional separation markers (NULL bytes) * * @return array * An array of strings with one element for each plural form in case of * a plural string, or one element in case of a regular string. This * is called a $textarray elsewhere. */ protected function unpackString($string) { return explode("\0", $string); } /** * Packs a string for storage in the database. * * @param array $strings * An array of strings. * * @return string * A packed string with NULL bytes separating each string. */ protected function packString(array $strings) { return implode("\0", $strings); } /** * Generate markup for an unpacked string. * * @param array $textarray * An array of strings as generated by unpacktring(). * @param string $empty * string Specific data to include as the data to use when empty. * * @return array * A render array. */ protected function renderTextarray(array $textarray, $empty = '') { $return = []; foreach ($textarray as $index => $value) { $span = [ '#type' => 'html_tag', '#tag' => 'span', '#value' => $value, ]; // data-empty is a proprietary attribute used in editor.css to be // displayed when starting to submit a new suggestion. if (!empty($empty)) { $span['#attributes']['data-empty'] = $empty; } if ($index) { // Do not add a <br> tag before the first element. $return[] = [ '#type' => 'html_tag', '#tag' => 'br', ]; } $return[] = $span; } return $return; } /** * Creates the form fragment for a translated string. * * @param \Drupal\Core\Form\FormStateInterface $form_state * The form state object. * @param object $string * Suggestion when there is one. * @param object $source * Source string retrieved from the database. * @param array $filters * An array of filters applied to the strings. * @param bool $source_match * Whether the strings matches with what? * * @return array * Form fragment. */ protected function translateTranslation(FormStateInterface &$form_state, $string, $source, array $filters, $source_match) { $is_own = $this->currentUser->id() == $string->uid; $is_active = $string->status && !$string->suggestion; $is_new = $string->tid == 'new'; $may_moderate = ($is_own ? $this->accessManager->check('moderate own suggestions') : $this->accessManager->check('moderate suggestions from others')); $may_moderate_suggestions = ($is_own ? $this->currentUser->hasPermission('decline own suggestions') : $this->currentUser->hasPermission('moderate suggestions from others')); // This string is a match if it was data from the database and was entered // by the searched user or its text included the searched portion. $filters_matched = []; $filters_to_match = 0; if ((int) $string->tid > 0) { // If we had a real tid for this, it has a uid and translation // loaded. if (!empty($filters['author'])) { $filters_matched[] = $string->uid == $filters['author']->id(); $filters_to_match++; } if (!empty($filters['search'])) { // Search is matched if the source matched the search and the // translation matched the user or the translation matched both. $filters_matched[] = (!empty($filters['author']) && $source_match) || (stripos($this->packString($string->translation), $filters['search']) !== FALSE); $filters_to_match++; } $filters_matched = array_filter($filters_matched); } $form = [ 'original' => [ '#type' => 'value', '#value' => $string, ], 'filters_match' => [ '#type' => 'value', '#value' => ($filters_to_match > 0) && (count($filters_matched) == $filters_to_match), ], ]; // Active radio box is used to pick the approved translation. $form['active'] = [ '#type' => 'radio', // #title does not support render arrays... '#title' => $this->render($this->renderTextarray($string->translation, $is_new ? $this->t('(empty)') : FALSE)), '#return_value' => $string->tid, '#default_value' => $is_active ? $string->tid : NULL, '#parents' => ['strings', $string->sid, 'active'], // Let a moderator roll back to the current translation even if they // would otherwise not have permission to approve such a string. '#disabled' => !$may_moderate && !$is_active, '#attributes' => ['class' => ['selector']], '#label_attributes' => ['class' => ['l10n-string']], '#title_display' => $is_new ? 'invisible' : 'before', ]; if ($string->tid) { if (($may_moderate || $may_moderate_suggestions) && $string->tid != 'new') { $form['declined'] = [ '#type' => 'checkbox', '#title' => t('Decline'), '#default_value' => !($string->status || $string->suggestion), ]; } if ($string->tid == 'new') { // Fill in with as many textareas as required to enter translation // for this string. $form['value'] = array_fill(0, count($string->translation), [ '#type' => 'textarea', '#cols' => 60, '#rows' => 3, '#default_value' => $this->t('<New translation>'), ]); } else { if ($this->accessManager->check('submit suggestions')) { $form['edit'] = [ '#type' => 'html_tag', '#tag' => 'label', '#value' => $this->t('Edit a copy'), '#attributes' => ['title' => $this->t('Edit a copy')], ]; } if (isset($string->username)) { $title = $this->l10nPo->translateByline($string->username, $string->uid, $string->created, -1, -1, FALSE); $form['author'] = [ '#type' => 'container', '#attributes' => [ 'class' => ['l10n-byline'], ], 'link' => [ '#type' => 'link', '#title' => $title, '#url' => Url::fromRoute('l10n_community.translation_details', ['translation' => $string->tid], [ 'attributes' => [ 'class' => ['l10n-more-link'], 'title' => $this->t('Show full history for translation.'), ], ]), ], 'more_info' => [ '#type' => 'container', '#attributes' => [ 'class' => ['l10n-more-info'], ], ], ]; } } } return $form; } /** * Format a suggestion/translation. * * @param array $element * Form item to format. * * @return array * Render array */ protected function translationListItem(array $element) { $element['#attributes']['class'][] = 'translation'; // Add is-selectable and is-declinable helpers for JS. if (!$element['active']['#disabled']) { $element['#attributes']['class'][] = 'is-selectable'; } if (isset($element['declined'])) { $element['#attributes']['class'][] = 'is-declinable'; } // Add information on whether this matched the filter. if ((int) $element['active']['#return_value'] > 0) { $element['#attributes']['class'][] = ($element['filters_match']['#value']) ? 'filter-match' : 'filter-no-match'; } switch ($element['active']['#return_value']) { case 'new': $element['#attributes']['class'][] = 'new-translation'; break; case '0': $element['#attributes']['class'][] = 'no-translation'; // Fallthrough. default: if (!empty($element['active']['#value'])) { $element['#attributes']['class'][] = 'is-active default'; } } return [ '#wrapper_attributes' => $element['#attributes'], 'actions' => $this->translateActions($element), 'original' => $element['original'] ?? [], // Add the radio box to pick the active translation. 'active' => $element['active'], 'author' => empty($element['author']) ? [] : [ '#type' => 'container', '#attributes' => ['class' => ['author']], 'inner' => $element['author'], ], 'value' => empty($element['value']) ? [] : $element['value'], ]; } /** * Build mock object for new textarea. * * @param object $source * Source string ? * @param int $nplurals * Number of plurals. * * @return object * String object. */ protected function translateTranslationTextarea($source, $nplurals) { return (object) [ 'sid' => $source->sid, 'tid' => 'new', // Fill in with as many items as required. If the source was plural, we // need to fill in with a number adequate for this language. 'translation' => array_fill(0, count($source->value) > 1 ? $nplurals : 1, ''), 'status' => '1', 'suggestion' => '1', 'uid' => $this->currentUser->id(), ]; } /** * {@inheritdoc} */ public static function trustedCallbacks() { return ['renderList']; } /** * Render items as an unordered list. * * @param array $element * Part of render array to be formatted as a list. * * @return array * Array with formatted element. */ public static function renderList(array $element) { $items = []; foreach (Element::children($element) as $child) { $items[] = $element[$child]; unset($element[$child]); } $element['rendered_list'] = [ '#theme' => 'item_list', '#items' => $items, ]; return $element; } /** * Helper function when rendering cannot be delayed. * * Sometimes a render array is not accepted (e.g. #title for a form). * * @param array $element * Element to be rendered. * * @return string * Rendered element. */ protected function render(array $element) { return $this->renderer->renderPlain($element); } /** * {@inheritdoc} */ public function validateForm(array &$form, FormStateInterface $form_state) { $strings = $form_state->getValue('strings'); // Original strings. $originals = $form_state->get('source_strings'); // Iterate outer structure built in l10n_community_translate_form(). $pattern = '/([!@%:]|<(ins|del)>[!@%:]<\/(ins|del)>)([\w-]+|<(ins|del)>[\w-]+<\/(ins|del)>)/'; foreach ($strings as $sid => $string) { $matches = []; // If there are variables in the original string, check in new // suggestions. We need to explode the strings as they may contain plurals // and check the corresponding plural. $originals_plurals = $this->unpackString($originals[$sid]); foreach ($originals_plurals as $sourceid => $sourcevalue) { if (preg_match_all($pattern, $sourcevalue, $matches)) { foreach ($string['translation'] as $tid => $options) { // Only process new suggestions. if (isset($options['value']) && is_array($options['value']) && $tid === 'new') { $not_found = []; $value = $options['value'][$sourceid]; if ($value !== (string) t('<New translation>')) { foreach ($matches[0] as $match) { if (!strstr($value, $match)) { $not_found[] = $match; } } } if ($not_found) { $form_state->setErrorByName('strings][' . $sid . '][translation][new][value][0', $this->t('Missing variable(s) @variables', ['@variables' => implode(', ', $not_found)])); } } } } } } } /** * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { $langcode = $form_state->getValue('langcode'); // Iterate outer structure built in self::setRows(). foreach ($form_state->getValue('strings') as $sid => $string) { // Iterate inner structure built in self::formItem(). // Form items have numeric $tid values and other keys here. foreach ($string['translation'] as $tid => $options) { // Store new suggestion. $all_values = 0; $empty_values = 0; // $options['value'] is the result of (a series of) textareas. if (isset($options['value']) && is_array($options['value'])) { foreach ($options['value'] as $key => $value) { $all_values++; if ($value === (string) t('<New translation>')) { $options['value'] = ''; $empty_values++; } } // If we had value in any of the textareas, add new suggestion. if ($tid === 'new' && $all_values > $empty_values) { $tid = $this->l10nPo->addSuggestion($sid, $this->packString($options['value']), $langcode, $this->currentUser->id(), $this->currentUser->id(), L10N_SERVER_MEDIUM_WEB); if ($tid) { if ($string['active'] === 'new') { // This new string was selected to be approved, so remember $tid // for later, so we can save this as an approved translation. $string['active'] = $tid; $this->l10nPo->counter(L10N_COMMUNITY_COUNT_ADDED); } else { $this->l10nPo->counter(L10N_COMMUNITY_COUNT_SUGGESTED); } } elseif ($tid === FALSE) { // We found this as an active string already in the DB. $this->l10nPo->counter(L10N_COMMUNITY_COUNT_DUPLICATE); } } } if (is_numeric($tid) && $tid > 0) { if ($tid == $string['active']) { if ($options['original']->suggestion) { // $tid is a suggestion that was made active. $this->l10nPo->approveString($langcode, $sid, $tid); $this->l10nPo->counter(L10N_COMMUNITY_COUNT_APPROVED); } } elseif (!empty($options['declined'])) { // The decline checkbox for this suggestion was checked. $this->l10nPo->counter($options['original']->suggestion ? L10N_COMMUNITY_COUNT_SUGGESTION_DECLINED : L10N_COMMUNITY_COUNT_DECLINED); $this->l10nPo->declineString($langcode, $sid, $tid, $user->uid); } } } } // Tell the user what happened. $this->l10nPo->updateMessage(); // Keep existing filters and other query arguments on form submission. $route_match = RouteMatch::createFromRequest($this->currentRequest); $form_state->setRedirect('l10n_community.language.translate.translate', ['group' => $route_match->getRawParameter('group')], ['query' => $this->currentRequest->query->all()]); } }