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 support foreach(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 = "

" . $this->_('Click here to build search phrase index') . "

"; } else { $phrases = file_get_contents($file); $phrases = str_replace(array('"', "\n", "<", ">"), ' ', $phrases); $script = 'script'; $inputfield->value = "<$script>" . "var phraseIndex = \"$phrases\"; " . "var phraseLanguageID = $language->id;" . "" . "

" . $this->_('Search all translatable files for specific text/phrase.') . ' ' . $this->_('Click found matches to edit translation or add file (if not already present).') . "

" . "

" . " " . "  " . "" . wireIconMarkup('refresh') . "" . "

"; } $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 .= "
" . "" . $this->_('CSV translation file to be imported after save') . "" . "
"; 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 = " (" . implode(' / ', $a) . ")"; } $editLabel = $this->_x('Edit', 'edit-language-file'); $out = "
" . "/$file — $message " . "  " . " $editLabel " . "
"; $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 .= "

$out

"; } /** * 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'); /** @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) { // CSV if($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 present foreach($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); } }