Subversion Repositories web.creative

Rev

Blame | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire Language Process, displays languages in Setup > Languages > 
 * 
 * It also contains the hooks for altering the output of the InputfieldFile to hold language info and links.
 * This is the process assigned to processwire/setup/languages/.
 *
 * ProcessWire 3.x, Copyright 2019 by Ryan Cramer
 * https://processwire.com
 *
 *
 */

class ProcessLanguage extends ProcessPageType {

  static public function getModuleInfo() {
    return array(
      'title' => __('Languages', __FILE__),
      'version' => 103,
      'summary' => __('Manage system languages', __FILE__),
      'author' => 'Ryan Cramer',
      'requires' => 'LanguageSupport',
      'icon' => 'language', 
      'useNavJSON' => true,
      'permission' => 'lang-edit',
      'permissions' => array(
        'lang-edit' => 'Administer languages and static translation files'
        )
      );
  }

  /**
   * The URL to the language-translator page (typically admin/setup/language-translator/)
   *
   */
  protected $translationUrl = '';

  /**
   * Array of messages for language_files, indexed by file basename
   *
   */
  protected $fileMessages = array();

  /**
   * CSV file to process, if present
   * 
   * @var Pagefile|null
   * 
   */
  protected $csvImportLabel = '';

  /**
   * Populate the fields shown in the default language list output
   *
   */
  public function __construct() {
    parent::__construct();
    
    $showFields = array('name', 'title', 'language_files', 'language_files_site'); 
    $this->set('showFields', $showFields); 
    $this->set('jsonListLabel', 'title|name'); 
    require_once(dirname(__FILE__) . '/LanguageParser.php'); 
  
    // make sure our files fields have CSV support
    foreach(array('language_files', 'language_files_site') as $fieldName) {
      $field = $this->wire('fields')->get($fieldName);
      if(!$field) continue;
      $extensions = $field->get('extensions'); 
      if(strpos($extensions, 'csv') === false) {
        $field->set('extensions', "$extensions csv");
        $field->save();
        $this->message("Added CSV support to field $fieldName", Notice::debug);
      }
    }
    
    $this->csvImportLabel = $this->_('CSV Import:') . ' ';
  }

  /**
   * Add InputfieldFile hooks 
   *
   */
  public function init() {
    $this->addHookBefore('InputfieldFile::render', $this, 'renderInputfieldFile');
    $this->addHookAfter('InputfieldFile::renderItem', $this, 'renderInputfieldFileItem'); 
    $this->addHookAfter('InputfieldFile::renderUpload', $this, 'renderInputfieldFileUpload');
    $this->addHookBefore('InputfieldFile::processInput', $this, 'processInputfieldFileInput');
  
    if(!$this->wire('config')->ajax) {
      $this->addHookBefore('InputfieldForm::render', $this, 'renderInputfieldForm');
    }
    parent::init();
  }

  protected function translationUrl() {
    if(!$this->translationUrl) {
      $support = $this->wire('modules')->get('LanguageSupport'); 
      $this->translationUrl = $this->wire('pages')->get($support->languageTranslatorPageID)->url;
    }
    return $this->translationUrl; 
  }
  
  public function processInputfieldFileInput(HookEvent $event) {
    /** @var InputfieldFile $inputfield */
    $inputfield = $event->object;
    $inputfield->overwrite = true; 
  }
  
  /**
   * Hook for before InputfieldFile::render
   *
   * In this case we add an 'edit' link to the translator and some info about the translation file.
   * 
   * @param HookEvent $event
   *
   */
  public function renderInputfieldFile(HookEvent $event) {

    /** @var InputfieldFile $inputfield */
    $inputfield = $event->object;
    $language = $this->wire('process')->getPage();
    
    /** @var Pagefiles $pagefiles */
    $pagefiles = $inputfield->attr('value');
    
    foreach($pagefiles as $pagefile) {
      /** Pagefile $pagefile */
      if($pagefile->ext() != 'csv') continue;
      $pagefiles->remove($pagefile);
      $this->processCSV($pagefile->filename(), $language);
    }
    
    $inputfield->descriptionRows = 0;
    $inputfield->overwrite = true;
    $inputfield->noCollapseItem = true;
    $inputfield->noShortName = true; 
    
  }
  
  public function renderInputfieldForm(HookEvent $event) {

    /** @var InputfieldForm $form */
    $form = $event->object;
    $language = $this->getPage();
    if(!$language->id) return;
    $file = $language->filesManager()->path . '.phrase-index.txt';
    $inputfield = $this->wire('modules')->get('InputfieldMarkup');
    $inputfield->label = $this->_('Live Search');
    $inputfield->icon = 'search';
    $placeholder = $this->_('Text to search for');
    $refreshUrl = "../../language-translator/add/?language_id=$language->id&refresh=1";
    $refreshLabel = $this->_('Refresh search phrase index');
    
    if(!is_file($file)) {
      $inputfield->value = "<p><a href='$refreshUrl'>" . 
        $this->_('Click here to build search phrase index') . "</a></p>";
      
    } else {

      $phrases = file_get_contents($file);
      $phrases = str_replace(array('"', "\n", "<", ">"), ' ', $phrases);

      $inputfield->value =
        "<script>" .
        "var phraseIndex = \"$phrases\"; " .
        "var phraseLanguageID = $language->id;" .
        "</script>" .
        "<p class='description' style='margin:0'>" .
        $this->_('Search all translatable files for specific text/phrase.') . ' ' .
        $this->_('Click found matches to edit translation or add file (if not already present).') .
        "<p>" .
        "<p>" .
        "<input type='text' class='language-phrase-search InputfieldIgnoreChanges' style='width:50%' name='_q' placeholder='$placeholder' /> " .
        "<span class='detail language-phrase-search-cnt'></span> &nbsp;" .
        "<a class='pw-tooltip' title='$refreshLabel' href='$refreshUrl'>" . wireIconMarkup('refresh') . "</a>" .  
        "</p>";
    }
    
    $field = $form->getChildByName('language_files_site');
    if($field) $form->insertBefore($inputfield, $field);
  }

  /**
   * Hook for InputfieldFile::renderItem
   *
   * In this case we add an 'edit' link to the translator and some info about the translation file.
   * 
   * @param HookEvent $event
   *
   */
  public function renderInputfieldFileItem(HookEvent $event) {

    $translationUrl = $this->translationUrl(); 
    /** @var Pagefile $pagefile */
    $pagefile = $event->arguments[0]; 
    /** @var Language $page */
    $page = $pagefile->get('page'); 
    
    if($pagefile->ext() == 'csv') {
      $event->return .=
        "<div class='InputfieldFileData InputfieldFileLanguageInfo'>" .
          "<span class='InputfieldFileLanguageFilename description'>" .
            $this->_('CSV translation file to be imported after save') . 
          "</span>" . 
        "</div>";
      return;
    }
    
    $textdomain = basename($pagefile->basename, '.json');

    $data = $page->translator->getTextdomain($textdomain);
    $file = $data['file']; 
    $pathname = $this->wire('config')->paths->root . $file;
    $translations =& $data['translations'];
    $total = count($translations); 
    $parser = $this->wire(new LanguageParser($page->translator, $pathname));
    $untranslated = $parser->getUntranslated();
    $alternates = $parser->getAlternates();
    $numPending = 0;
    $numAbandoned = 0;
    $numFallback = 0;

    foreach($untranslated as $hash => $text) {
      if(!isset($translations[$hash]) || !strlen($translations[$hash]['text'])) $numPending++;
    }

    foreach($translations as $hash => $translation) {
      if(isset($untranslated[$hash])) continue;
      $numAbandoned++;
      if($page->isDefault()) continue;
      foreach($alternates as $srcHash => $values) {
        if(isset($values[$hash]) && isset($untranslated[$srcHash])) {
          $numFallback++;
          $numAbandoned--;
          break;
        }
      }
    }

    $total += $numAbandoned; 
    $message = sprintf($this->_n("%d phrase", "%d phrases", $total), $total);

    if($numAbandoned || $numPending || $numFallback) {
      $a = array();
      if($numAbandoned) $a[] = sprintf($this->_('%d abandoned'), $numAbandoned);
      if($numPending) $a[] = sprintf($this->_('%d blank'), $numPending);
      if($numFallback) $a[] = sprintf($this->_('%d fallback'), $numFallback);
      $message = " <span class='ui-state-error-text'>(" . implode(' / ', $a) . ")</span>";
    }

    $editLabel = $this->_x('Edit', 'edit-language-file'); 

    $out =  
      "<div class='InputfieldFileData InputfieldFileLanguageInfo'>" . 
      "<span class='InputfieldFileLanguageFilename description'>/$file &#8212;</span> <span class='notes'>$message</span> " .
      "<a class='action' href='{$translationUrl}edit/?language_id={$page->id}&amp;textdomain=$textdomain'>&nbsp; " . 
      "<i class='fa fa-edit'></i> $editLabel <i class='fa fa-angle-double-right hover-only'></i></a>" . 
      "</div>";

    $page->translator->unloadTextdomain($textdomain);

    $event->return .= $out; 

  }

  /**
   * Hook for InputfieldFile::renderUpload
   *
   * This just adds a 'new' link to add a new translation file.
   * 
   * @param HookEvent $event
   *
   */
  public function renderInputfieldFileUpload(HookEvent $event) {

    $translationUrl = $this->translationUrl();
    /** @var Pagefiles $pagefiles */
    $pagefiles = $event->arguments(0);
    /** @var Page $page */
    $page = $pagefiles->get('page'); 
    /** @var InputfieldFile $inputfield */
    $inputfield = $event->object; 
    $out = '';
  
    /** @var InputfieldButton $btn1 */
    $btn1 = $this->wire('modules')->get('InputfieldButton'); 
    $btn1->href = "{$translationUrl}add/?language_id={$page->id}";
    $btn1->value = $this->_('Find Files to Translate'); 
    $btn1->icon = 'plane';
    if($inputfield->name == 'language_files') $btn1->showInHeader();
    $out .= $btn1->render();
  
    if(count($inputfield->attr('value'))) {
      /** @var InputfieldButton $btn2 */
      $btn2 = $this->wire('modules')->get('InputfieldButton');
      $btn2->href = "../download/?language_id={$page->id}&field=" . $inputfield->attr('name');
      $btn2->value = $this->_('Download ZIP');
      $btn2->icon = 'file-zip-o';
      $btn2->setSecondary();
      $btn2->addClass('download-button'); 
      $out .= $btn2->render();
      
      $btn2->href .= '&csv=1';
      $btn2->value = $this->_('Download CSV');
      $btn2->icon = 'file-excel-o';
      $out .= $btn2->render();
    }
    
    $event->return .= "<p>$out</p>";
  }

  /**
   * Modify the output per-field in the PageType list (template-method)
   *
   * In this case we make it return a count for the language_files
   * 
   * @param string $name
   * @param mixed $value
   * @return string
   *
   */
  protected function renderListFieldValue($name, $value) {
    if($name == 'language_files' || $name == 'language_files_site') {
      return count($value); 
    } else if($name == 'title') { 
      if(!$value) return '(blank)';
      return (string) $value;
    } else {
      return parent::renderListFieldValue($name, $value); 
    }
  }

  public function ___execute() { 
    // check if 2.5 update needed to add new language_files_site field
    if(!$this->wire('fields')->get('language_files_site')) {
      require_once(dirname(__FILE__) . '/LanguageSupportInstall.php');
      $installer = $this->wire(new LanguageSupportInstall());
      $installer->addFilesFields($this->wire('fieldgroups')->get(LanguageSupport::languageTemplateName));
    }
    return parent::___execute();
  }

  /**
   * Create and send a ZIP of translation files or CSV of translations
   *
   */
  public function ___executeDownload() {
    
    $id = (int) $this->input->get('language_id'); 
    if(!$id) throw new WireException("No language specified"); 
    
    $language = $this->wire('languages')->get($id);
    if(!$language->id) throw new WireException("Unknown language"); 
    
    $fieldName = $this->input->get('field') == 'language_files_site' ? 'language_files_site' : 'language_files';
    $csv = (int) $this->wire('input')->get('csv');
    $path = $language->$fieldName->path();
    $files = array();
    
    foreach($language->$fieldName as $file) {
      $files[] = $file->filename;
    }
    
    if($csv) {
      // CSV
      $filename = $language->name . "-" . (strpos($fieldName, 'site') ? 'site' : 'wire') . ".csv";
      header("Content-type: application/force-download");
      header("Content-Transfer-Encoding: Binary");
      header("Content-disposition: attachment; filename=$filename");

      $fp = fopen('php://output', 'w');
      $defaultCol = $language->name == 'en' ? 'default' : 'en';
      $fields = array($defaultCol, $language->name, 'description', 'file', 'hash');
      fputcsv($fp, $fields);
      
      foreach($files as $f) {
        
        $textdomain = basename($f, '.json');
        $data = $language->translator->getTextdomain($textdomain);
        $file = $data['file'];
        $pathname = $this->wire('config')->paths->root . $file;
        $translated =& $data['translations'];
        $parser = $this->wire(new LanguageParser($language->translator, $pathname));
        $untranslated = $parser->getUntranslated();
        $comments = $parser->getComments();

        foreach($untranslated as $hash => $text1) {
          $text2 = isset($translated[$hash])  ? $translated[$hash]['text'] : '';
          $comment = isset($comments[$hash]) ? $comments[$hash] : '';
          if(strpos($comment, '//') !== false) list(, $comment) = explode('//', $comment);
          $fields = array($text1, $text2, trim($comment), $file, $hash);
          fputcsv($fp, $fields);
        }
      }
      
      fclose($fp);

    } else {
      // ZIP
      $zipname = $language->name . "-";
      $zipname .= $fieldName == 'language_files' ? 'wire' : 'site';
      $zipfile = "$path$zipname.zip";
      $info = wireZipFile($zipfile, $files, array("overwrite" => true));
      if(!count($info['files'])) {
        $this->error("Error adding files to ZIP");
        $this->session->redirect('../');
      } else {
        wireSendFile($zipfile);
      }
    }
    
    exit(0);
  }

  /**
   * Process a CSV file to import changes from it
   * 
   * Must be in the same format as the one provied by the executeDownload() method
   * 
   * @param string $csvFile
   * @param Language $language
   * @return bool
   * @throws WireException
   * 
   */
  public function processCSV($csvFile, Language $language) {
    
    $fp = fopen($csvFile, "r");
    
    if($fp === false) {
      $this->error($this->csvImportLabel . "Unable to open: $csvFile");
      return false;
    }

    $keys = array(
      'original',
      'translated',
      'file',
      'hash',
    );
    
    $n = 0;
    $header = array();
    $translator = new LanguageTranslator($language);
    $textdomain = '';
    $lastTextdomain = '';
    $lastFile = '';
    $numChanges = 0;
    $numTotal = 0;
    $numGross = 0;
    $translations = null;
    $halt = false;
    
    while(($csvData = fgetcsv($fp, 8192, ",")) !== FALSE) {
      
      if(++$n === 1) {
        // header row
        $header = $csvData; 
        foreach($header as $key => $value) {
          $header[$key] = strtolower($value);
        }
        // make sure everything we need is present
        foreach($keys as $k => $key) {
          if($k > 1 && !in_array($key, $header)) {
            $this->error($this->csvImportLabel . "CSV data missing required column '$key'");
            $halt = true;
          }
        }
        if($halt) break;
        continue;
        
      } 
      
      $row = array();
      foreach($header as $key => $name) {
        if($key === 0) $name = 'original';
        if($key === 1) $name = 'translated';
        $row[$name] = $csvData[$key];
      }
      
      if(empty($row['file']) || empty($row['original'])) continue;
      
      $file = $row['file'];
      $hash = $row['hash'];
      // $textOriginal = $row['original'];
      $textTranslated = $row['translated'];
      $textdomain = $translator->filenameToTextdomain($file);
      
      if(!$translator->textdomainFileExists($textdomain)) {
        $textdomain = $translator->addFileToTranslate($file, false, false);
        //$translator->loadTextdomain($textdomain);
      }
      
      if(is_null($translations)) $translations = $translator->getTranslations($textdomain);
      
      if(!$textdomain) {
        $this->warning($this->csvImportLabel . sprintf(
            $this->_('Unrecognized textdomain for file: %s'), 
            $this->wire('sanitizer')->entities($file)
          )
        );
        continue;
      }
      
      if($textdomain != $lastTextdomain) {
        if(!$lastFile) $lastFile = $file;
        if(!$lastTextdomain) $lastTextdomain = $textdomain;
        $this->processCSV_saveTextdomain($translator, $lastTextdomain, $lastFile, $numChanges);
        $translations = $translator->getTranslations($textdomain);
        $numChanges = 0;
      }
      
      $translation = isset($translations[$hash]) ? $translations[$hash] : array('text' => '');
      if($translation['text'] != $textTranslated) {
        $translator->setTranslationFromHash($textdomain, $hash, $textTranslated);
        $numChanges++;
        $numTotal++;
      }

      $lastTextdomain = $textdomain;
      $lastFile = $file;
      $numGross++;
    }
    
    if($numChanges) {
      $this->processCSV_saveTextdomain($translator, $textdomain, $lastFile, $numChanges);
    }
    $language->save();
    
    fclose($fp);
    $this->message($this->csvImportLabel . sprintf($this->_('%d total translations, %d total changes'), $numGross, $numTotal));
    
    return $halt ? false : true;
  }

  /**
   * Save a textdomain, helper for processCSV method
   * 
   * @param LanguageTranslator $translator
   * @param string $textdomain
   * @param string $filename
   * @param int $numChanges
   * 
   */
  protected function processCSV_saveTextdomain(LanguageTranslator $translator, $textdomain, $filename, $numChanges) {
    if($filename) { /* ignore, not currently used */ }
    $file = $translator->textdomainToFilename($textdomain);
    if($numChanges) {
      try {
        $translator->saveTextdomain($textdomain);
        $this->message($this->csvImportLabel . sprintf($this->_('Saved %d change(s) for file: %s'), $numChanges, $file));
      } catch(\Exception $e) {
        $this->error($e->getMessage());
      }
    } else {
      // no changes
    }
    $translator->unloadTextdomain($textdomain);
  }
}