artabro/wire/modules/LanguageSupport/ProcessLanguage.module
2024-08-27 11:35:37 +02:00

658 lines
20 KiB
Text

<?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 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 = "<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> &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();
$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 &#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) {
$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 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);
}
}