l10n_server-2.x-dev/l10n_community/src/L10nExporter.php
l10n_community/src/L10nExporter.php
<?php declare(strict_types=1); namespace Drupal\l10n_community; use Drupal\Core\Config\ConfigManagerInterface; use Drupal\Core\Database\StatementInterface; use Drupal\Core\Language\Language; use Drupal\l10n_server\Entity\L10nServerLine; use Drupal\l10n_server\Entity\L10nServerProjectInterface; use Drupal\l10n_server\Entity\L10nServerReleaseInterface; use Drupal\locale\PluralFormulaInterface; /** * Service description. */ class L10nExporter { /** * The config manager. * * @var \Drupal\Core\Config\ConfigManagerInterface */ protected ConfigManagerInterface $configManager; /** * The plural formula service. * * @var \Drupal\locale\PluralFormulaInterface */ protected PluralFormulaInterface $pluralFormula; /** * Constructs a L10nExport object. * * @param \Drupal\Core\Config\ConfigManagerInterface $config_manager * The config manager. * @param \Drupal\locale\PluralFormulaInterface $plural_formula * The plural formula service. */ public function __construct(ConfigManagerInterface $config_manager, PluralFormulaInterface $plural_formula) { $this->configManager = $config_manager; $this->pluralFormula = $plural_formula; } /** * Generates the PO(T) files content and wrap in tarball for a given project. * * @param \Drupal\l10n_server\Entity\L10nServerProjectInterface $project * Project entity to extract releases from. * @param \Drupal\Core\Language\Language $language * Language object. * @param \Drupal\l10n_server\Entity\L10nServerReleaseInterface|null $release * Release object to generate tarball for, or NULL to generate * with all releases considered. * @param bool $full_export * TRUE if the export should contain translated and untranslated string, * FALSE if only translations should be exported. * @param bool $compact * A compact export will skip outputting the comments, superfluous * newlines, empty translations and the list of files. TRUE or FALSE. * @param bool $limit_to_installer * Whether we should only export the translations needed for the installer * and not those needed for the runtime site. * @param bool $include_suggestions * Whether to include suggestions. * * @return array|null * An associative array of file data and metadata. */ public function export(L10nServerProjectInterface $project, Language $language, ?L10nServerReleaseInterface $release = NULL, bool $full_export = TRUE, bool $compact = FALSE, bool $limit_to_installer = FALSE, bool $include_suggestions = FALSE) { $strings = $this->queryStrings($project, $language, $full_export, $limit_to_installer, $include_suggestions, $release); $previous_sid = $sid_count = 0; $export_string = $po_data = []; /** @var \Drupal\l10n_server\Entity\L10nServerStringInterface $string */ foreach ($strings as $string) { if ($string->sid != $previous_sid) { // New string in the stream. Store all the info about *the previous one* // (if any). $this->exportStringFiles($po_data, $project->getUri(), $language, $full_export, $export_string, $compact, $include_suggestions); // Now fill in the new string values. $previous_sid = $string->sid; $export_string = [ 'comment' => [], 'value' => $string->value, 'context' => $string->context, 'translation' => (!empty($string->translation) && !$string->suggestion) ? $string->translation : '', 'suggestions' => [], 'revisions' => [], 'changed' => $string->time_approved ?? 0, 'type' => $string->type, 'has_suggestion' => $string->has_suggestion ?? FALSE, ]; // Count this source string with this first occurrence found. $sid_count++; } else { // Existing string but with new location information. if ($export_string['type'] != 0 && $export_string['type'] != $string->type) { // Elevate string type if it is not already 0 (POTX_STRING_BOTH), and // the currently found string type is different to the previously // found. $export_string['type'] = 0; } } // Uniquely collected, so we use array keys for speed. $export_string['comment'][$string->location][$string->lineno] = 1; $export_string['revisions'][$string->revision] = 1; if ($string->suggestion) { $export_string['suggestions'][$string->translation] = 1; } } if ($previous_sid > 0) { // Store the last string because that only has all its accumulated // information available after the loop ended. $this->exportStringFiles($po_data, $project->getUri(), $language, $full_export, $export_string, $compact, $include_suggestions); } if (empty($po_data)) { // No strings were found. if (isset($release)) { $message = t('There are no strings in the %release release of %project to export.', [ '%project' => $project->label(), '%release' => $release->getVersion(), ]); } else { $message = t('There are no strings in any releases of %project to export.', [ '%project' => $project->label(), ]); } // Message to the user. \Drupal::messenger()->addStatus($message); // Message to watchdog for possible automated packaging. \Drupal::logger('l10n_community')->warning($message); return NULL; } // Generate a 'unique' temporary filename for this package. /** @var \Drupal\Core\File\FileSystemInterface $file_system */ $file_system = \Drupal::service('file_system'); $tempfile = tempnam($file_system->getTempDirectory(), 'l10n_community-' . $project->getUri()); if (!$compact) { if (count($po_data['revisions']) == 1) { $file_list = '# Generated from file: ' . $po_data['revisions'][0] . "\n"; } else { $file_list = '# Generated from files:' . "\n# " . implode("\n# ", $po_data['revisions']) . "\n"; } } else { $file_list = ''; } $release_title = $project->label() . ' (' . (isset($release) ? $release->getVersion() : 'all releases') . ')'; if (!$full_export) { $header = '# ' . $language->getName() . ' translation of ' . $release_title . "\n"; $header .= "# Copyright (c) " . date('Y') . ' by the ' . $language->getName() . " translation team\n"; $header .= $file_list; $header .= "#\n"; $header .= "msgid \"\"\n"; $header .= "msgstr \"\"\n"; $header .= "\"Project-Id-Version: " . $release_title . "\\n\"\n"; $header .= "\"POT-Creation-Date: " . date("Y-m-d H:iO") . "\\n\"\n"; // Use date placeholder, if we have no date information (no translation // here yet). $header .= "\"PO-Revision-Date: " . (!empty($po_data['changed']) ? date("Y-m-d H:iO", $po_data['changed']) : 'YYYY-mm-DD HH:MM+ZZZZ') . "\\n\"\n"; $header .= "\"Language-Team: " . $language->getName() . "\\n\"\n"; $header .= "\"MIME-Version: 1.0\\n\"\n"; $header .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n"; $header .= "\"Content-Transfer-Encoding: 8bit\\n\"\n"; $plurals = $this->pluralFormula->getNumberOfPlurals($language->getId()); $formula = $this->pluralFormula->getFormula($language->getId()); if ((!empty($formula) || $formula === "0") && $plurals) { // @todo Fix plural formula export // phpcs:ignore //$header .= "\"Plural-Forms: nplurals=" . $plurals . "; plural=" . strtr($formula, ['$' => '']) . ";\\n\"\n"; } } else { $language_title = (isset($language) ? $language->getName() : 'LANGUAGE'); $header = "# " . $language_title . " translation of " . $release_title . "\n"; $header .= "# Copyright (c) " . date('Y') . "\n"; $header .= $file_list; $header .= "#\n"; $header .= "msgid \"\"\n"; $header .= "msgstr \"\"\n"; $header .= "\"Project-Id-Version: " . $release_title . "\\n\"\n"; $header .= "\"POT-Creation-Date: " . date("Y-m-d H:iO") . "\\n\"\n"; $header .= "\"PO-Revision-Date: YYYY-mm-DD HH:MM+ZZZZ\\n\"\n"; $header .= "\"Language-Team: " . $language_title . "\\n\"\n"; $header .= "\"MIME-Version: 1.0\\n\"\n"; $header .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n"; $header .= "\"Content-Transfer-Encoding: 8bit\\n\"\n"; $plurals = $this->pluralFormula->getNumberOfPlurals($language->getId()); $formula = $this->pluralFormula->getFormula($language->getId()); if (isset($language) && (!empty($formula) || $formula === "0") && $plurals) { // @todo Fix plural formula export // phpcs:ignore //$header .= "\"Plural-Forms: nplurals=" . $plurals . "; plural=" . strtr($formula, ['$' => '']) . ";\\n\"\n"; } else { $header .= "\"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n\"\n"; } } // Write to file directly. We should only do this once. $fh = fopen($tempfile, 'w'); fwrite($fh, $header . "\n" . $po_data['file']); fclose($fh); // Output a single PO(T) file. return [ 'text/plain', $tempfile, $project->getUri() . '-' . (isset($release) ? $release->getVersion() : 'all') . (isset($language) ? '.' . $language->getId() : '') . ($full_export ? '.pot' : '.po'), $sid_count, ]; } /** * Helper function to store the export string. * * @param array $po_data * An array of Po data. * @param string $uri * An URI string. * @param \Drupal\Core\Language\Language $language * A language object. * @param bool $template * Whether to export a language template. * @param array $export_string * What strings to export e.g. comment, suggestions, has_suggestion, * translation, value, context. * @param bool $compact * Whether to use compact form. * @param bool $suggestions * Whether to include suggestions. */ private function exportStringFiles(array &$po_data, string $uri, Language $language, bool $template, array $export_string, bool $compact = FALSE, bool $suggestions = FALSE) { $output = ''; if (!empty($export_string)) { // Location comments are constructed in fileone:1,2,5; filetwo:123,537 // format, where the numbers represent the line numbers of source // occurances in the respective source files. $comment = []; foreach ($export_string['comment'] as $path => $lines) { $comment[] = preg_replace('!(^[^/]+/)!', '', $path) . ':' . implode(',', array_keys($lines)); } $comment = '#: ' . implode('; ', $comment) . "\n"; if (!$compact) { $output = $comment; } $fuzzy = FALSE; if ($suggestions) { $all_suggestions = array_keys($export_string['suggestions']); // Export information about suggestions if inclusion was requested. if ($export_string['has_suggestion']) { // If we had suggestions, add comment to let reviewers know. $output .= count($all_suggestions) > 1 ? "# Suggestions on the localization server:\n" : "# Suggestion on the localization server:\n"; } if (empty($export_string['translation']) && !empty($all_suggestions)) { // If no translation, make the translation the first identified // suggestion and mark the translation fuzzy (so it keeps to be a // suggestion on reimport). $export_string['translation'] = array_shift($all_suggestions); $fuzzy = TRUE; } if (!empty($all_suggestions)) { if (strpos($export_string['value'], "\0")) { foreach ($all_suggestions as $i => $suggestion) { // Format the suggestions in a readable format, if plurals. $all_suggestions[$i] = str_replace("\0", ' / ', $suggestion); } } // Add all other suggestions as comment lines. Multiline suggestions // will appear on multiple lines, people need to figure these out // manually. $output .= '# ' . str_replace("\n", "\n# ", implode("\n", $all_suggestions)) . "\n"; } } if ($fuzzy) { $output .= "#, fuzzy\n"; } if (strpos($export_string['value'], "\0") !== FALSE) { // This is a string with plural variants. [$singular, $plural] = explode("\0", $export_string['value']); if (!empty($export_string['context'])) { $output .= 'msgctxt ' . $this->exportString($export_string['context']); } $output .= 'msgid ' . $this->exportString($singular) . 'msgid_plural ' . $this->exportString($plural); if (!$template && !empty($export_string['translation'])) { // Export translations we have. foreach (explode("\0", $export_string['translation']) as $id => $value) { $output .= 'msgstr[' . $id . '] ' . $this->exportString($value); } } elseif (isset($language)) { // Empty msgstrs based on plural formula for language. Could be a // plural without translation or a template generated for a specific // language. $plurals = $this->pluralFormula->getNumberOfPlurals($language->getId()); for ($pi = 0; $pi < $plurals; $pi++) { $output .= 'msgstr[' . $pi . '] ""' . "\n"; } } else { // Translation template without language, assume two msgstrs. $output .= 'msgstr[0] ""' . "\n"; $output .= 'msgstr[1] ""' . "\n"; } } else { // Simple string (and possibly translation pair). if (!empty($export_string['context'])) { $output .= 'msgctxt ' . $this->exportString($export_string['context']); } $output .= 'msgid ' . $this->exportString($export_string['value']); if (!empty($export_string['translation'])) { $output .= 'msgstr ' . $this->exportString($export_string['translation']); } else { $output .= 'msgstr ""' . "\n"; } } if (empty($po_data)) { $po_data = [ 'file' => '', 'changed' => 0, 'revisions' => [], ]; } // Add to existing file storage. $po_data['file'] .= $output; if (!$compact) { $po_data['file'] .= "\n"; } if (!$template) { $po_data['changed'] = max($po_data['changed'], $export_string['changed']); } $po_data['revisions'] = array_unique(array_merge($po_data['revisions'], array_keys($export_string['revisions']))); } } /** * Print out a string on multiple lines. * * @param string $str * A string. * * @return string * A string. */ private function exportString(string $str) { $stri = addcslashes($str, "\0..\37\\\""); $parts = []; // Cut text into several lines. while ($stri != "") { $i = strpos($stri, "\\n"); if ($i === FALSE) { $curstr = $stri; $stri = ""; } else { $curstr = substr($stri, 0, $i + 2); $stri = substr($stri, $i + 2); } $curparts = explode("\n", $this->exportWrap($curstr, 70)); $parts = array_merge($parts, $curparts); } // Multiline string. if (count($parts) > 1) { return "\"\"\n\"" . implode("\"\n\"", $parts) . "\"\n"; } // Single line string. elseif (count($parts) == 1) { return "\"$parts[0]\"\n"; } // No translation. else { return "\"\"\n"; } } /** * Custom word wrapping for Portable Object (Template) files. * * @param string $str * A string. * @param int $len * An integer. * * @return string * A string. */ public function exportWrap(string $str, int $len) { $words = explode(' ', $str); $ret = []; $cur = ""; $nstr = 1; while (count($words)) { $word = array_shift($words); if ($nstr) { $cur = $word; $nstr = 0; } elseif (strlen("$cur $word") > $len) { $ret[] = $cur . " "; $cur = $word; } else { $cur = "$cur $word"; } } $ret[] = $cur; return implode("\n", $ret); } /** * The export query. * * @param \Drupal\l10n_server\Entity\L10nServerProjectInterface $project * Project entity to extract releases from. * @param \Drupal\Core\Language\Language $language * Language object. * @param bool $full_export * TRUE if the export should contain translated and untranslated string, * FALSE if only translations should be exported. * @param bool $limit_to_installer * Whether we should only export the translations needed for the installer * and not those needed for the runtime site. * @param bool $include_suggestions * Whether to include suggestions. * @param \Drupal\l10n_server\Entity\L10nServerReleaseInterface|null $release * Release object to generate tarball for, or NULL to generate * with all releases considered. * * @return \Drupal\Core\Database\StatementInterface|null * Strings collection or NULL if empty. */ protected function queryStrings(L10nServerProjectInterface $project, Language $language, bool $full_export, bool $limit_to_installer, bool $include_suggestions, ?L10nServerReleaseInterface $release): ?StatementInterface { $query = \Drupal::database()->select('l10n_server_file', 'f'); $query->innerJoin('l10n_server_line', 'l', 'f.fid = l.fid'); $query->innerJoin('l10n_server_string', 's', 'l.sid = s.sid'); $query->fields('s', ['sid', 'value', 'context']) ->fields('f', ['location', 'revision']) ->fields('l', ['lineno', 'type']) ->condition('f.pid', $project->id()) ->orderBy('s.sid'); // Improve the query for templates. $query->leftJoin('l10n_server_status_flag', 'st', 'st.sid = s.sid AND st.language = :language', [ ':language' => $language->getId(), ]); // Join differently based on full export value. // We can skip strings without translation for non-full exports. $translation_join = ($full_export) ? 'leftJoin' : 'innerJoin'; $query->$translation_join('l10n_server_translation', 't', 's.sid = t.sid AND t.language = :language AND t.status = 1', [ ':language' => $language->getId(), ]); $query->fields('t', ['translation', 'suggestion']) ->fields('st', ['has_suggestion']) ->orderBy('t.suggestion') ->orderBy('t.created', 'DESC'); // Installer strings are POTX_STRING_INSTALLER or POTX_STRING_BOTH. if ($limit_to_installer) { $query->condition('l.type', [ L10nServerLine::POTX_STRING_BOTH, L10nServerLine::POTX_STRING_INSTALLER, ], 'IN'); } // Only include suggestions if requested, otherwise filter out. if (!$full_export && !$include_suggestions) { $query->condition('t.suggestion', 0); } if (isset($release)) { // Release restriction. $query->condition('f.rid', $release->id()); } return $query->execute(); } }