mirror of
https://github.com/processwire/processwire.git
synced 2025-08-13 18:24:57 +02:00
Add support for retaining abandoned translations in __('text')
calls. This enables you to change the value in __('text')
_x('text', 'context')
, $this->_('text')
, etc. calls without abandoning the existing translation. To use, specify bracket PHP array syntax with 2 or more phrases you'll accept translations for, rather than automatically abandoning them. This is useful in cases where you need to change the text, but do not want to automatically lose any existing translations. For example, the call: __(['new text', 'old text']);
will use a translation for 'old text' if 'new text' has not yet been translated. In the admin translation tools, it identifies these as "fallback" translations. See phpdoc notes in __() function for more details.
This commit is contained in:
@@ -82,6 +82,18 @@
|
||||
* // providing a note via PHP comment to the person doing translation
|
||||
* echo __('Welcome friend!'); // A friendly welcome message for new users
|
||||
*
|
||||
* // CHANGING EXISTING TRANSLATIONS (3.0.151+) -----------------------------------------
|
||||
*
|
||||
* // In ProcessWire 3.0.151+ you can change existing phrases without automatically
|
||||
* // abandoning the translations for them. To use, include both new and old phrase.
|
||||
* // Specify PHP array (bracket syntax required) with 2+ phrases you accept translations
|
||||
* // for where the first is the newest/current text to translate. This array replaces
|
||||
* // the $text argument of this function.
|
||||
* __([ 'New text', 'Old text' ]);
|
||||
*
|
||||
* // The above can also be used with _x() and _n() calls as well.
|
||||
* _x([ 'Canada Goose', 'Canadian Goose' ], 'bird');
|
||||
*
|
||||
* // ADVANCED EXAMPLES (3.0.125+) -------------------------------------------------------
|
||||
*
|
||||
* // using the entityEncode option
|
||||
@@ -103,12 +115,13 @@
|
||||
* __(true, [
|
||||
* // would apply only to a _x('Search', 'nav'); call (context)
|
||||
* 'Search' => [ 'Buscar', 'nav' ]
|
||||
* ]);
|
||||
* ]);
|
||||
*
|
||||
* ~~~~~~
|
||||
*
|
||||
* #pw-group-translation
|
||||
*
|
||||
* @param string|bool $text Text for translation.
|
||||
* @param string|array|bool $text Text for translation.
|
||||
* @param string|array $textdomain Textdomain for the text, may be class name, filename, or something made up by you.
|
||||
* If omitted, a debug backtrace will attempt to determine it automatically.
|
||||
* @param string|bool|array $context Name of context - DO NOT USE with this function for translation as it will not be parsed for translation.
|
||||
@@ -124,17 +137,24 @@ function __($text, $textdomain = null, $context = '') {
|
||||
'translations' => array(), // fallback translations to use when live translation not available ['Original text' => 'Translated text']
|
||||
'_useLimit' => null, // internal use: use limit argument for debug_backtrace call
|
||||
);
|
||||
if($text === true) {
|
||||
// set and get options
|
||||
if(is_array($textdomain)) {
|
||||
// translations specified as array in $textdomain argument
|
||||
$context = $textdomain;
|
||||
$textdomain = 'translations';
|
||||
$textArray = false;
|
||||
if(!is_string($text)) {
|
||||
if($text === true) {
|
||||
// set and get options
|
||||
if(is_array($textdomain)) {
|
||||
// translations specified as array in $textdomain argument
|
||||
$context = $textdomain;
|
||||
$textdomain = 'translations';
|
||||
}
|
||||
// merge existing translations if specified
|
||||
if($textdomain == 'translations' && is_array($context)) $context = array_merge($options['translations'], $context);
|
||||
if($context !== '') $options[$textdomain] = $context;
|
||||
return $options[$textdomain];
|
||||
|
||||
} else if(is_array($text)) {
|
||||
$textArray = $text;
|
||||
$text = reset($textArray);
|
||||
}
|
||||
// merge existing translations if specified
|
||||
if($textdomain == 'translations' && is_array($context)) $context = array_merge($options['translations'], $context);
|
||||
if($context !== '') $options[$textdomain] = $context;
|
||||
return $options[$textdomain];
|
||||
}
|
||||
if(!wire('languages') || (!$language = wire('user')->language) || !$language->id) {
|
||||
// multi-language not installed or not available
|
||||
@@ -165,7 +185,22 @@ function __($text, $textdomain = null, $context = '') {
|
||||
// common translation
|
||||
$textdomain = 'wire/modules/LanguageSupport/LanguageTranslator.php';
|
||||
}
|
||||
$value = $language->translator()->getTranslation($textdomain, $text, $context);
|
||||
if($textArray) {
|
||||
$value = null;
|
||||
foreach($textArray as $n => $t) {
|
||||
$tr = $language->translator()->getTranslation($textdomain, $t, $context);
|
||||
if(!$n && $language->isDefault()) {
|
||||
$value = strlen($tr) ? $tr : $t;
|
||||
break; // default language, do not use alternates
|
||||
}
|
||||
if($t === $tr || !strlen($tr)) continue; // if not translated, start over
|
||||
$value = $tr;
|
||||
break;
|
||||
}
|
||||
if($value === null) $value = $text;
|
||||
} else {
|
||||
$value = $language->translator()->getTranslation($textdomain, $text, $context);
|
||||
}
|
||||
$encode = $options['entityEncode'];
|
||||
if($value === "=") {
|
||||
// translated value should be same as source value
|
||||
|
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* Return the results by calling $parser->getUntranslated() and $parser->getComments();
|
||||
*
|
||||
* ProcessWire 3.x, Copyright 2016 by Ryan Cramer
|
||||
* ProcessWire 3.x, Copyright 2020 by Ryan Cramer
|
||||
* https://processwire.com
|
||||
*
|
||||
*
|
||||
@@ -39,6 +39,14 @@ class LanguageParser extends Wire {
|
||||
*/
|
||||
protected $untranslated = array();
|
||||
|
||||
/**
|
||||
* Array of phrase alternates, indexed by source phrase
|
||||
*
|
||||
* @var array
|
||||
*
|
||||
*/
|
||||
protected $alternates = array();
|
||||
|
||||
/**
|
||||
* Total number of phrases found
|
||||
*
|
||||
@@ -59,6 +67,18 @@ class LanguageParser extends Wire {
|
||||
$this->execute($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get phrase alternates
|
||||
*
|
||||
* @param string $hash Specify phrase hash to get alternates or omit to get all alternates
|
||||
* @return array
|
||||
*
|
||||
*/
|
||||
public function getAlternates($hash = '') {
|
||||
if(empty($hash)) return $this->alternates;
|
||||
return isset($this->alternates[$hash]) ? $this->alternates[$hash] : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all found comments, indexed by hash
|
||||
*
|
||||
@@ -85,6 +105,9 @@ class LanguageParser extends Wire {
|
||||
|
||||
/**
|
||||
* Given a hash, return the untranslated text associated with it
|
||||
*
|
||||
* @param string $hash
|
||||
* @return string|bool Returns untranslated text (string) on success or boolean false if not available
|
||||
*
|
||||
*/
|
||||
public function getTextFromHash($hash) {
|
||||
@@ -92,7 +115,9 @@ class LanguageParser extends Wire {
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin parsing
|
||||
* Begin parsing given file
|
||||
*
|
||||
* @param string $file
|
||||
*
|
||||
*/
|
||||
protected function execute($file) {
|
||||
@@ -114,12 +139,73 @@ class LanguageParser extends Wire {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run regex's on file contents to locate all translation functions
|
||||
* Find text array values and place in alternates
|
||||
*
|
||||
* This method also converts the __(['a','b','c']) array calls to single value calls like __('a')
|
||||
* as a pre-parser for all parsers that follow it, so they do not need to be * aware of array values
|
||||
* for translation calls.
|
||||
*
|
||||
* @param string $data
|
||||
*
|
||||
*/
|
||||
protected function findArrayTranslations(&$data) {
|
||||
|
||||
if(!strpos($data, '_([')) return;
|
||||
|
||||
$regex =
|
||||
'/((?:->_|\b__|\b_n|\b_x)\(\[\s*)' . // "->_([" or "__([" or "_n([" or "_x(["
|
||||
'([\'"])(.+?)(?<!\\\\)\\2' . // 'text1'
|
||||
'([^\]]*?\])\s*' . // , 'text2', 'text3' ]"
|
||||
'([^)]*\))/m'; // and the remainder of the function call
|
||||
|
||||
$funcTypes = array('->_(' => '>', '__(' => '_', '_n(' => 'n', '_x(' => 'x');
|
||||
|
||||
if(!preg_match_all($regex, $data, $m)) return;
|
||||
|
||||
foreach($m[0] as $key => $find) {
|
||||
|
||||
$func = trim(str_replace('[', '', $m[1][$key])); // "->_([" or "__([" or "_n([" or "_x(["
|
||||
$funcType = isset($funcTypes[$func]) ? $funcTypes[$func] : '_';
|
||||
$quote = $m[2][$key]; // single quote or double quote ['"]
|
||||
$text1 = $m[3][$key]; // first text in array
|
||||
$textArrayStr = trim($m[4][$key], ' ,[]'); // the other text phrases in the array (CSV and quoted)
|
||||
$theRest = $m[5][$key]; // remainder of function call, i.e. ", __FILE__)" or ", 'context-str'"
|
||||
$context = '';
|
||||
|
||||
$trimRest = ltrim($theRest, ', ');
|
||||
if($funcType === 'x' && (strpos($trimRest, '"') === 0 || strpos($trimRest, "'") === 0)) {
|
||||
if(preg_match('/^([\'"])(.+?)(?<!\\\\)\\1/', $trimRest, $matches)) {
|
||||
$context = $matches[2];
|
||||
}
|
||||
}
|
||||
|
||||
// Convert from: "__(['a', 'b', 'c'])" to "__('a')" and remember 'b' and 'c' alternates
|
||||
$replace = $func . $quote . $text1 . $quote . $theRest;
|
||||
$data = str_replace($find, $replace, $data);
|
||||
$text1 = $this->unescapeText($text1);
|
||||
|
||||
// Given string "'b', 'c'" convert to array and place in alternates
|
||||
if(preg_match_all('/(^|,\s*)([\'"])(.+?)(?<!\\\\)\\2/', $textArrayStr, $matches)) {
|
||||
$hash1 = $this->getTextHash($text1, $context);
|
||||
if(!isset($this->alternates[$hash1])) $this->alternates[$hash1] = array();
|
||||
foreach($matches[3] as $text) {
|
||||
$text2 = $this->unescapeText($text);
|
||||
$hash2 = $this->getTextHash($text, $context);
|
||||
$this->alternates[$hash1][$hash2] = $text2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run regexes on file contents to locate all translation functions
|
||||
*
|
||||
* @param string $file
|
||||
* @return array
|
||||
*
|
||||
*/
|
||||
protected function parseFile($file) {
|
||||
|
||||
|
||||
$matches = array(
|
||||
1 => array(), // $this->_('text');
|
||||
2 => array(), // __('text', [textdomain]);
|
||||
@@ -130,6 +216,7 @@ class LanguageParser extends Wire {
|
||||
if(!is_file($file)) return $matches;
|
||||
|
||||
$data = file_get_contents($file);
|
||||
$this->findArrayTranslations($data);
|
||||
|
||||
// Find $this->_('text') style matches
|
||||
preg_match_all( '/(>_)\(\s*' . // $this->_(
|
||||
@@ -162,6 +249,11 @@ class LanguageParser extends Wire {
|
||||
|
||||
/**
|
||||
* Build the match abstracted away from the preg_match result
|
||||
*
|
||||
* @param array $m
|
||||
* @param int $key
|
||||
* @param string $text
|
||||
* @return array
|
||||
*
|
||||
*/
|
||||
protected function buildMatch(array $m, $key, $text) {
|
||||
@@ -195,19 +287,18 @@ class LanguageParser extends Wire {
|
||||
|
||||
/**
|
||||
* Process the match and populate $this->untranslated and $this->comments
|
||||
*
|
||||
* @param array $match
|
||||
*
|
||||
*/
|
||||
protected function processMatch(array $match) {
|
||||
|
||||
$text = $match['text'];
|
||||
$text = $this->unescapeText($match['text']);
|
||||
$tail = $match['tail'];
|
||||
$context = $match['context'];
|
||||
$plural = $match['plural'];
|
||||
$comments = '';
|
||||
|
||||
// replace any escaped characters with non-escaped versions
|
||||
if(strpos($text, '\\') !== false) $text = str_replace(array('\\"', '\\\'', '\\$', '\\'), array('"', "'", '$', '\\'), $text);
|
||||
|
||||
// get the translation for $text in $context
|
||||
$translation = $this->translator->getTranslation($this->textdomain, $text, $context);
|
||||
|
||||
@@ -243,4 +334,34 @@ class LanguageParser extends Wire {
|
||||
if($comments) $this->comments[$hash] = $comments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace any escaped characters with non-escaped versions
|
||||
*
|
||||
* @param string $text
|
||||
* @return string
|
||||
*
|
||||
*/
|
||||
protected function unescapeText($text) {
|
||||
if(strpos($text, '\\') !== false) {
|
||||
$text = str_replace(array('\\"', '\\\'', '\\$', '\\'), array('"', "'", '$', '\\'), $text);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash for given text + context
|
||||
*
|
||||
* @param string $text
|
||||
* @param string $context
|
||||
* @return string
|
||||
*
|
||||
*/
|
||||
protected function getTextHash($text, $context) {
|
||||
$translation = $this->translator->getTranslation($this->textdomain, $text, $context); // get the translation for $text in $context
|
||||
if($translation == $text) $translation = ''; // if translation == $text then that means no translation was found, make $translation blank
|
||||
$hash = $this->translator->setTranslation($this->textdomain, $text, $translation, $context);
|
||||
if(!$hash) $hash = $text;
|
||||
return $hash;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -194,6 +194,7 @@ class ProcessLanguage extends ProcessPageType {
|
||||
$translationUrl = $this->translationUrl();
|
||||
/** @var Pagefile $pagefile */
|
||||
$pagefile = $event->arguments[0];
|
||||
/** @var Language $page */
|
||||
$page = $pagefile->get('page');
|
||||
|
||||
if($pagefile->ext() == 'csv') {
|
||||
@@ -215,25 +216,37 @@ class ProcessLanguage extends ProcessPageType {
|
||||
$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])) $numAbandoned++;
|
||||
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) {
|
||||
$message .= " <span class='ui-state-error-text'>(";
|
||||
if($numAbandoned) $message .= sprintf($this->_('%d abandoned'), $numAbandoned);
|
||||
if($numPending) $message .= ($numAbandoned ? ', ' : '') . sprintf($this->_('%d blank'), $numPending);
|
||||
$message .= ")</span>";
|
||||
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');
|
||||
|
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* This is the process assigned to the processwire/setup/language-translator/ page.
|
||||
*
|
||||
* ProcessWire 3.x, Copyright 2019 by Ryan Cramer
|
||||
* ProcessWire 3.x, Copyright 2020 by Ryan Cramer
|
||||
* https://processwire.com
|
||||
*
|
||||
*
|
||||
@@ -39,6 +39,14 @@ class ProcessLanguageTranslator extends Process {
|
||||
*/
|
||||
protected $untranslated = array();
|
||||
|
||||
/**
|
||||
* Alternate translations indexed by hash-key
|
||||
*
|
||||
* @var array
|
||||
*
|
||||
*/
|
||||
protected $alternates = array();
|
||||
|
||||
/**
|
||||
* Optional comment labels for translations indexed by hash.
|
||||
*
|
||||
@@ -367,8 +375,8 @@ class ProcessLanguageTranslator extends Process {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function executeEditField($hash, $untranslated, $translated) {
|
||||
|
||||
protected function executeEditField($hash, $untranslated, $translated, $alternates) {
|
||||
|
||||
/** @var InputfieldText $field */
|
||||
|
||||
if(strlen($untranslated) < 128) {
|
||||
@@ -399,6 +407,21 @@ class ProcessLanguageTranslator extends Process {
|
||||
}
|
||||
}
|
||||
|
||||
if($this->language && !$this->language->isDefault()) {
|
||||
foreach($alternates as $altText => $altTranslation) {
|
||||
if(empty($altText) || empty($altTranslation)) continue;
|
||||
$field->placeholder = $altTranslation;
|
||||
$field->notes = trim(
|
||||
"$field->notes\n" .
|
||||
sprintf(
|
||||
$this->_('Fallback: when untranslated, the text “%1$s” is used, which is translated from “%2$s”.'),
|
||||
$altTranslation, $altText
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if((!strlen($translated) || $translated === '+') && !$field instanceof InputfieldTextarea) {
|
||||
$languages = $this->wire('languages');
|
||||
$languages->setLanguage($this->language);
|
||||
@@ -434,14 +457,15 @@ class ProcessLanguageTranslator extends Process {
|
||||
$fieldset->attr('id+name', 'abandoned_fieldset');
|
||||
$fieldset->description = $this->_('The following unused translations were found. This means that the original untranslated text was either changed or deleted. It is recommended that you delete abandoned translations unless you need to keep them to copy/paste to a new translation.');
|
||||
$fieldset->collapsed = Inputfield::collapsedYes;
|
||||
|
||||
$n = 0;
|
||||
|
||||
foreach($translations as $hash => $translation) {
|
||||
|
||||
// if the hash still exists in the untranslated phrases, then it is not abandoned
|
||||
if(isset($this->untranslated[$hash])) continue;
|
||||
if(!isset($translation['text'])) $translation['text'] = '';
|
||||
|
||||
if(!$this->isAbandonedHash($hash)) continue;
|
||||
|
||||
$n++;
|
||||
/** @var InputfieldCheckbox $field */
|
||||
$field = $this->modules->get("InputfieldCheckbox");
|
||||
@@ -450,6 +474,7 @@ class ProcessLanguageTranslator extends Process {
|
||||
$field->description = !strlen($translation['text']) ? $this->_('[empty]') : $translation['text'];
|
||||
$field->label = $this->_('Delete?'); // Checkbox label
|
||||
$field->icon = 'trash-o';
|
||||
|
||||
$fieldset->add($field);
|
||||
}
|
||||
|
||||
@@ -509,7 +534,14 @@ class ProcessLanguageTranslator extends Process {
|
||||
|
||||
foreach($this->untranslated as $hash => $untranslated) {
|
||||
$translated = isset($translations[$hash]) ? $translations[$hash]['text'] : '';
|
||||
$form->add($this->executeEditField($hash, $untranslated, $translated));
|
||||
$alternates = array();
|
||||
if(isset($this->alternates[$hash])) {
|
||||
foreach($this->alternates[$hash] as $altHash => $altText) {
|
||||
if(!isset($translations[$altHash])) continue;
|
||||
$alternates[$altText] = $translations[$altHash]['text'];
|
||||
}
|
||||
}
|
||||
$form->add($this->executeEditField($hash, $untranslated, $translated, $alternates));
|
||||
}
|
||||
|
||||
$this->executeEditAbandoned($translations, $form);
|
||||
@@ -618,6 +650,7 @@ class ProcessLanguageTranslator extends Process {
|
||||
$parser = new LanguageParser($this->translator, $file);
|
||||
$this->comments = $parser->getComments();
|
||||
$this->untranslated = $parser->getUntranslated();
|
||||
$this->alternates = $parser->getAlternates();
|
||||
return $parser->getNumFound();
|
||||
}
|
||||
|
||||
@@ -756,5 +789,31 @@ class ProcessLanguageTranslator extends Process {
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the given translation hash abandoned?
|
||||
*
|
||||
* @param string $hash
|
||||
* @return bool
|
||||
* @since 3.0.151
|
||||
*
|
||||
*/
|
||||
protected function isAbandonedHash($hash) {
|
||||
// if the hash still exists in the untranslated phrases, then it is not abandoned
|
||||
if(isset($this->untranslated[$hash])) return false;
|
||||
if($this->language && $this->language->isDefault()) return true;
|
||||
$abandoned = true;
|
||||
|
||||
foreach($this->alternates as $srcHash => $values) {
|
||||
if(isset($values[$hash]) && isset($this->untranslated[$srcHash])) {
|
||||
// $text = $this->untranslated[$srcHash];
|
||||
// $notes = "Currently used as fallback translation for: " . $text;
|
||||
$abandoned = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $abandoned;
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user