l10n_server-2.x-dev/l10n_community/src/Controller/L10nCommunityLanguagesController.php
l10n_community/src/Controller/L10nCommunityLanguagesController.php
<?php declare(strict_types=1); namespace Drupal\l10n_community\Controller; use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Cache\Cache; use Drupal\Core\Config\ConfigManagerInterface; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Database\Connection; use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Render\Renderer; use Drupal\Core\Url; use Drupal\group\Entity\Group; use Drupal\l10n_community\L10nTranslator; use Drupal\l10n_server\Entity\L10nServerProject; use Drupal\l10n_server\Entity\L10nServerProjectInterface; use Drupal\l10n_server\Entity\L10nServerReleaseInterface; use Drupal\l10n_server\Entity\L10nServerStringInterface; use Drupal\l10n_server\Entity\L10nServerTranslationInterface; use Drupal\l10n_server\L10nPo; use Drupal\language\Entity\ConfigurableLanguage; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\Response; /** * Returns responses for Localization community UI routes. */ class L10nCommunityLanguagesController extends ControllerBase { /** * The database connection. * * @var \Drupal\Core\Database\Connection */ protected Connection $connection; /** * The config manager. * * @var \Drupal\Core\Config\ConfigManagerInterface */ protected ConfigManagerInterface $configManager; /** * Renderer service. * * @var Drupal\Core\Render\Renderer */ protected Renderer $renderer; /** * Translator service. * * @var Drupal\l10n_community\L10nTranslator */ protected L10nTranslator $translator; /** * L10n helper. * * @var \Drupal\l10n_server\L10nPo */ protected $l10nPo; /** * The controller constructor. * * @param \Drupal\Core\Database\Connection $connection * Database connection. * @param \Drupal\Core\Config\ConfigManagerInterface $config_manager * The config manager. * @param \Drupal\Core\Render\Renderer $renderer * The renderer service. * @param \Drupal\l10n_community\L10nTranslator $translator * The translator service. * @param \Drupal\l10n_server\L10nPo $l10n_po * L10n helper. */ public function __construct( Connection $connection, ConfigManagerInterface $config_manager, Renderer $renderer, L10nTranslator $translator, L10nPo $l10n_po ) { $this->connection = $connection; $this->configManager = $config_manager; $this->renderer = $renderer; $this->translator = $translator; $this->l10nPo = $l10n_po; } /** * {@inheritdoc} */ public static function create( ContainerInterface $container ) { return new static( $container->get('database'), $container->get('config.manager'), $container->get('renderer'), $container->get('l10n_community.translator'), $container->get('l10n_server.po') ); } /** * Order listing table by column for language overview columns. */ public static function sortByColumnLanguage($a, $b) { $sortkey = ($_GET['order'] == t('Language') ? 0 : ($_GET['order'] == t('Contributors') ? 2 : 1)); if (@$a[$sortkey]['sortdata'] == @$b[$sortkey]['sortdata']) { return 0; } return ((@$a[$sortkey]['sortdata'] < @$b[$sortkey]['sortdata']) ? -1 : 1) * ($_GET['sort'] == 'asc' ? 1 : -1); } /** * Builds the response. */ public function explore() { /** @var \Drupal\l10n_community\L10nStatistics $statistics */ $statistics = \Drupal::service('l10n_community.statistics'); // Checking whether we have languages to translate to. if (!$languages = \Drupal::languageManager()->getLanguages()) { $build['content'] = [ '#type' => 'item', '#markup' => $this->t('No languages to list.'), ]; return $build; } // Checking whether we have strings to translate. if (!$num_source = $statistics->getStringCount()) { $build['content'] = [ '#type' => 'item', '#markup' => $this->t('No strings to translate.'), ]; return $build; } // Generate listing of all languages with summaries. The list of languages // is relatively "short", compared to projects, so we don't need a pager // here. $table_rows = []; $string_counts = $statistics->getLanguagesStringCount(); foreach ($languages as $langcode => $language) { // Need to load this again to get access to the third party settings :/. $language = ConfigurableLanguage::load($langcode); if (!$language->getThirdPartySetting('l10n_pconfig', 'formula')) { $table_rows[] = [ [ 'data' => t('@language', [ '@language' => $language->getName(), ]), 'sortdata' => t('@language', [ '@language' => $language->getName(), ]), 'class' => ['rowhead'], ], [ 'data' => t('Uninitialized plural formula. Please set up the plural formula in <a href="@language-config">the langauge configuration</a> or alternatively <a href="@import-url">import a valid interface translation</a> for Drupal in this language.', [ '@import-url' => Url::fromUri('internal:/admin/structure/translate/import')->toString(), '@language-config' => Url::fromUri('internal:/admin/config/regional/language')->toString(), ]), 'class' => ['error'], ], ['data' => ''], ]; } else { $stats = $statistics->getLanguageStatisticsByLanguage($langcode); $progress = [ 'data' => [ '#theme' => 'l10n_community_progress_columns', '#sum' => $stats['strings'], '#translated' => $stats['translations'], '#has_suggestion' => $stats['suggestions'], ], ]; $table_rows[] = [ [ 'data' => new FormattableMarkup('<a href=":link">@name</a>', [ ':link' => Url::fromUri('internal:/translate/languages/' . $langcode)->toString(), '@name' => $language->getName(), ]), 'sortdata' => t('@language', [ '@language' => $language->getName(), ]), 'class' => ['rowhead'], ], [ 'data' => $progress, 'sortdata' => ($num_source == 0 ? 0 : round(@$string_counts[$langcode]['translations'] / $num_source * 100, 2)), ], [ 'data' => $stats['users'], 'sortdata' => $stats['users'], ], ]; } } if (!empty($_GET['sort']) && !empty($_GET['order'])) { usort($table_rows, static::class . '::sortByColumnLanguage'); } $header = [ [ 'data' => t('Language'), 'class' => ['rowhead'], 'field' => 'language', ], [ 'data' => t('Overall progress'), 'field' => 'progress', ], [ 'data' => t('Contributors'), 'field' => 'contributors', ], ]; $build['content'] = [ '#type' => 'table', '#header' => $header, '#rows' => $table_rows, '#attributes' => [ 'class' => [ 'l10n-community-overview l10n-community-highlighted', ], ], '#cache' => [ 'max-age' => Cache::PERMANENT, ], ]; $build['#attached']['library'][] = 'l10n_community/tables'; return $build; } /** * Builds the response. * * @param \Drupal\group\Entity\Group $group * The group entity. * * @return array * An associative render array. * * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException * @throws \Drupal\Core\TypedData\Exception\MissingDataException */ public function translate(Group $group): array { /** @var \Drupal\l10n_community\L10nTranslator $translator */ $translator = \Drupal::service('l10n_community.translator'); $langcode = $group->get('field_translation_language')->first()->getValue()['target_id']; $language = \Drupal::languageManager()->getLanguage($langcode); $filters = self::buildFilterValues($_GET); $drupal_settings = [ 'l10nServerURLs' => $this->addUrlModifiers($langcode, $filters), ]; $strings = $translator->getStrings($langcode, $filters, $filters['limit']); // Add RTL style if the current language's direction is RTL. if ($language->getDirection() == LanguageInterface::DIRECTION_RTL) { $build['#attached']['library'][] = 'l10n_community/editor-rtl'; } // Set the most appropriate title. if ($filters['project']) { $project = L10nServerProject::load($filters['project']); $build['#title'] = $this->t('Translate %project to @language', [ '%project' => $project->label(), '@language' => $language->getName(), ]); } else { $build['#title'] = $this->t('Translate to @language', [ '@language' => $language->getName(), ]); } // Add the filter form. $build['filter'] = \Drupal::formBuilder()->getForm('Drupal\l10n_community\Form\FilterForm'); // Output the actual strings. if (!count($strings)) { \Drupal::messenger()->addError($this->t('No strings found with this filter. Try adjusting the filter options.')); } else { $build['translate_form'] = \Drupal::formBuilder()->getForm('Drupal\l10n_community\Form\TranslateForm', $language, $filters, $strings); } $build['#attached']['drupalSettings'] = $drupal_settings; return $build; } /** * Title callback. * * @return \Drupal\Core\StringTranslation\TranslatableMarkup * Page title. */ public function translateTitle() { return $this->t('Translate'); } /** * Builds the response. * * @param \Drupal\group\Entity\Group $group * The group entity. * * @return array * An associative render array. */ public function import(Group $group): array { $group = \Drupal::routeMatch()->getParameter('group'); $langcode = $group->get('field_translation_language')->first()->getValue()['target_id']; return \Drupal::formBuilder()->getForm('Drupal\l10n_community\Form\ImportForm', $langcode); } /** * Title callback. * * @return \Drupal\Core\StringTranslation\TranslatableMarkup * Page title. * * @throws \Drupal\Core\TypedData\Exception\MissingDataException */ public function importTitle() { /** @var \Drupal\group\Entity\Group $group */ $group = \Drupal::routeMatch()->getParameter('group'); $langcode = $group->get('field_translation_language')->first()->getValue()['target_id']; $language = \Drupal::languageManager()->getLanguage($langcode); return $this->t('Import to @language', ['@language' => $language->getName()]); } /** * Builds the response. * * @param \Drupal\group\Entity\Group $group * The group entity. * * @return array * An associative render array. * * @throws \Drupal\Core\TypedData\Exception\MissingDataException */ public function export(Group $group): array { $langcode = $group->get('field_translation_language')->first()->getValue()['target_id']; return \Drupal::formBuilder()->getForm('Drupal\l10n_community\Form\ExportForm', NULL, $langcode); } /** * Title callback. * * @return \Drupal\Core\StringTranslation\TranslatableMarkup * Page title. */ public function exportTitle() { /** @var \Drupal\group\Entity\Group $group */ $group = \Drupal::routeMatch()->getParameter('group'); $langcode = $group->get('field_translation_language')->first()->getValue()['target_id']; $language = \Drupal::languageManager()->getLanguage($langcode); return $this->t('Export @language translations', ['@language' => $language->getName()]); } /** * Builds the response. * * @param \Drupal\group\Entity\Group $group * @param \Drupal\l10n_server\Entity\L10nServerProjectInterface $project * @param \Drupal\l10n_server\Entity\L10nServerReleaseInterface $release * * @return array * * @throws \Drupal\Core\TypedData\Exception\MissingDataException */ public function reset(Group $group, L10nServerProjectInterface $project, L10nServerReleaseInterface $release) { $langcode = $group->get('field_translation_language')->first()->getValue()['target_id']; $language = \Drupal::languageManager()->getLanguage($langcode); $build['content'] = [ '#type' => 'item', '#markup' => __METHOD__ . '::' . $group->label() . '::' . $project->label() . '::' . $release->label() . '::' . $language->getId(), ]; return $build; } /** * Check and sanitize arguments and build filter array. * * @param array $params * Associative array with unsanitized values. * * @return array * * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException */ public static function buildFilterValues(array $params) { $project = $release = NULL; // Convert array representation of flags to one integer. if (isset($params['status']) && is_array($params['status'])) { if (isset($params['status']['suggestion'])) { $params['status'] = ((int) $params['status']['translation']) | ((int) $params['status']['suggestion']); } else { $params['status'] = (int) $params['status']['translation']; } } $filter = [ 'project' => NULL, 'status' => isset($params['status']) ? (int) $params['status'] : 0, 'release' => 'all', 'search' => !empty($params['search']) ? (string) $params['search'] : '', 'author' => NULL, // Dropdown, validated by form API. 'context' => isset($params['context']) ? (string) $params['context'] : 'all', 'limit' => (isset($params['limit']) && in_array($params['limit'], [5, 10, 20, 30, 50])) ? (int) $params['limit'] : 10, 'sid' => (!empty($params['sid']) && is_numeric($params['sid'])) ? $params['sid'] : 0, ]; if (isset($params['author'])) { $user_storage = \Drupal::entityTypeManager() ->getStorage('user'); if (is_numeric($params['author'])) { $user = $user_storage->load($params['author']); } else { $users = $user_storage->loadByProperties(['name' => $params['author']]); $user = reset($users); } if (isset($user)) { $filter['author'] = $user->id(); } } // The project can be a dropdown or text field depending on number of // projects. So we need to sanitize its value. if (isset($params['project'])) { // Try to load project by uri or id, but give URI priority. URI is used // to shorten the URL and have simple redirects. ID is used if the // filter form was submitted. $project_storage = \Drupal::entityTypeManager() ->getStorage('l10n_server_project'); if (is_numeric($params['project'])) { $project = $project_storage->load($params['project']); } else { $projects = $project_storage->loadByProperties(['uri' => $params['project']]); $project = reset($projects); } /** @var \Drupal\l10n_server\Entity\L10nServerProject $project */ if ($project instanceof \Drupal\l10n_server\Entity\L10nServerProject) { $filter['project'] = $project->id(); $release_storage = \Drupal::entityTypeManager() ->getStorage('l10n_server_release'); if (isset($params['release']) && ($releases = $release_storage->loadByProperties(['pid' => $project->id(), 'rid' => $params['release']]))) { // Allow to select this release, if belongs to current project only. $filter['release'] = $params['release']; } } } return $filter; } /** * Generate and add JS for URL replacements. * * These ensure we keep filter values. * * @param string $langcode * @param array $filters * * @return array */ public function addUrlModifiers(string $langcode, array $filters) { $filters = self::flatFilters($filters); $urls = [ 'translate/languages/' . $langcode . '/translate', 'translate/languages/' . $langcode . '/export', ]; $replacements = []; foreach ($urls as $url) { $replacements[Url::fromUri('internal:/' . $url)->toString()] = Url::fromUri('internal:/' . $url, ['query' => $filters])->toString(); } return $replacements; } /** * Replace complex data filters (objects or arrays) with string representations. * * @param array $filters * Associative array with filters passed. * * @return array * The modified filter array only containing string and number values. */ public static function flatFilters(array $filters) { // foreach (['project' => 'uri', 'author' => 'name'] as $name => $key) { // if (!empty($filters[$name])) { // $filters[$name] = isset($filters[$name]->$key) ?? $filters[$name]->$key; // } // } return $filters; } /** * Provides full history information about translation. * * This callback is invoked from AJAX. * * @param \Drupal\l10n_server\Entity\L10nServerTranslationInterface $translation * Translation entity. * * @return \Symfony\Component\HttpFoundation\Response * Response to AJAX call. */ public function translationDetails(L10nServerTranslationInterface $translation) { $string = $translation->sid->entity; $uid_submitted = 0; $history_list = []; foreach ($translation->getHistory() as $item) { $username = $item->uid_action?->entity?->label() ?? ''; // @todo séparer cette fonction du formulaire ? $history_list[] = $this->l10nPo->translateByline($username, $item->uid_action->target_id, $item->time_action->value, $item->medium_action->value, $item->type_action->value); if ($item->type_action->value == L10N_SERVER_ACTION_ADD) { // Remember the first uid who submitted this. This will get overwritten, // and since we are going backwards in time, the last value kept will be // the first uid who submitted this string. We need this in case it is // different from the user we used for attribution, so we can display // the difference to the translator/moderator. $uid_submitted = $item->uid_action->value; } } if (!empty($uid_submitted) && ($uid_submitted != $string->uid_entered->value)) { // Turns out the user used for attribution is different from the user used // for the string storage. array_unshift($history_list, $this->t('by @author', [ '@author' => $string->uid_entered->entity->toLink()->toString(), ])); } $output = [ '#theme' => 'item_list', '#items' => $history_list, ]; return new Response($this->renderer->renderPlain($output)); } /** * Generate a list of projects and releases where a string appears. * * We could provide much more information (down to line numbers of files), but * usability should also be kept in mind. It is possible to investigate hidden * information sources though, like tooltips on the release titles presented. * * This callback is invoked from AJAX. * * @param \Drupal\l10n_server\Entity\L10nServerStringInterface $string * String entity. * * @return \Symfony\Component\HttpFoundation\Response * Response to AJAX call. */ public function sourceDetails(L10nServerStringInterface $string) { $version_list = []; $project_list = []; $previous_project = ''; foreach ($this->translator->getSourceDetails($string->id()) as $instance) { $release_info = $instance->version . ' <span title="' . $this->formatPlural($instance->occurrence_count, 'Appears once in this release.', 'Appears @count times in this release.') . '">(' . $instance->occurrence_count . ')</span>'; if ($instance->project_title != $previous_project) { if (!empty($version_list)) { $project_list[] = ['#markup' => implode(', ', $version_list)]; } $version_list = ['<em>' . $instance->project_title . ':</em> ' . $release_info]; } else { $version_list[] = $release_info; } $previous_project = $instance->project_title; } $project_list[] = ['#markup' => implode(', ', $version_list)]; $usage_list = [ '#theme' => 'item_list', '#items' => $project_list, ]; return new Response($this->renderer->renderPlain($usage_list)); } }