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();
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc