Rev 22 | Blame | Compare with Previous | 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** @method string execute()* @method string executeDownload()* @method bool|int processCSV($csvFile, Language $language, array $options = array())**/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');}/*** Wired to ProcessWire instance**/public function wired() {parent::wired();$fields = $this->wire()->fields;// make sure our files fields have CSV supportforeach(array('language_files', 'language_files_site') as $fieldName) {$field = $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(name^=language_files)::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) {/** @var LanguageSupport $support */$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) {$inputfield = $event->object; /** @var InputfieldFile $inputfield */$language = $this->wire()->process->getPage(); /** @var Language $language *//** @var Pagefiles $pagefiles */$pagefiles = $inputfield->attr('value');foreach($pagefiles as $pagefile) {/** @var 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';/** @var InputfieldMarkup $inputfield */$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);$script = 'script';$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> " ."<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();$pagefile = $event->arguments[0]; /** @var Pagefile $pagefile */$page = $pagefile->get('page'); /** @var Language $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);/** @var LanguageParser $parser */$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 —</span> <span class='notes'>$message</span> " ."<a class='action' href='{$translationUrl}edit/?language_id={$page->id}&textdomain=$textdomain'> " ."<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) {$modules = $this->wire()->modules;$translationUrl = $this->translationUrl();$pagefiles = $event->arguments(0); /** @var Pagefiles $pagefiles */$page = $pagefiles->get('page'); /** @var Page $page */$inputfield = $event->object; /** @var InputfieldFile $inputfield */$out = '';/** @var InputfieldButton $btn1 */$btn1 = $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 = $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 fieldif(!$this->wire()->fields->get('language_files_site')) {require_once(dirname(__FILE__) . '/LanguageSupportInstall.php');/** @var LanguageSupportInstall $installer */$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() {$config = $this->wire()->config;$input = $this->wire()->input;$id = (int) $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 = $input->get('field') == 'language_files_site' ? 'language_files_site' : 'language_files';$textdomain = $this->wire()->sanitizer->textdomain($input->get('textdomain'));$textdomains = array();$csv = (int) $input->get('csv');$path = $language->$fieldName->path();$files = array();if($textdomain) {$file = $language->translator->textdomainToFilename($textdomain);if($file) {$files[] = $file;$textdomains[$file] = $textdomain;} else {$textdomain = '';}}if(!count($files)) {foreach($language->$fieldName as $file) {$files[] = $file->filename;}}if(!count($files)) {throw new WireException('No translation files specified to download');}if($csv) {// CSVif($textdomain) {// i.e. es-modulename.csv$parts = explode('--', $textdomain);$basename = array_pop($parts);$parts = explode('-', $basename);$basename = array_shift($parts);$filename = "$language->name-$basename.csv";} else {// i.e. es-site.csv or es-wire.csv$filename = $language->name . "-" . (strpos($fieldName, 'site') ? 'site' : 'wire') . ".csv";}if($input->get('view')) {header("Content-type: text/plain");} else {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) {if(isset($textdomains[$f])) {$textdomain = $textdomains[$f];} else {$textdomain = basename($f, '.json');}$data = $language->translator->getTextdomain($textdomain);if(empty($data)) continue;$file = $data['file'];$pathname = $config->paths->root . $file;$translated =& $data['translations'];$parser = $this->wire(new LanguageParser($language->translator, $pathname)); /** @var LanguageParser $parser */$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* @param array $options Additional options (3.0.181+)* - `file` (string): Use this path/file (relative to install root)* - `quiet` (bool): Suppress generating notifications? (default=false)* @return bool|int Returns false on error or integer on success, where value is number of translations imported* @throws WireException* @since 3.0.195 Previously was not hookable**/public function ___processCSV($csvFile, Language $language, array $options = array()) {$defaults = array('file' => '','quiet' => false,);$options = array_merge($defaults, $options);$fp = fopen($csvFile, "r");if($fp === false) {if(!$options['quiet']) $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;$optionsFileBasename = '';$halt = false;$this->wire($translator);if(!empty($options['file'])) {$options['file'] = ltrim($this->wire()->files->unixFileName($options['file']), '/');$optionsFileBasename = basename($options['file']);}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 presentforeach($keys as $k => $key) {if($k > 1 && !in_array($key, $header)) {if($key === 'file' && !empty($options['file'])) {// default file provided so not required in CSV data} else {if(!$options['quiet']) $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($options['file']) {if(empty($row['file'])) {$row['file'] = $options['file'];} else {$rowFileBasename = basename($row['file']);if($rowFileBasename === $optionsFileBasename) {// i.e. site/modules/Hello/Hello.module$row['file'] = $options['file'];} else {// i.e. site/modules/Hello/World.module$row['file'] = dirname($options['file']) . '/' . $rowFileBasename;}}}if(empty($row['original']) || empty($row['file'])) 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);}if(is_null($translations)) {$translations = $translator->getTranslations($textdomain);}if(!$textdomain) {if(!$options['quiet']) $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);if(!$options['quiet']) {$this->message($this->csvImportLabel . sprintf($this->_('%d total translations, %d total changes'), $numGross, $numTotal), Notice::noGroup);}return $halt ? false : $numGross;}/*** 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), Notice::noGroup);} catch(\Exception $e) {$this->error($e->getMessage());}} else {// no changes}$translator->unloadTextdomain($textdomain);}}