Plugin Directory

source: loco-translate/trunk/src/admin/DebugController.php

Last change on this file was 3269005, checked in by timwhitlock, 8 months ago

Compatible with PHP 8.4

File size: 38.3 KB
Line 
1<?php
2/**
3 * @codeCoverageIgnore
4 * @noinspection PhpUnused
5 */
6class Loco_admin_DebugController extends Loco_mvc_AdminController {
7
8    /**
9     * Text domain of debugger, limits when gets logged
10     * @var string|null $domain
11     */
12    private $domain;
13
14    /**
15     * Temporarily forced locale
16     * @var string|null $locale
17     */
18    private $locale;
19
20    /**
21     * Log lines for final result
22     * @var null|ArrayIterator
23     */   
24    private $output;
25
26    /**
27     * Current indent for recursive logging calls
28     * @var string
29     */
30    private $indent = '';
31
32
33    /**
34     * {@inheritdoc}
35     */
36    public function init(){
37        parent::init();
38        // get a better default locale than en_US
39        $locale = get_locale();
40        if( 'en_US' === $locale ){
41            foreach( get_available_languages() as $locale ){
42                if( 'en_US' !== $locale ){
43                    break;
44                }
45            }
46        }
47        $params = [
48            'domain' => '',
49            'locale' => '',
50            'msgid' => '',
51            'msgctxt' => '',
52            'msgid_plural' => '',
53            'n' => '',
54            'unhook' => '',
55            'loader' => '',
56            'loadpath' => '',
57            'jspath' => '',
58        ];
59        $defaults = [
60            'n' => '1',
61            'domain' => 'default',
62            'locale' => $locale,
63        ];
64        foreach( array_intersect_key(stripslashes_deep($_GET),$params) as $k => $value ){
65            if( '' !== $value ){
66                $params[$k] = $value;
67            }
68        }
69        $this->set('form', new Loco_mvc_ViewParams($params) );
70        $this->set('default', new Loco_mvc_ViewParams($defaults+$params) );
71    }
72
73
74    /**
75     * @return void
76     */
77    private function log( ...$args ){
78        $message = array_shift($args);
79        if( $args ){
80            $message = vsprintf($message,$args);
81        }
82        if( is_null($this->output) ){
83            $this->output = new ArrayIterator;
84            $this->set('log', $this->output );
85        }
86        // redact any path information outside of WordPress root, and shorten any common locations
87        $message = str_replace( [LOCO_LANG_DIR,WP_LANG_DIR,WP_CONTENT_DIR,ABSPATH], ['{loco_lang_dir}','{wp_lang_dir}','{wp_content_dir}','{abspath}'], $message );
88        $this->output[] = $this->indent.$message;
89    }
90   
91   
92    private function logDomainState( $domain ) {
93        $indent = $this->indent;
94        $this->indent = $indent.' . '; 
95        // filter callback should log determined locale, but may not be set up yet
96        $locale = determine_locale();
97        $this->log('determine_locale() == %s', $locale );
98        // Show the state just prior to potentially triggering JIT. There are no hooks between __() and load_textdomain().
99        global $l10n, $l10n_unloaded, $wp_textdomain_registry;
100        $this->log('$l10[%s] == %s', $domain, self::debugMember($l10n,$domain) );
101        $this->log('$l10n_unloaded[%s] == %s', $domain, self::debugMember($l10n_unloaded,$domain) );
102        $this->log('$wp_textdomain_registry->has(%s) == %b', $domain, $wp_textdomain_registry->has($domain) );
103        $this->log('is_textdomain_loaded(%s) == %b', $domain, is_textdomain_loaded($domain) );
104        // the following will fire more hooks, making mess of logs. We should already see this value above directly from $l10n[$domain]
105        // $this->log('  ? get_translations_for_domain(%s) == %s', $domain, self::debugType( get_translations_for_domain($domain) ) );
106        $this->indent = $indent;
107    }
108
109
110
111    private static function debugMember( array $data, $key ){
112        return self::debugType( array_key_exists($key,$data) ? $data[$key] : null );
113    }
114
115
116    private static function debugType( $value ){
117        return is_object($value) ? get_class($value) : json_encode($value,JSON_UNESCAPED_SLASHES);
118    }
119
120
121    /**
122     * `loco_unload_early_textdomain` filter callback.
123     */
124    public function filter_loco_unload_early_textdomain( $bool, $domain ){
125        if( $this->domain === $domain ){
126            $value = $GLOBALS['l10n'][$domain]??null;
127            $type = is_object($value) ? get_class($value) : gettype($value);
128            $this->log('~ filter:loco_unload_early_textdomain: $l10n[%s] => %s; returning %s', $domain, $type, json_encode($bool) );
129        }
130        return $bool;
131    }
132
133
134    /**
135     * `loco_unloaded_textdomain` action callback from the loading helper
136     */
137    public function on_loco_unloaded_textdomain( $domain ){
138        if( $domain === $this->domain ){
139            $this->log('~ action:loco_unloaded_textdomain: Text domain loaded prematurely, unloaded "%s"',$domain);
140        }
141    }
142
143
144    /**
145     * @deprecated
146     * `loco_unseen_textdomain` action callback from the loading helper
147     * TODO This has been scrapped in rewritten helper. Move the logic somewhere else.
148     */
149    public function on_loco_unseen_textdomain( $domain ){
150        if( $domain !== $this->domain ){
151            return;
152        }
153        $locale = determine_locale();
154        if( 'en_US' === $locale ){
155            return;
156        }
157        if( is_textdomain_loaded($domain) ){
158            $this->log('~ action:loco_unseen_textdomain: "%s" was loaded before helper started',$domain);
159        }
160        else {
161            $this->log('~ action:loco_unseen_textdomain: "%s" isn\'t loaded for "%s"',$domain,$locale);
162        }
163    }
164
165
166    /**
167     * `pre_determine_locale` filter callback
168     */
169    public function filter_pre_determine_locale( ?string $locale = null ):?string {
170        if( is_string($this->locale) ) {
171            $this->log( '~ filter:pre_determine_locale: %s => %s', $locale ?: 'none', $this->locale );
172            $locale = $this->locale;
173        }
174        return $locale;
175    }
176
177
178    /**
179     * `load_textdomain` callback
180     */
181    public function on_load_textdomain( $domain, $mopath ){
182        if( $domain === $this->domain ){
183            $this->log('~ action:load_textdomain: %s', $mopath );
184        }
185    }
186
187
188    /**
189     * `load_textdomain_mofile` callback
190     */
191    public function filter_load_textdomain_mofile( $mofile, $domain ){
192        if( $domain === $this->domain ){
193            $this->log('~ filter:load_textdomain_mofile: %s (exists=%b)', $mofile, file_exists($mofile) );
194        }
195        return $mofile;
196    }
197
198
199    /**
200     * `load_translation_file` filter callback
201     */
202    public function filter_load_translation_file( $file, $domain/*, $locale = ''*/ ){
203        if( $domain === $this->domain ){
204            $this->log('~ filter:load_translation_file: %s (exists=%b)', $file, file_exists($file) );
205        }
206        return $file;
207    }
208
209
210    /**
211     * `translation_file_format` filter callback
212     * TODO let form option override 'php' as preferred format
213     */
214    public function filter_translation_file_format( $preferred_format, $domain ){
215        if( $domain === $this->domain ){
216            $this->log('~ filter:translation_file_format: %s', $preferred_format );
217        }
218        return $preferred_format;
219    }
220
221
222
223    /**
224     * `lang_dir_for_domain` filter callback, requires WP>=6.6
225     */
226    public function filter_lang_dir_for_domain( $path, $domain, $locale ){
227        if( $domain === $this->domain && $locale === $this->locale ){
228            if( $path ) {
229                $this->log( '~ filter:lang_dir_for_domain %s', $path );
230            }
231            else {
232                $this->log( '! filter:lang_dir_for_domain has no path. JIT likely to fail');
233            }
234        }
235        return $path;
236    }
237
238
239    /**
240     * `load_script_textdomain_relative_path` filter callback
241     */
242    public function filter_load_script_textdomain_relative_path( $relative/*, $src*/ ){
243        if( preg_match('!pub/js/(?:min|src)/dummy.js!', $relative )){
244            $form = $this->get('form');
245            $path = $form['jspath'];
246            //error_log( json_encode(func_get_args(),JSON_UNESCAPED_SLASHES).' -> '.$path );
247            $this->log( '~ filter:load_script_textdomain_relative_path: %s => %s', $relative, $path );
248            return $path;
249        }
250        return $relative;
251    }
252
253
254    /**
255     * `pre_load_script_translations` filter callback
256     * @noinspection PhpUnusedParameterInspection
257     */
258    public function filter_pre_load_script_translations( $translations, $file, $handle /*, $domain*/ ){
259        if( 'loco-translate-dummy' === $handle && ! is_null($translations) ){
260            $this->log('~ filter:pre_load_script_translations: Short-circuited with %s value', gettype($translations) );
261        }
262        return $translations;
263    }
264
265
266    /**
267     * `load_script_translation_file` filter callback.
268     */
269    public function filter_load_script_translation_file( $file, $handle/* ,$domain*/ ){
270        if( 'loco-translate-dummy' === $handle ){
271            // error_log( json_encode(func_get_args(),JSON_UNESCAPED_SLASHES) );
272            // if file is not found, this will fire again with file=false
273            $this->log('~ filter:load_script_translation_file: %s', var_export($file,true) );
274        }
275        return $file;
276    }
277
278
279    /**
280     * `load_script_translations` filter callback
281     * @noinspection PhpUnusedParameterInspection
282     */
283    public function filter_load_script_translations( $translations, $file, $handle, $domain ){
284        if( 'loco-translate-dummy' === $handle ){
285            // just log it if the value isn't JSON.
286            if( ! is_string($translations) || '' === $translations || '{' !== $translations[0] ) {
287                $this->log( '~ filter:load_script_translations: %s', var_export($translations,true) );
288            }
289        }
290        return $translations;
291    }
292
293
294    /**
295     * `[n]gettext[_with_context]` filter callback
296     */
297    public function temp_filter_gettext(){
298        $i = func_num_args() - 1;
299        $args = func_get_args();
300        $translation = $args[0];
301        if( $args[$i] === $this->domain ){
302            $args = array_slice($args,1,--$i);
303            $opts = JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE;
304            $this->log('~ filter:gettext: %s => %s', json_encode($args,$opts), json_encode($translation,$opts) );
305        }
306        return $translation;
307    }
308
309
310
311    /**
312     * @return null|Loco_package_Bundle
313     */
314    private function getBundleByDomain( $domain, $type ){
315        if( 'default' === $domain ){
316            $this->log('Have WordPress core bundle');
317            return Loco_package_Core::create();
318        }
319        if( 'plugin' === $type ){
320            $search = Loco_package_Plugin::getAll();
321        }
322        else if( 'theme' === $type || 'child' === $type ){
323            $type = 'theme';
324            $search = Loco_package_Theme::getAll();
325        }
326        else {
327            $type = 'bundle';
328            $search = array_merge( Loco_package_Plugin::getAll(), Loco_package_Theme::getAll() );
329        }
330        /* @var Loco_package_Bundle $bundle */
331        foreach( $search as $bundle ){
332            /* @var Loco_package_Project $project */
333            foreach( $bundle as $project ){
334                if( $project->getDomain()->getName() === $domain ){
335                    $this->log('Have %s bundle => %s', strtolower($bundle->getType()), $bundle->getName() );
336                    return $bundle;
337                }
338            }
339        }
340        $message = 'No '.$type.' known with text domain '.$domain;
341        Loco_error_AdminNotices::warn($message);
342        $this->log('! '.$message);
343        return null;
344    }
345
346
347    /**
348     * @return LocoPoMessage|null
349     */
350    private function findMessage( $findKey, Loco_gettext_Data $messages ){
351        /* @var LocoPoMessage $m */
352        foreach( $messages as $m ){
353            if( $m->getKey() === $findKey ){
354                return $m;
355            }
356        }
357        return null;
358    }
359
360
361    /**
362     * Get translation from a message falling back to source, as per __, _n etc..
363     */
364    private function getMsgstr( LocoPoMessage $m, $pluralIndex = 0 ){
365        $values = $m->exportSerial();
366        if( array_key_exists($pluralIndex,$values) && '' !== $values[$pluralIndex] ){
367            return $values[$pluralIndex];
368        }
369        $values = $m->exportSerial('source');
370        if( $pluralIndex ){
371            if( array_key_exists(1,$values) && '' !== $values[1] ){
372                return $values[1];
373            }
374            $this->log('! message is singular, defaulting to msgid');
375        }
376        return $values[0];
377    }
378
379
380    /**
381     * Look up a source key in given messages, returning source if untranslated, and null if not found.
382     * @return string|null
383     */
384    private function findMsgstr( $findKey, $pluralIndex, Loco_gettext_Data $messages ){
385        $m = $this->findMessage( $findKey, $messages );
386        return $m ? $this->getMsgstr( $m, $pluralIndex ) : null;
387    }
388
389
390    /**
391     * @return Plural_Forms|null
392     */
393    private function parsePluralForms( $raw ){
394        try {
395            $this->log('Parsing header: %s', $raw );
396            if( ! preg_match( '#^nplurals=\\d+;\\s*plural=([-+/*%!=<>|&?:()n\\d ]+);?$#', $raw, $match ) ) {
397                throw new InvalidArgumentException( 'Invalid Plural-Forms header, ' . json_encode($raw) );
398            }
399            return new Plural_Forms( trim( $match[1],'() ') );
400        }
401        catch( Exception $e ){
402            $this->log('! %s', $e->getMessage() );
403            return null;
404        }
405    }
406   
407   
408   
409    private function selectPluralForm( $quantity, $pluralIndex, ?Plural_Forms $eq = null ){
410        try {
411            if( $eq instanceof Plural_Forms ) {
412                $pluralIndex = $eq->execute( $quantity );
413                $this->log( '> Selected plural form [%u]', $pluralIndex );
414            }
415        }
416        catch ( Exception $e ){
417            $this->log('! Keeping plural form [%u]; %s', $pluralIndex, $e->getMessage() );
418        }
419        return $pluralIndex;
420    }
421   
422   
423    /*private function logTextDomainsLoaded(){
424        foreach(['l10n','l10n_unloaded'] as $k ){
425            foreach( $GLOBALS[$k] as $d => $t ){
426                $type = is_object($t) ? get_class($t) : gettype($t);
427                $this->log('? $%s[%s] => %s', $k, var_export($d,true), $type );
428            }
429        }
430    }*/
431   
432   
433    /*public function on_unload_textdomain( $domain, $reloadable ){
434        $this->log('~ action:unload_textdomain: %s, reloadable = %b', $domain, $reloadable);
435    }*/
436
437
438    /**
439     * Forcefully remove the no reload flag which prevents JIT loading.
440     * Note that since WP 6.7 load_(theme|plugin)_textdomain invokes JIT loader
441     */
442    private function unlockDomain( $domain ) {
443        global $l10n_unloaded;
444        if( is_array($l10n_unloaded) && isset($l10n_unloaded[$domain]) ){
445            $this->log('Removing JIT lock');
446            unset( $l10n_unloaded[$domain] );
447        }
448    }
449
450
451    /**
452     * Prepare text domain for MO file lookup
453     * @return void
454     */
455    private function preloadDomain( $domain, $type, $path ){
456        // plugin and theme loaders allow missing path argument, custom loader does not
457        if( '' === $path ){
458            $file = null;
459            $path = false;
460        }
461        // Just-in-time loader takes no path argument
462        else if( 'none' === $type || '' === $type ){
463            $file = null;
464            Loco_error_AdminNotices::debug('Path argument ignored. Not required for this loading option.');
465        }
466        else {
467            $this->log('Have path argument => %s', $path );
468            $file = new Loco_fs_File($path);
469        }
470
471        // Without a loader the current state of the text domain will be used for our translation.
472        // If the text domain was loaded before we set our locale, it may be in the wrong language.
473        if( 'none' === $type ){
474            $this->log('No loader, current state is:');
475            $this->logDomainState($domain);
476            // Note that is_textdomain_loaded() returns false even if NOOP_Translations is set,
477            // and NOOP_Translations being set prevents JIT loading, so will never translate our forced locale!
478            if( isset($GLOBALS['l10n'][$domain]) ){
479                // WordPress >= 6.5
480                if( class_exists('WP_Translation_Controller',false) ) {
481                    $locale = WP_Translation_Controller::get_instance()->get_locale();
482                    if( $locale && $locale !== $this->locale ){
483                        Loco_error_AdminNotices::warn( sprintf('Translations already loaded for "%s". A loader is recommended to select "%s"',$locale,$this->locale) );
484                    }
485                }
486            }
487            return;
488        }
489       
490        // Unload text domain for any forced loading method
491        $this->log('Unloading text domain for %s loader', $type?:'auto' );
492        $returned = unload_textdomain($domain);
493        $callee = 'unload_textdomain';
494        // Bootstrap text domain if a loading function was selected
495        if( 'plugin' === $type ){
496            if( $file ){
497                if( $file->isAbsolute() ){
498                    $path = $file->getRelativePath(WP_PLUGIN_DIR);
499                }
500                else {
501                    $file->normalize(WP_PLUGIN_DIR);
502                }
503                if( ! $file->exists() || ! $file->isDirectory() ){
504                    throw new InvalidArgumentException('Loader argument must be a directory relative to WP_PLUGIN_DIR');
505                }
506            }
507            $this->log('Calling load_plugin_textdomain with $plugin_rel_path=%s',$path);
508            $returned = load_plugin_textdomain( $domain, false, $path );
509            $callee = 'load_plugin_textdomain';
510            $this->unlockDomain($domain);
511        }
512        else if( 'theme' === $type || 'child' === $type ){
513            // Note that absent path argument will use current theme, and not necessarily whatever $domain is
514            if( $file && ( ! $file->isAbsolute() || ! $file->isDirectory() ) ){
515                throw new InvalidArgumentException('Path argument must reference the theme directory');
516            }
517            $this->log('Calling load_theme_textdomain with $path=%s',$path);
518            $returned = load_theme_textdomain( $domain, $path );
519            $callee = 'load_theme_textdomain';
520            $this->unlockDomain($domain);
521        }
522        else if( 'custom' === $type ){
523            if( $file && ! $file->isAbsolute() ){
524                $path = $file->normalize(WP_CONTENT_DIR);
525                $this->log('Resolving relative path argument to %s',$path);
526            }
527            if( is_null($file) || ! $file->exists() || $file->isDirectory() ){
528                throw new InvalidArgumentException('Path argument must reference an existent file');
529            }
530            $expected = [ $this->locale.'.mo', $this->locale.'.l10n.php' ];
531            $bits = explode('-',$file->basename() );
532            if( ! in_array( end($bits), $expected) ){
533                throw new InvalidArgumentException('Path argument must end in '.$this->locale.'.mo');
534            }
535            $this->log('Calling load_textdomain with $mofile=%s',$path);
536            $returned = load_textdomain($domain,$path,$this->locale);
537            $callee = 'load_textdomain';
538        }
539        // JIT doesn't work for WordPress core
540        else if( 'default' === $domain ){
541            $this->log('Reloading default text domain');
542            $callee = 'load_default_textdomain';
543            $returned = load_default_textdomain($this->locale);
544        }
545        // Defaulting to JIT (auto):
546        // When we called unload_textdomain we passed $reloadable=false on purpose to force memory removal
547        // So if we want to allow _load_textdomain_just_in_time, we'll have to hack the reloadable lock.
548        else {
549            $this->unlockDomain($domain);
550        }
551        $this->log('> %s returned %s', $callee, var_export($returned,true) );
552    }
553
554
555
556    /**
557     * Preload domain for a script, then forcing retrieval of JSON.
558     */
559    private function preloadScript( $path, string $domain, ?Loco_package_Bundle $bundle = null ):Loco_gettext_Data {
560        $this->log('Have script argument => %s', $path );
561        if( preg_match('/^[0-9a-f]{32}$/',$path) ){
562            throw new Loco_error_Exception('Enter the script path, not the hash');
563        }
564        // normalize file reference if bundle is known. Warning already raised if not.
565        // simulator will allow non-existent js. We can still find translations even if it's absent.
566        $jsfile = new Loco_fs_File($path);
567        if( $bundle ){
568            $basepath = $bundle->getDirectoryPath();
569            if( $jsfile->isAbsolute() ) {
570                $path = $jsfile->getRelativePath($basepath);
571                $this->get('form')['jspath'] = $path;
572            }
573            else {
574                $jsfile->normalize($basepath);
575            }
576            if( ! $jsfile->exists() ){
577                $this->log( '! Script not found. load_script_textdomain may fail');
578            }
579        }
580        // log hashable path for comparison with what WordPress computes:
581        if( '.min.js' === substr($path,-7) ) {
582            $path = substr($path,0,-7).'.js';
583        }
584        else {
585            $valid = array_flip( Loco_data_Settings::get()->jsx_alias ?: ['js'] );
586            if( ! array_key_exists($jsfile->extension(),$valid) ) {
587                Loco_error_AdminNotices::debug("Script path didn't end with .".implode('|',array_keys($valid) ) );
588            }
589        }
590        $hash = md5($path);
591        $this->log('> md5(%s) => %s', var_export($path,true), $hash );
592        // filters will point our debug script to the actual script we're simulating
593        $handle = $this->enqueueScript('dummy');
594        if( ! wp_set_script_translations($handle,$domain) ){
595            throw new Loco_error_Exception('wp_set_script_translations returned false');
596        }
597        // load_script_textdomain won't fire until footer, so grab JSON directly
598        $this->log('Calling load_script_textdomain( %s )', trim(json_encode([$handle,$domain],JSON_UNESCAPED_SLASHES),'[]') );
599        $json = load_script_textdomain($handle,$domain);
600        $this->dequeueScript('dummy');
601        if( is_string($json) && '' !== $json ){
602            $this->log('> Parsing %u bytes of JSON...', strlen($json) );
603            return Loco_gettext_Data::fromJson($json);
604        }
605        throw new Loco_error_Exception('load_script_textdomain returned '.var_export($json,true) );
606    }       
607
608
609
610    /**
611     * Run the string lookup and render result screen, unless an error is thrown.
612     * @return string
613     */
614    private function renderResult( Loco_mvc_ViewParams $form ){
615        $msgid = $form['msgid'];
616        $msgctxt = $form['msgctxt'];
617        // singular form by default
618        $msgid_plural = $form['msgid_plural'];
619        $quantity = ctype_digit($form['n']) ? (int) $form['n'] : 1;
620        $pluralIndex = 0;
621        //
622        $domain = $form['domain']?:'default';
623        $this->log('Running test for domain => %s', $domain );
624        //$this->logDomainState($domain);
625        $default = $this->get('default');
626        $tag = $form['locale'] ?: $default['locale'];
627        $locale = Loco_Locale::parse($tag);
628        if( ! $locale->isValid() ){
629            throw new InvalidArgumentException('Invalid locale code ('.$tag.')');
630        }
631        // unhook all existing filters, including our own
632        if( $form['unhook'] ){
633            $this->log('Unhooking l10n filters');
634            array_map( 'remove_all_filters', [
635                // these filters are all used by Loco_hooks_LoadHelper, and will need re-hooking afterwards:
636                'theme_locale','plugin_locale','unload_textdomain','load_textdomain','load_script_translation_file','load_script_translations',
637                // these filters also affect text domain loading / file reading:
638                'pre_load_textdomain','override_load_textdomain','load_textdomain_mofile','translation_file_format','load_translation_file','override_unload_textdomain','lang_dir_for_domain',
639                // script translation hooks:
640                'load_script_textdomain_relative_path','pre_load_script_translations','load_script_translation_file','load_script_translations',
641                // these filters affect translation fetching via __, _n, _x and _nx:
642                'gettext','ngettext','gettext_with_context','ngettext_with_context'
643            ] );
644            // helper isn't a singleton, and will be garbage-collected now. Restart it.
645            new Loco_hooks_LoadHelper;
646        }
647        // Ensuring our forced locale requires no other filters be allowed to run.
648        // We're doing this whether "unhook" is set or not, otherwise determine_locale won't work.
649        remove_all_filters('pre_determine_locale');
650        $this->reHook();
651        $this->locale = (string) $locale;
652        $this->log('Have locale: %s', $this->locale );
653        $actual = determine_locale();
654        if( $actual !== $this->locale ){
655            $this->log('determine_locale() => %s', $actual );
656            Loco_error_AdminNotices::warn( sprintf('Locale %s is overriding %s', $actual, $this->locale) );
657        }
658        // Deferred setting of text domain to avoid hooks firing before we're ready
659        $this->domain = $domain;
660        //new Loco_hooks_LoadDebugger($domain);
661
662        // Perform preloading according to user choice, and optional path argument.
663        $type = $form['loader'];
664        $bundle = $this->getBundleByDomain($domain,$type);
665        $this->preloadDomain( $domain, $type, $form['loadpath'] );
666
667        // Create source message for string query
668        class_exists('Loco_gettext_Data');
669        $message = new LocoPoMessage(['source'=>$msgid,'context'=>$msgctxt,'target'=>'']);
670        $this->log('Query: %s', LocoPo::pair('msgid',$msgid) );
671        if( '' !== $msgid_plural ){
672            $this->log('     | %s (n=%u)', LocoPo::pair('msgid_plural',$msgid_plural), $quantity );
673            $message->offsetSet('plurals', [new LocoPoMessage(['source'=>$msgid_plural,'target'=>''])] );
674        }
675        $findKey = $message->getKey();
676
677        // Perform runtime translation request via WordPress
678        if( '' === $msgctxt ){
679            if( '' === $msgid_plural ) {
680                $callee = '__';
681                $params = [ $msgid, $domain ];
682                $this->addHook('gettext', 'temp_filter_gettext', 3, 99 );
683            }
684            else {
685                $callee = '_n';
686                $params = [ $msgid, $msgid_plural, $quantity, $domain ];
687                $this->addHook('ngettext', 'temp_filter_gettext', 5, 99 );
688            }
689        }
690        else {
691            $this->log('     | %s', LocoPo::pair('msgctxt',$msgctxt) );
692            if( '' === $msgid_plural ){
693                $callee = '_x';
694                $params = [ $msgid, $msgctxt, $domain ];
695                $this->addHook('gettext_with_context', 'temp_filter_gettext', 4, 99 );
696            }
697            else {
698                $callee = '_nx';
699                $params = [ $msgid, $msgid_plural, $quantity, $msgctxt, $domain ];
700                $this->addHook('ngettext_with_context', 'temp_filter_gettext', 6, 99 );
701            }
702        }
703        $this->log('Calling %s( %s )', $callee, trim( json_encode($params,JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE), '[]') );
704        $msgstr = call_user_func_array($callee,$params);
705        $this->log("====>| %s", LocoPo::pair('msgstr',$msgstr,0) );
706
707        // Post check for text domain auto-load failure
708        $loaded = get_translations_for_domain($domain);
709        if( ! is_textdomain_loaded($domain) ){
710            $this->log('! Text domain not loaded after %s() call completed', $callee );
711            $this->log('  get_translations_for_domain => %s', self::debugType($loaded) );
712        }
713
714        // Establish retrospectively if a non-zero plural index was used.
715        if( '' !== $msgid_plural ){
716            $header = null;
717            if( class_exists('WP_Translation_Controller',false) ){
718                $h = WP_Translation_Controller::get_instance()->get_headers($domain);
719                if( array_key_exists('Plural-Forms',$h) ) {
720                    $header = $h['Plural-Forms'];
721                }
722            }
723            if( is_null($header) ){
724                $header = $locale->getPluralFormsHeader();
725                $this->log('! Can\'t get Plural-Forms; Using built-in rules');
726            }
727            $pluralIndex = $this->selectPluralForm( $quantity, $pluralIndex, $this->parsePluralForms($header) );
728        }
729
730        // Simulate JavaScript translation if script path is set. This will be used as a secondary result.
731        $path = $form['jspath'];
732        if( is_string($path) && '' !== $path ) {
733            try {
734                $data = $this->preloadScript( $path, $domain, $bundle );
735                // Let JED-defined plural forms override plural index
736                if( '' !== $msgid_plural ){
737                    $header = $data->getHeaders()->offsetGet('Plural-Forms');
738                    if( $header ){
739                        $pluralIndex = $this->selectPluralForm( $quantity, $pluralIndex, $this->parsePluralForms($header) );
740                    }
741                }
742                $msgstr = $this->findMsgstr( $findKey, $pluralIndex, $data );
743                if( is_null($msgstr) ){
744                    $this->log('! No match in JSON');
745                }
746                else {
747                    $this->log("====>| %s", LocoPo::pair('msgstr',$msgstr,0) );
748                }
749                // Override primary translation result for script translation
750                $callee = 'load_script_textdomain';
751            }
752            catch( Exception $e ){
753                $this->log('! %s (falling back to PHP)', $e->getMessage() );
754                Loco_error_AdminNotices::warn('Script translation failed. Falling back to PHP translation');
755            }
756        }
757
758        // Establish translation success, assuming that source being returned is equivalent to an absent translation
759        $fallback = $pluralIndex ? $msgid_plural : $msgid;
760        $translated = is_string($msgstr) && '' !== $msgstr && $msgstr !== $fallback;
761        $this->log('Translated result state => %s', $translated?'true':'false');
762
763        // We're done with our temporary hooks now.
764        $this->domain = null;
765        $this->locale = null;
766
767        // Obtain all possible translations from all known targets (requires bundle)
768        $pofiles = new Loco_fs_FileList;
769        if( $bundle ){
770            foreach( $bundle as $project ) {
771                if( $project instanceof Loco_package_Project && $project->getDomain()->getName() === $domain ){
772                    $pofiles->augment( $project->initLocaleFiles($locale) );
773                }
774            }
775        }
776        // Without a configured bundle, we'll have to search all possible locations, but this won't include Author files.
777        // We may as well add these anyway, in case bundle is misconfigured. Small risk of plugin/theme domain conflicts.
778        if( 'default' !== $domain ){
779            /* @var Loco_package_Bundle $tmp */
780            foreach( [ new Loco_package_Plugin('',''), new Loco_package_Theme('','') ] as $tmp ) {
781                foreach( $tmp->getSystemTargets() as $root ){
782                    $pofiles->add( new Loco_fs_LocaleFile( sprintf('%s/%s-%s.po',$root,$domain,$locale) ) );
783                }
784            }
785        }
786        $grouped = [];
787        $matches = [];
788        $searched = 0;
789        $matched  = 0;
790        $this->log('Searching %u possible locations for string versions', $pofiles->count() );
791        /* @var Loco_fs_LocaleFile $pofile */
792        foreach( $pofiles as $pofile ){
793            // initialize translation set for this PO and its siblings
794            $dir = new Loco_fs_LocaleDirectory( $pofile->dirname() );
795            $type = $dir->getTypeId();
796            $args = [ 'type' => $dir->getTypeLabel($type) ];
797            // as long as we know the bundle and the PO file exists, we can link to the editor.
798            // bear in mind that domain may not be unique to one set of translations (core) so ...
799            if( $bundle && $pofile->exists() ){
800                $route = strtolower($bundle->getType()).'-file-edit';
801                // find exact project in bundle. Required for core, or any multi-domain bundle
802                $project = $bundle->getDefaultProject();
803                if( is_null($project) || 1 < $bundle->count() ){
804                    $slug = $pofile->getPrefix();
805                    foreach( $bundle as $candidate ){
806                        if( $candidate->getSlug() === $slug ){
807                            $project = $candidate;
808                            break;
809                        }
810                    }
811                }
812                $args['href'] = Loco_mvc_AdminRouter::generate( $route, [
813                    'bundle' => $bundle->getHandle(),
814                    'domain' => $project ? $project->getId() : $domain,
815                    'path' => $pofile->getRelativePath(WP_CONTENT_DIR),
816                ] );
817            }
818            $groupIdx = count($grouped);
819            $grouped[] = new Loco_mvc_FileParams( $args, $pofile );
820            // even if PO file is missing, we can search the MO, JSON etc..
821            $siblings = new Loco_fs_Siblings($pofile);
822            $siblings->setDomain($domain);
823            $exts = [];
824            foreach( $siblings->expand() as $file ){
825                try {
826                    $ext = strtolower( $file->fullExtension() );
827                    if( ! preg_match('!^(?:pot?|mo|json|l10n\\.php)$!',$ext) || ! $file->exists() ){
828                        continue;
829                    }
830                    $searched++;
831                    $message = $this->findMessage($findKey,Loco_gettext_Data::load($file));
832                    if( $message ){
833                        $matched++;
834                        $value = $this->getMsgstr($message,$pluralIndex);
835                        $args = [ 'msgstr' => $value ];
836                        $matches[$groupIdx][] = new Loco_mvc_FileParams($args,$file);
837                        $this->log('> found in %s => %s', $file, var_export($value,true) );
838                        $exts[$ext] = $message->translated();
839                    }
840                }
841                catch( Exception $e ){
842                    Loco_error_Debug::trace( '%s in %s', $e->getMessage(), $file );
843                }
844            }
845            // warn if found in PO, but not MO.
846            if( isset($exts['po']) && $exts['po'] && ! isset($exts['mo']) ){
847                Loco_error_AdminNotices::debug('Found in PO, but not MO. Is it fuzzy? Does it need recompiling?');
848            }
849        }
850
851        // display result if translation occurred, or if we found the string in at least one file, even if empty
852        $this->log('> %u matches in %u locations; %u files searched', $matched, count($grouped), $searched );
853        if( $matches || $translated ){
854            $result = new Loco_mvc_ViewParams( $form->getArrayCopy() );
855            $result['translated'] = $translated;
856            $result['msgstr'] = $msgstr;
857            $result['callee'] = $callee;
858            $result['grouped'] = $grouped;
859            $result['matches'] = $matches;
860            $result['searched'] = $searched;
861            $result['calleeDoc'] = 'https://developer.wordpress.org/reference/functions/'.$callee.'/';
862            return $this->view( 'admin/debug/debug-result', ['result'=>$result]);
863        }
864        // Source string not found in any translation files
865        $name = $bundle ? $bundle->getName() : $domain;
866        throw new Loco_error_Warning('No `'.$locale.'` translations found for this string in '.$name );
867    }
868
869
870
871    /**
872     * @return void
873     */
874    private function surpriseMe(){
875        $project = null;
876        /* @var Loco_package_Bundle[] $bundles */
877        $bundles = array_merge( Loco_package_Plugin::getAll(), Loco_package_Theme::getAll(), [ Loco_package_Core::create() ] );
878        while( $bundles && is_null($project) ){
879            $key = array_rand($bundles);
880            $project = $bundles[$key]->getDefaultProject();
881            unset($bundles[$key]);
882        }
883        // It should be impossible for project to be null, due to WordPress core always being non-empty
884        if( ! $project instanceof Loco_package_Project ){
885            throw new LogicException('No translation projects');
886        }
887        $domain = $project->getDomain()->getName();
888        // Pluck a random locale from existing PO translations
889        $files = $project->findLocaleFiles('po')->getArrayCopy();
890        $pofile = $files ? $files[ array_rand($files) ] : null;
891        $locale = $pofile instanceof Loco_fs_LocaleFile ? (string) $pofile->getLocale() : '';
892        // Get a random source string from the code... avoiding full extraction.. pluck a PHP file...
893        class_exists('Loco_gettext_Data');
894        $message = new LocoPoMessage(['source'=>'']);
895        $extractor = loco_wp_extractor();
896        $extractor->setDomain($domain);
897        $files = $project->getSourceFinder()->group('php')->export()->getArrayCopy();
898        while( $files ){
899            $key = array_rand($files);
900            $file = $files[$key];
901            $strings = ( new LocoExtracted )->extractSource( $extractor, $file->getContents() )->export();
902            if( $strings ){
903                $message = new LocoPoMessage( $strings[ array_rand($strings) ] );
904                break;
905            }
906            // try next source file...
907            unset($files[$key]);
908        }
909        // apply random choice
910        $form = $this->get('form');
911        $form['domain'] = $domain;
912        $form['locale'] = $locale;
913        $form['msgid'] = $message->source;
914        $form['msgctxt'] = $message->context;
915        // random message could be a plural form
916        $plurals = $message->plurals;
917        if( is_array($plurals) && array_key_exists(0,$plurals) && $plurals[0] instanceof LocoPoMessage ){
918            $form['msgid_plural'] = $plurals[0]['source'];
919        }
920        Loco_error_AdminNotices::info( sprintf('Randomly selected "%s". Click Submit to check a string.', $project->getName() ) );
921    }
922
923
924
925    /**
926     * {@inheritdoc}
927     */
928    public function render(){
929        $title = __('String debugger','loco-translate');
930        $this->set('breadcrumb', Loco_admin_Navigation::createSimple($title) );
931
932        try {
933            // Process form if (at least) "msgid" is set
934            $form = $this->get('form');
935            if( '' !== $form['msgid'] ){
936                return $this->renderResult($form);
937            }
938            // Pluck a random string for testing
939            else if( array_key_exists('randomize',$_GET) ){
940                $this->surpriseMe();
941            }
942        }
943        catch( Loco_error_Exception $e ){
944            Loco_error_AdminNotices::add($e);
945        }
946        catch( Exception $e ){
947            Loco_error_AdminNotices::add( Loco_error_Exception::convert($e) );
948        }
949
950        return $this->view('admin/debug/debug-form');
951    }
952   
953}
Note: See TracBrowser for help on using the repository browser.