954 lines
38 KiB
PHP
954 lines
38 KiB
PHP
<?php
|
|
/**
|
|
* @codeCoverageIgnore
|
|
* @noinspection PhpUnused
|
|
*/
|
|
class Loco_admin_DebugController extends Loco_mvc_AdminController {
|
|
|
|
/**
|
|
* Text domain of debugger, limits when gets logged
|
|
* @var string|null $domain
|
|
*/
|
|
private $domain;
|
|
|
|
/**
|
|
* Temporarily forced locale
|
|
* @var string|null $locale
|
|
*/
|
|
private $locale;
|
|
|
|
/**
|
|
* Log lines for final result
|
|
* @var null|ArrayIterator
|
|
*/
|
|
private $output;
|
|
|
|
/**
|
|
* Current indent for recursive logging calls
|
|
* @var string
|
|
*/
|
|
private $indent = '';
|
|
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function init(){
|
|
parent::init();
|
|
// get a better default locale than en_US
|
|
$locale = get_locale();
|
|
if( 'en_US' === $locale ){
|
|
foreach( get_available_languages() as $locale ){
|
|
if( 'en_US' !== $locale ){
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
$params = [
|
|
'domain' => '',
|
|
'locale' => '',
|
|
'msgid' => '',
|
|
'msgctxt' => '',
|
|
'msgid_plural' => '',
|
|
'n' => '',
|
|
'unhook' => '',
|
|
'loader' => '',
|
|
'loadpath' => '',
|
|
'jspath' => '',
|
|
];
|
|
$defaults = [
|
|
'n' => '1',
|
|
'domain' => 'default',
|
|
'locale' => $locale,
|
|
];
|
|
foreach( array_intersect_key(stripslashes_deep($_GET),$params) as $k => $value ){
|
|
if( '' !== $value ){
|
|
$params[$k] = $value;
|
|
}
|
|
}
|
|
$this->set('form', new Loco_mvc_ViewParams($params) );
|
|
$this->set('default', new Loco_mvc_ViewParams($defaults+$params) );
|
|
}
|
|
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
private function log( ...$args ){
|
|
$message = array_shift($args);
|
|
if( $args ){
|
|
$message = vsprintf($message,$args);
|
|
}
|
|
if( is_null($this->output) ){
|
|
$this->output = new ArrayIterator;
|
|
$this->set('log', $this->output );
|
|
}
|
|
// redact any path information outside of WordPress root, and shorten any common locations
|
|
$message = str_replace( [LOCO_LANG_DIR,WP_LANG_DIR,WP_CONTENT_DIR,ABSPATH], ['{loco_lang_dir}','{wp_lang_dir}','{wp_content_dir}','{abspath}'], $message );
|
|
$this->output[] = $this->indent.$message;
|
|
}
|
|
|
|
|
|
private function logDomainState( $domain ) {
|
|
$indent = $this->indent;
|
|
$this->indent = $indent.' . ';
|
|
// filter callback should log determined locale, but may not be set up yet
|
|
$locale = determine_locale();
|
|
$this->log('determine_locale() == %s', $locale );
|
|
// Show the state just prior to potentially triggering JIT. There are no hooks between __() and load_textdomain().
|
|
global $l10n, $l10n_unloaded, $wp_textdomain_registry;
|
|
$this->log('$l10[%s] == %s', $domain, self::debugMember($l10n,$domain) );
|
|
$this->log('$l10n_unloaded[%s] == %s', $domain, self::debugMember($l10n_unloaded,$domain) );
|
|
$this->log('$wp_textdomain_registry->has(%s) == %b', $domain, $wp_textdomain_registry->has($domain) );
|
|
$this->log('is_textdomain_loaded(%s) == %b', $domain, is_textdomain_loaded($domain) );
|
|
// the following will fire more hooks, making mess of logs. We should already see this value above directly from $l10n[$domain]
|
|
// $this->log(' ? get_translations_for_domain(%s) == %s', $domain, self::debugType( get_translations_for_domain($domain) ) );
|
|
$this->indent = $indent;
|
|
}
|
|
|
|
|
|
|
|
private static function debugMember( array $data, $key ){
|
|
return self::debugType( array_key_exists($key,$data) ? $data[$key] : null );
|
|
}
|
|
|
|
|
|
private static function debugType( $value ){
|
|
return is_object($value) ? get_class($value) : json_encode($value,JSON_UNESCAPED_SLASHES);
|
|
}
|
|
|
|
|
|
/**
|
|
* `loco_unload_early_textdomain` filter callback.
|
|
*/
|
|
public function filter_loco_unload_early_textdomain( $bool, $domain ){
|
|
if( $this->domain === $domain ){
|
|
$value = $GLOBALS['l10n'][$domain]??null;
|
|
$type = is_object($value) ? get_class($value) : gettype($value);
|
|
$this->log('~ filter:loco_unload_early_textdomain: $l10n[%s] => %s; returning %s', $domain, $type, json_encode($bool) );
|
|
}
|
|
return $bool;
|
|
}
|
|
|
|
|
|
/**
|
|
* `loco_unloaded_textdomain` action callback from the loading helper
|
|
*/
|
|
public function on_loco_unloaded_textdomain( $domain ){
|
|
if( $domain === $this->domain ){
|
|
$this->log('~ action:loco_unloaded_textdomain: Text domain loaded prematurely, unloaded "%s"',$domain);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* @deprecated
|
|
* `loco_unseen_textdomain` action callback from the loading helper
|
|
* TODO This has been scrapped in rewritten helper. Move the logic somewhere else.
|
|
*/
|
|
public function on_loco_unseen_textdomain( $domain ){
|
|
if( $domain !== $this->domain ){
|
|
return;
|
|
}
|
|
$locale = determine_locale();
|
|
if( 'en_US' === $locale ){
|
|
return;
|
|
}
|
|
if( is_textdomain_loaded($domain) ){
|
|
$this->log('~ action:loco_unseen_textdomain: "%s" was loaded before helper started',$domain);
|
|
}
|
|
else {
|
|
$this->log('~ action:loco_unseen_textdomain: "%s" isn\'t loaded for "%s"',$domain,$locale);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* `pre_determine_locale` filter callback
|
|
*/
|
|
public function filter_pre_determine_locale( ?string $locale = null ):?string {
|
|
if( is_string($this->locale) ) {
|
|
$this->log( '~ filter:pre_determine_locale: %s => %s', $locale ?: 'none', $this->locale );
|
|
$locale = $this->locale;
|
|
}
|
|
return $locale;
|
|
}
|
|
|
|
|
|
/**
|
|
* `load_textdomain` callback
|
|
*/
|
|
public function on_load_textdomain( $domain, $mopath ){
|
|
if( $domain === $this->domain ){
|
|
$this->log('~ action:load_textdomain: %s', $mopath );
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* `load_textdomain_mofile` callback
|
|
*/
|
|
public function filter_load_textdomain_mofile( $mofile, $domain ){
|
|
if( $domain === $this->domain ){
|
|
$this->log('~ filter:load_textdomain_mofile: %s (exists=%b)', $mofile, file_exists($mofile) );
|
|
}
|
|
return $mofile;
|
|
}
|
|
|
|
|
|
/**
|
|
* `load_translation_file` filter callback
|
|
*/
|
|
public function filter_load_translation_file( $file, $domain/*, $locale = ''*/ ){
|
|
if( $domain === $this->domain ){
|
|
$this->log('~ filter:load_translation_file: %s (exists=%b)', $file, file_exists($file) );
|
|
}
|
|
return $file;
|
|
}
|
|
|
|
|
|
/**
|
|
* `translation_file_format` filter callback
|
|
* TODO let form option override 'php' as preferred format
|
|
*/
|
|
public function filter_translation_file_format( $preferred_format, $domain ){
|
|
if( $domain === $this->domain ){
|
|
$this->log('~ filter:translation_file_format: %s', $preferred_format );
|
|
}
|
|
return $preferred_format;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* `lang_dir_for_domain` filter callback, requires WP>=6.6
|
|
*/
|
|
public function filter_lang_dir_for_domain( $path, $domain, $locale ){
|
|
if( $domain === $this->domain && $locale === $this->locale ){
|
|
if( $path ) {
|
|
$this->log( '~ filter:lang_dir_for_domain %s', $path );
|
|
}
|
|
else {
|
|
$this->log( '! filter:lang_dir_for_domain has no path. JIT likely to fail');
|
|
}
|
|
}
|
|
return $path;
|
|
}
|
|
|
|
|
|
/**
|
|
* `load_script_textdomain_relative_path` filter callback
|
|
*/
|
|
public function filter_load_script_textdomain_relative_path( $relative/*, $src*/ ){
|
|
if( preg_match('!pub/js/(?:min|src)/dummy.js!', $relative )){
|
|
$form = $this->get('form');
|
|
$path = $form['jspath'];
|
|
//error_log( json_encode(func_get_args(),JSON_UNESCAPED_SLASHES).' -> '.$path );
|
|
$this->log( '~ filter:load_script_textdomain_relative_path: %s => %s', $relative, $path );
|
|
return $path;
|
|
}
|
|
return $relative;
|
|
}
|
|
|
|
|
|
/**
|
|
* `pre_load_script_translations` filter callback
|
|
* @noinspection PhpUnusedParameterInspection
|
|
*/
|
|
public function filter_pre_load_script_translations( $translations, $file, $handle /*, $domain*/ ){
|
|
if( 'loco-translate-dummy' === $handle && ! is_null($translations) ){
|
|
$this->log('~ filter:pre_load_script_translations: Short-circuited with %s value', gettype($translations) );
|
|
}
|
|
return $translations;
|
|
}
|
|
|
|
|
|
/**
|
|
* `load_script_translation_file` filter callback.
|
|
*/
|
|
public function filter_load_script_translation_file( $file, $handle/* ,$domain*/ ){
|
|
if( 'loco-translate-dummy' === $handle ){
|
|
// error_log( json_encode(func_get_args(),JSON_UNESCAPED_SLASHES) );
|
|
// if file is not found, this will fire again with file=false
|
|
$this->log('~ filter:load_script_translation_file: %s', var_export($file,true) );
|
|
}
|
|
return $file;
|
|
}
|
|
|
|
|
|
/**
|
|
* `load_script_translations` filter callback
|
|
* @noinspection PhpUnusedParameterInspection
|
|
*/
|
|
public function filter_load_script_translations( $translations, $file, $handle, $domain ){
|
|
if( 'loco-translate-dummy' === $handle ){
|
|
// just log it if the value isn't JSON.
|
|
if( ! is_string($translations) || '' === $translations || '{' !== $translations[0] ) {
|
|
$this->log( '~ filter:load_script_translations: %s', var_export($translations,true) );
|
|
}
|
|
}
|
|
return $translations;
|
|
}
|
|
|
|
|
|
/**
|
|
* `[n]gettext[_with_context]` filter callback
|
|
*/
|
|
public function temp_filter_gettext(){
|
|
$i = func_num_args() - 1;
|
|
$args = func_get_args();
|
|
$translation = $args[0];
|
|
if( $args[$i] === $this->domain ){
|
|
$args = array_slice($args,1,--$i);
|
|
$opts = JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE;
|
|
$this->log('~ filter:gettext: %s => %s', json_encode($args,$opts), json_encode($translation,$opts) );
|
|
}
|
|
return $translation;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* @return null|Loco_package_Bundle
|
|
*/
|
|
private function getBundleByDomain( $domain, $type ){
|
|
if( 'default' === $domain ){
|
|
$this->log('Have WordPress core bundle');
|
|
return Loco_package_Core::create();
|
|
}
|
|
if( 'plugin' === $type ){
|
|
$search = Loco_package_Plugin::getAll();
|
|
}
|
|
else if( 'theme' === $type || 'child' === $type ){
|
|
$type = 'theme';
|
|
$search = Loco_package_Theme::getAll();
|
|
}
|
|
else {
|
|
$type = 'bundle';
|
|
$search = array_merge( Loco_package_Plugin::getAll(), Loco_package_Theme::getAll() );
|
|
}
|
|
/* @var Loco_package_Bundle $bundle */
|
|
foreach( $search as $bundle ){
|
|
/* @var Loco_package_Project $project */
|
|
foreach( $bundle as $project ){
|
|
if( $project->getDomain()->getName() === $domain ){
|
|
$this->log('Have %s bundle => %s', strtolower($bundle->getType()), $bundle->getName() );
|
|
return $bundle;
|
|
}
|
|
}
|
|
}
|
|
$message = 'No '.$type.' known with text domain '.$domain;
|
|
Loco_error_AdminNotices::warn($message);
|
|
$this->log('! '.$message);
|
|
return null;
|
|
}
|
|
|
|
|
|
/**
|
|
* @return LocoPoMessage|null
|
|
*/
|
|
private function findMessage( $findKey, Loco_gettext_Data $messages ){
|
|
/* @var LocoPoMessage $m */
|
|
foreach( $messages as $m ){
|
|
if( $m->getKey() === $findKey ){
|
|
return $m;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
|
|
/**
|
|
* Get translation from a message falling back to source, as per __, _n etc..
|
|
*/
|
|
private function getMsgstr( LocoPoMessage $m, $pluralIndex = 0 ){
|
|
$values = $m->exportSerial();
|
|
if( array_key_exists($pluralIndex,$values) && '' !== $values[$pluralIndex] ){
|
|
return $values[$pluralIndex];
|
|
}
|
|
$values = $m->exportSerial('source');
|
|
if( $pluralIndex ){
|
|
if( array_key_exists(1,$values) && '' !== $values[1] ){
|
|
return $values[1];
|
|
}
|
|
$this->log('! message is singular, defaulting to msgid');
|
|
}
|
|
return $values[0];
|
|
}
|
|
|
|
|
|
/**
|
|
* Look up a source key in given messages, returning source if untranslated, and null if not found.
|
|
* @return string|null
|
|
*/
|
|
private function findMsgstr( $findKey, $pluralIndex, Loco_gettext_Data $messages ){
|
|
$m = $this->findMessage( $findKey, $messages );
|
|
return $m ? $this->getMsgstr( $m, $pluralIndex ) : null;
|
|
}
|
|
|
|
|
|
/**
|
|
* @return Plural_Forms|null
|
|
*/
|
|
private function parsePluralForms( $raw ){
|
|
try {
|
|
$this->log('Parsing header: %s', $raw );
|
|
if( ! preg_match( '#^nplurals=\\d+;\\s*plural=([-+/*%!=<>|&?:()n\\d ]+);?$#', $raw, $match ) ) {
|
|
throw new InvalidArgumentException( 'Invalid Plural-Forms header, ' . json_encode($raw) );
|
|
}
|
|
return new Plural_Forms( trim( $match[1],'() ') );
|
|
}
|
|
catch( Exception $e ){
|
|
$this->log('! %s', $e->getMessage() );
|
|
return null;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private function selectPluralForm( $quantity, $pluralIndex, ?Plural_Forms $eq = null ){
|
|
try {
|
|
if( $eq instanceof Plural_Forms ) {
|
|
$pluralIndex = $eq->execute( $quantity );
|
|
$this->log( '> Selected plural form [%u]', $pluralIndex );
|
|
}
|
|
}
|
|
catch ( Exception $e ){
|
|
$this->log('! Keeping plural form [%u]; %s', $pluralIndex, $e->getMessage() );
|
|
}
|
|
return $pluralIndex;
|
|
}
|
|
|
|
|
|
/*private function logTextDomainsLoaded(){
|
|
foreach(['l10n','l10n_unloaded'] as $k ){
|
|
foreach( $GLOBALS[$k] as $d => $t ){
|
|
$type = is_object($t) ? get_class($t) : gettype($t);
|
|
$this->log('? $%s[%s] => %s', $k, var_export($d,true), $type );
|
|
}
|
|
}
|
|
}*/
|
|
|
|
|
|
/*public function on_unload_textdomain( $domain, $reloadable ){
|
|
$this->log('~ action:unload_textdomain: %s, reloadable = %b', $domain, $reloadable);
|
|
}*/
|
|
|
|
|
|
/**
|
|
* Forcefully remove the no reload flag which prevents JIT loading.
|
|
* Note that since WP 6.7 load_(theme|plugin)_textdomain invokes JIT loader
|
|
*/
|
|
private function unlockDomain( $domain ) {
|
|
global $l10n_unloaded;
|
|
if( is_array($l10n_unloaded) && isset($l10n_unloaded[$domain]) ){
|
|
$this->log('Removing JIT lock');
|
|
unset( $l10n_unloaded[$domain] );
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Prepare text domain for MO file lookup
|
|
* @return void
|
|
*/
|
|
private function preloadDomain( $domain, $type, $path ){
|
|
// plugin and theme loaders allow missing path argument, custom loader does not
|
|
if( '' === $path ){
|
|
$file = null;
|
|
$path = false;
|
|
}
|
|
// Just-in-time loader takes no path argument
|
|
else if( 'none' === $type || '' === $type ){
|
|
$file = null;
|
|
Loco_error_AdminNotices::debug('Path argument ignored. Not required for this loading option.');
|
|
}
|
|
else {
|
|
$this->log('Have path argument => %s', $path );
|
|
$file = new Loco_fs_File($path);
|
|
}
|
|
|
|
// Without a loader the current state of the text domain will be used for our translation.
|
|
// If the text domain was loaded before we set our locale, it may be in the wrong language.
|
|
if( 'none' === $type ){
|
|
$this->log('No loader, current state is:');
|
|
$this->logDomainState($domain);
|
|
// Note that is_textdomain_loaded() returns false even if NOOP_Translations is set,
|
|
// and NOOP_Translations being set prevents JIT loading, so will never translate our forced locale!
|
|
if( isset($GLOBALS['l10n'][$domain]) ){
|
|
// WordPress >= 6.5
|
|
if( class_exists('WP_Translation_Controller',false) ) {
|
|
$locale = WP_Translation_Controller::get_instance()->get_locale();
|
|
if( $locale && $locale !== $this->locale ){
|
|
Loco_error_AdminNotices::warn( sprintf('Translations already loaded for "%s". A loader is recommended to select "%s"',$locale,$this->locale) );
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Unload text domain for any forced loading method
|
|
$this->log('Unloading text domain for %s loader', $type?:'auto' );
|
|
$returned = unload_textdomain($domain);
|
|
$callee = 'unload_textdomain';
|
|
// Bootstrap text domain if a loading function was selected
|
|
if( 'plugin' === $type ){
|
|
if( $file ){
|
|
if( $file->isAbsolute() ){
|
|
$path = $file->getRelativePath(WP_PLUGIN_DIR);
|
|
}
|
|
else {
|
|
$file->normalize(WP_PLUGIN_DIR);
|
|
}
|
|
if( ! $file->exists() || ! $file->isDirectory() ){
|
|
throw new InvalidArgumentException('Loader argument must be a directory relative to WP_PLUGIN_DIR');
|
|
}
|
|
}
|
|
$this->log('Calling load_plugin_textdomain with $plugin_rel_path=%s',$path);
|
|
$returned = load_plugin_textdomain( $domain, false, $path );
|
|
$callee = 'load_plugin_textdomain';
|
|
$this->unlockDomain($domain);
|
|
}
|
|
else if( 'theme' === $type || 'child' === $type ){
|
|
// Note that absent path argument will use current theme, and not necessarily whatever $domain is
|
|
if( $file && ( ! $file->isAbsolute() || ! $file->isDirectory() ) ){
|
|
throw new InvalidArgumentException('Path argument must reference the theme directory');
|
|
}
|
|
$this->log('Calling load_theme_textdomain with $path=%s',$path);
|
|
$returned = load_theme_textdomain( $domain, $path );
|
|
$callee = 'load_theme_textdomain';
|
|
$this->unlockDomain($domain);
|
|
}
|
|
else if( 'custom' === $type ){
|
|
if( $file && ! $file->isAbsolute() ){
|
|
$path = $file->normalize(WP_CONTENT_DIR);
|
|
$this->log('Resolving relative path argument to %s',$path);
|
|
}
|
|
if( is_null($file) || ! $file->exists() || $file->isDirectory() ){
|
|
throw new InvalidArgumentException('Path argument must reference an existent file');
|
|
}
|
|
$expected = [ $this->locale.'.mo', $this->locale.'.l10n.php' ];
|
|
$bits = explode('-',$file->basename() );
|
|
if( ! in_array( end($bits), $expected) ){
|
|
throw new InvalidArgumentException('Path argument must end in '.$this->locale.'.mo');
|
|
}
|
|
$this->log('Calling load_textdomain with $mofile=%s',$path);
|
|
$returned = load_textdomain($domain,$path,$this->locale);
|
|
$callee = 'load_textdomain';
|
|
}
|
|
// JIT doesn't work for WordPress core
|
|
else if( 'default' === $domain ){
|
|
$this->log('Reloading default text domain');
|
|
$callee = 'load_default_textdomain';
|
|
$returned = load_default_textdomain($this->locale);
|
|
}
|
|
// Defaulting to JIT (auto):
|
|
// When we called unload_textdomain we passed $reloadable=false on purpose to force memory removal
|
|
// So if we want to allow _load_textdomain_just_in_time, we'll have to hack the reloadable lock.
|
|
else {
|
|
$this->unlockDomain($domain);
|
|
}
|
|
$this->log('> %s returned %s', $callee, var_export($returned,true) );
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Preload domain for a script, then forcing retrieval of JSON.
|
|
*/
|
|
private function preloadScript( $path, string $domain, ?Loco_package_Bundle $bundle = null ):Loco_gettext_Data {
|
|
$this->log('Have script argument => %s', $path );
|
|
if( preg_match('/^[0-9a-f]{32}$/',$path) ){
|
|
throw new Loco_error_Exception('Enter the script path, not the hash');
|
|
}
|
|
// normalize file reference if bundle is known. Warning already raised if not.
|
|
// simulator will allow non-existent js. We can still find translations even if it's absent.
|
|
$jsfile = new Loco_fs_File($path);
|
|
if( $bundle ){
|
|
$basepath = $bundle->getDirectoryPath();
|
|
if( $jsfile->isAbsolute() ) {
|
|
$path = $jsfile->getRelativePath($basepath);
|
|
$this->get('form')['jspath'] = $path;
|
|
}
|
|
else {
|
|
$jsfile->normalize($basepath);
|
|
}
|
|
if( ! $jsfile->exists() ){
|
|
$this->log( '! Script not found. load_script_textdomain may fail');
|
|
}
|
|
}
|
|
// log hashable path for comparison with what WordPress computes:
|
|
if( '.min.js' === substr($path,-7) ) {
|
|
$path = substr($path,0,-7).'.js';
|
|
}
|
|
else {
|
|
$valid = array_flip( Loco_data_Settings::get()->jsx_alias ?: ['js'] );
|
|
if( ! array_key_exists($jsfile->extension(),$valid) ) {
|
|
Loco_error_AdminNotices::debug("Script path didn't end with .".implode('|',array_keys($valid) ) );
|
|
}
|
|
}
|
|
$hash = md5($path);
|
|
$this->log('> md5(%s) => %s', var_export($path,true), $hash );
|
|
// filters will point our debug script to the actual script we're simulating
|
|
$handle = $this->enqueueScript('dummy');
|
|
if( ! wp_set_script_translations($handle,$domain) ){
|
|
throw new Loco_error_Exception('wp_set_script_translations returned false');
|
|
}
|
|
// load_script_textdomain won't fire until footer, so grab JSON directly
|
|
$this->log('Calling load_script_textdomain( %s )', trim(json_encode([$handle,$domain],JSON_UNESCAPED_SLASHES),'[]') );
|
|
$json = load_script_textdomain($handle,$domain);
|
|
$this->dequeueScript('dummy');
|
|
if( is_string($json) && '' !== $json ){
|
|
$this->log('> Parsing %u bytes of JSON...', strlen($json) );
|
|
return Loco_gettext_Data::fromJson($json);
|
|
}
|
|
throw new Loco_error_Exception('load_script_textdomain returned '.var_export($json,true) );
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Run the string lookup and render result screen, unless an error is thrown.
|
|
* @return string
|
|
*/
|
|
private function renderResult( Loco_mvc_ViewParams $form ){
|
|
$msgid = $form['msgid'];
|
|
$msgctxt = $form['msgctxt'];
|
|
// singular form by default
|
|
$msgid_plural = $form['msgid_plural'];
|
|
$quantity = ctype_digit($form['n']) ? (int) $form['n'] : 1;
|
|
$pluralIndex = 0;
|
|
//
|
|
$domain = $form['domain']?:'default';
|
|
$this->log('Running test for domain => %s', $domain );
|
|
//$this->logDomainState($domain);
|
|
$default = $this->get('default');
|
|
$tag = $form['locale'] ?: $default['locale'];
|
|
$locale = Loco_Locale::parse($tag);
|
|
if( ! $locale->isValid() ){
|
|
throw new InvalidArgumentException('Invalid locale code ('.$tag.')');
|
|
}
|
|
// unhook all existing filters, including our own
|
|
if( $form['unhook'] ){
|
|
$this->log('Unhooking l10n filters');
|
|
array_map( 'remove_all_filters', [
|
|
// these filters are all used by Loco_hooks_LoadHelper, and will need re-hooking afterwards:
|
|
'theme_locale','plugin_locale','unload_textdomain','load_textdomain','load_script_translation_file','load_script_translations',
|
|
// these filters also affect text domain loading / file reading:
|
|
'pre_load_textdomain','override_load_textdomain','load_textdomain_mofile','translation_file_format','load_translation_file','override_unload_textdomain','lang_dir_for_domain',
|
|
// script translation hooks:
|
|
'load_script_textdomain_relative_path','pre_load_script_translations','load_script_translation_file','load_script_translations',
|
|
// these filters affect translation fetching via __, _n, _x and _nx:
|
|
'gettext','ngettext','gettext_with_context','ngettext_with_context'
|
|
] );
|
|
// helper isn't a singleton, and will be garbage-collected now. Restart it.
|
|
new Loco_hooks_LoadHelper;
|
|
}
|
|
// Ensuring our forced locale requires no other filters be allowed to run.
|
|
// We're doing this whether "unhook" is set or not, otherwise determine_locale won't work.
|
|
remove_all_filters('pre_determine_locale');
|
|
$this->reHook();
|
|
$this->locale = (string) $locale;
|
|
$this->log('Have locale: %s', $this->locale );
|
|
$actual = determine_locale();
|
|
if( $actual !== $this->locale ){
|
|
$this->log('determine_locale() => %s', $actual );
|
|
Loco_error_AdminNotices::warn( sprintf('Locale %s is overriding %s', $actual, $this->locale) );
|
|
}
|
|
// Deferred setting of text domain to avoid hooks firing before we're ready
|
|
$this->domain = $domain;
|
|
//new Loco_hooks_LoadDebugger($domain);
|
|
|
|
// Perform preloading according to user choice, and optional path argument.
|
|
$type = $form['loader'];
|
|
$bundle = $this->getBundleByDomain($domain,$type);
|
|
$this->preloadDomain( $domain, $type, $form['loadpath'] );
|
|
|
|
// Create source message for string query
|
|
class_exists('Loco_gettext_Data');
|
|
$message = new LocoPoMessage(['source'=>$msgid,'context'=>$msgctxt,'target'=>'']);
|
|
$this->log('Query: %s', LocoPo::pair('msgid',$msgid) );
|
|
if( '' !== $msgid_plural ){
|
|
$this->log(' | %s (n=%u)', LocoPo::pair('msgid_plural',$msgid_plural), $quantity );
|
|
$message->offsetSet('plurals', [new LocoPoMessage(['source'=>$msgid_plural,'target'=>''])] );
|
|
}
|
|
$findKey = $message->getKey();
|
|
|
|
// Perform runtime translation request via WordPress
|
|
if( '' === $msgctxt ){
|
|
if( '' === $msgid_plural ) {
|
|
$callee = '__';
|
|
$params = [ $msgid, $domain ];
|
|
$this->addHook('gettext', 'temp_filter_gettext', 3, 99 );
|
|
}
|
|
else {
|
|
$callee = '_n';
|
|
$params = [ $msgid, $msgid_plural, $quantity, $domain ];
|
|
$this->addHook('ngettext', 'temp_filter_gettext', 5, 99 );
|
|
}
|
|
}
|
|
else {
|
|
$this->log(' | %s', LocoPo::pair('msgctxt',$msgctxt) );
|
|
if( '' === $msgid_plural ){
|
|
$callee = '_x';
|
|
$params = [ $msgid, $msgctxt, $domain ];
|
|
$this->addHook('gettext_with_context', 'temp_filter_gettext', 4, 99 );
|
|
}
|
|
else {
|
|
$callee = '_nx';
|
|
$params = [ $msgid, $msgid_plural, $quantity, $msgctxt, $domain ];
|
|
$this->addHook('ngettext_with_context', 'temp_filter_gettext', 6, 99 );
|
|
}
|
|
}
|
|
$this->log('Calling %s( %s )', $callee, trim( json_encode($params,JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE), '[]') );
|
|
$msgstr = call_user_func_array($callee,$params);
|
|
$this->log("====>| %s", LocoPo::pair('msgstr',$msgstr,0) );
|
|
|
|
// Post check for text domain auto-load failure
|
|
$loaded = get_translations_for_domain($domain);
|
|
if( ! is_textdomain_loaded($domain) ){
|
|
$this->log('! Text domain not loaded after %s() call completed', $callee );
|
|
$this->log(' get_translations_for_domain => %s', self::debugType($loaded) );
|
|
}
|
|
|
|
// Establish retrospectively if a non-zero plural index was used.
|
|
if( '' !== $msgid_plural ){
|
|
$header = null;
|
|
if( class_exists('WP_Translation_Controller',false) ){
|
|
$h = WP_Translation_Controller::get_instance()->get_headers($domain);
|
|
if( array_key_exists('Plural-Forms',$h) ) {
|
|
$header = $h['Plural-Forms'];
|
|
}
|
|
}
|
|
if( is_null($header) ){
|
|
$header = $locale->getPluralFormsHeader();
|
|
$this->log('! Can\'t get Plural-Forms; Using built-in rules');
|
|
}
|
|
$pluralIndex = $this->selectPluralForm( $quantity, $pluralIndex, $this->parsePluralForms($header) );
|
|
}
|
|
|
|
// Simulate JavaScript translation if script path is set. This will be used as a secondary result.
|
|
$path = $form['jspath'];
|
|
if( is_string($path) && '' !== $path ) {
|
|
try {
|
|
$data = $this->preloadScript( $path, $domain, $bundle );
|
|
// Let JED-defined plural forms override plural index
|
|
if( '' !== $msgid_plural ){
|
|
$header = $data->getHeaders()->offsetGet('Plural-Forms');
|
|
if( $header ){
|
|
$pluralIndex = $this->selectPluralForm( $quantity, $pluralIndex, $this->parsePluralForms($header) );
|
|
}
|
|
}
|
|
$msgstr = $this->findMsgstr( $findKey, $pluralIndex, $data );
|
|
if( is_null($msgstr) ){
|
|
$this->log('! No match in JSON');
|
|
}
|
|
else {
|
|
$this->log("====>| %s", LocoPo::pair('msgstr',$msgstr,0) );
|
|
}
|
|
// Override primary translation result for script translation
|
|
$callee = 'load_script_textdomain';
|
|
}
|
|
catch( Exception $e ){
|
|
$this->log('! %s (falling back to PHP)', $e->getMessage() );
|
|
Loco_error_AdminNotices::warn('Script translation failed. Falling back to PHP translation');
|
|
}
|
|
}
|
|
|
|
// Establish translation success, assuming that source being returned is equivalent to an absent translation
|
|
$fallback = $pluralIndex ? $msgid_plural : $msgid;
|
|
$translated = is_string($msgstr) && '' !== $msgstr && $msgstr !== $fallback;
|
|
$this->log('Translated result state => %s', $translated?'true':'false');
|
|
|
|
// We're done with our temporary hooks now.
|
|
$this->domain = null;
|
|
$this->locale = null;
|
|
|
|
// Obtain all possible translations from all known targets (requires bundle)
|
|
$pofiles = new Loco_fs_FileList;
|
|
if( $bundle ){
|
|
foreach( $bundle as $project ) {
|
|
if( $project instanceof Loco_package_Project && $project->getDomain()->getName() === $domain ){
|
|
$pofiles->augment( $project->initLocaleFiles($locale) );
|
|
}
|
|
}
|
|
}
|
|
// Without a configured bundle, we'll have to search all possible locations, but this won't include Author files.
|
|
// We may as well add these anyway, in case bundle is misconfigured. Small risk of plugin/theme domain conflicts.
|
|
if( 'default' !== $domain ){
|
|
/* @var Loco_package_Bundle $tmp */
|
|
foreach( [ new Loco_package_Plugin('',''), new Loco_package_Theme('','') ] as $tmp ) {
|
|
foreach( $tmp->getSystemTargets() as $root ){
|
|
$pofiles->add( new Loco_fs_LocaleFile( sprintf('%s/%s-%s.po',$root,$domain,$locale) ) );
|
|
}
|
|
}
|
|
}
|
|
$grouped = [];
|
|
$matches = [];
|
|
$searched = 0;
|
|
$matched = 0;
|
|
$this->log('Searching %u possible locations for string versions', $pofiles->count() );
|
|
/* @var Loco_fs_LocaleFile $pofile */
|
|
foreach( $pofiles as $pofile ){
|
|
// initialize translation set for this PO and its siblings
|
|
$dir = new Loco_fs_LocaleDirectory( $pofile->dirname() );
|
|
$type = $dir->getTypeId();
|
|
$args = [ 'type' => $dir->getTypeLabel($type) ];
|
|
// as long as we know the bundle and the PO file exists, we can link to the editor.
|
|
// bear in mind that domain may not be unique to one set of translations (core) so ...
|
|
if( $bundle && $pofile->exists() ){
|
|
$route = strtolower($bundle->getType()).'-file-edit';
|
|
// find exact project in bundle. Required for core, or any multi-domain bundle
|
|
$project = $bundle->getDefaultProject();
|
|
if( is_null($project) || 1 < $bundle->count() ){
|
|
$slug = $pofile->getPrefix();
|
|
foreach( $bundle as $candidate ){
|
|
if( $candidate->getSlug() === $slug ){
|
|
$project = $candidate;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
$args['href'] = Loco_mvc_AdminRouter::generate( $route, [
|
|
'bundle' => $bundle->getHandle(),
|
|
'domain' => $project ? $project->getId() : $domain,
|
|
'path' => $pofile->getRelativePath(WP_CONTENT_DIR),
|
|
] );
|
|
}
|
|
$groupIdx = count($grouped);
|
|
$grouped[] = new Loco_mvc_FileParams( $args, $pofile );
|
|
// even if PO file is missing, we can search the MO, JSON etc..
|
|
$siblings = new Loco_fs_Siblings($pofile);
|
|
$siblings->setDomain($domain);
|
|
$exts = [];
|
|
foreach( $siblings->expand() as $file ){
|
|
try {
|
|
$ext = strtolower( $file->fullExtension() );
|
|
if( ! preg_match('!^(?:pot?|mo|json|l10n\\.php)$!',$ext) || ! $file->exists() ){
|
|
continue;
|
|
}
|
|
$searched++;
|
|
$message = $this->findMessage($findKey,Loco_gettext_Data::load($file));
|
|
if( $message ){
|
|
$matched++;
|
|
$value = $this->getMsgstr($message,$pluralIndex);
|
|
$args = [ 'msgstr' => $value ];
|
|
$matches[$groupIdx][] = new Loco_mvc_FileParams($args,$file);
|
|
$this->log('> found in %s => %s', $file, var_export($value,true) );
|
|
$exts[$ext] = $message->translated();
|
|
}
|
|
}
|
|
catch( Exception $e ){
|
|
Loco_error_Debug::trace( '%s in %s', $e->getMessage(), $file );
|
|
}
|
|
}
|
|
// warn if found in PO, but not MO.
|
|
if( isset($exts['po']) && $exts['po'] && ! isset($exts['mo']) ){
|
|
Loco_error_AdminNotices::debug('Found in PO, but not MO. Is it fuzzy? Does it need recompiling?');
|
|
}
|
|
}
|
|
|
|
// display result if translation occurred, or if we found the string in at least one file, even if empty
|
|
$this->log('> %u matches in %u locations; %u files searched', $matched, count($grouped), $searched );
|
|
if( $matches || $translated ){
|
|
$result = new Loco_mvc_ViewParams( $form->getArrayCopy() );
|
|
$result['translated'] = $translated;
|
|
$result['msgstr'] = $msgstr;
|
|
$result['callee'] = $callee;
|
|
$result['grouped'] = $grouped;
|
|
$result['matches'] = $matches;
|
|
$result['searched'] = $searched;
|
|
$result['calleeDoc'] = 'https://developer.wordpress.org/reference/functions/'.$callee.'/';
|
|
return $this->view( 'admin/debug/debug-result', ['result'=>$result]);
|
|
}
|
|
// Source string not found in any translation files
|
|
$name = $bundle ? $bundle->getName() : $domain;
|
|
throw new Loco_error_Warning('No `'.$locale.'` translations found for this string in '.$name );
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
private function surpriseMe(){
|
|
$project = null;
|
|
/* @var Loco_package_Bundle[] $bundles */
|
|
$bundles = array_merge( Loco_package_Plugin::getAll(), Loco_package_Theme::getAll(), [ Loco_package_Core::create() ] );
|
|
while( $bundles && is_null($project) ){
|
|
$key = array_rand($bundles);
|
|
$project = $bundles[$key]->getDefaultProject();
|
|
unset($bundles[$key]);
|
|
}
|
|
// It should be impossible for project to be null, due to WordPress core always being non-empty
|
|
if( ! $project instanceof Loco_package_Project ){
|
|
throw new LogicException('No translation projects');
|
|
}
|
|
$domain = $project->getDomain()->getName();
|
|
// Pluck a random locale from existing PO translations
|
|
$files = $project->findLocaleFiles('po')->getArrayCopy();
|
|
$pofile = $files ? $files[ array_rand($files) ] : null;
|
|
$locale = $pofile instanceof Loco_fs_LocaleFile ? (string) $pofile->getLocale() : '';
|
|
// Get a random source string from the code... avoiding full extraction.. pluck a PHP file...
|
|
class_exists('Loco_gettext_Data');
|
|
$message = new LocoPoMessage(['source'=>'']);
|
|
$extractor = loco_wp_extractor();
|
|
$extractor->setDomain($domain);
|
|
$files = $project->getSourceFinder()->group('php')->export()->getArrayCopy();
|
|
while( $files ){
|
|
$key = array_rand($files);
|
|
$file = $files[$key];
|
|
$strings = ( new LocoExtracted )->extractSource( $extractor, $file->getContents() )->export();
|
|
if( $strings ){
|
|
$message = new LocoPoMessage( $strings[ array_rand($strings) ] );
|
|
break;
|
|
}
|
|
// try next source file...
|
|
unset($files[$key]);
|
|
}
|
|
// apply random choice
|
|
$form = $this->get('form');
|
|
$form['domain'] = $domain;
|
|
$form['locale'] = $locale;
|
|
$form['msgid'] = $message->source;
|
|
$form['msgctxt'] = $message->context;
|
|
// random message could be a plural form
|
|
$plurals = $message->plurals;
|
|
if( is_array($plurals) && array_key_exists(0,$plurals) && $plurals[0] instanceof LocoPoMessage ){
|
|
$form['msgid_plural'] = $plurals[0]['source'];
|
|
}
|
|
Loco_error_AdminNotices::info( sprintf('Randomly selected "%s". Click Submit to check a string.', $project->getName() ) );
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function render(){
|
|
$title = __('String debugger','loco-translate');
|
|
$this->set('breadcrumb', Loco_admin_Navigation::createSimple($title) );
|
|
|
|
try {
|
|
// Process form if (at least) "msgid" is set
|
|
$form = $this->get('form');
|
|
if( '' !== $form['msgid'] ){
|
|
return $this->renderResult($form);
|
|
}
|
|
// Pluck a random string for testing
|
|
else if( array_key_exists('randomize',$_GET) ){
|
|
$this->surpriseMe();
|
|
}
|
|
}
|
|
catch( Loco_error_Exception $e ){
|
|
Loco_error_AdminNotices::add($e);
|
|
}
|
|
catch( Exception $e ){
|
|
Loco_error_AdminNotices::add( Loco_error_Exception::convert($e) );
|
|
}
|
|
|
|
return $this->view('admin/debug/debug-form');
|
|
}
|
|
|
|
}
|