first commit

This commit is contained in:
User A0264400
2026-04-01 23:20:16 +03:00
commit a766acdc90
23071 changed files with 4933189 additions and 0 deletions

View File

@@ -0,0 +1,953 @@
<?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');
}
}

View File

@@ -0,0 +1,42 @@
<?php
/**
*
*/
class Loco_admin_ErrorController extends Loco_mvc_AdminController {
public function renderError( Exception $e ){
$this->set('error', Loco_error_Exception::convert($e) );
return $this->render();
}
public function render(){
$e = $this->get('error');
if( $e ){
/* @var Loco_error_Exception $e */
$file = Loco_mvc_FileParams::create( new Loco_fs_File( $e->getRealFile() ) );
$file['line'] = $e->getRealLine();
$this->set('file', $file );
if( loco_debugging() ){
$trace = [];
foreach( $e->getRealTrace() as $raw ) {
$frame = new Loco_mvc_ViewParams($raw);
if( $frame->has('file') ){
$frame['file'] = Loco_mvc_FileParams::create( new Loco_fs_File($frame['file']) )->relpath;
}
$trace[] = $frame;
}
$this->set('trace',$trace);
}
}
else {
$e = new Loco_error_Exception('Unknown error');
$this->set('error', $e );
}
return $this->view( $e->getTemplate() );
}
}

View File

@@ -0,0 +1,56 @@
<?php
/**
* Generic navigation helper.
*/
class Loco_admin_Navigation extends ArrayIterator {
public function add( string $name, ?string $href = null, ?bool $active = false ):self {
$this[] = new Loco_mvc_ViewParams( compact('name','href','active') );
return $this;
}
/**
* Create a breadcrumb trail for a given view below a bundle
*/
public static function createBreadcrumb( Loco_package_Bundle $bundle ):self {
$nav = new Loco_admin_Navigation;
// root link depends on bundle type
$type = strtolower( $bundle->getType() );
if( 'core' !== $type ){
$link = new Loco_mvc_ViewParams( [
'href' => Loco_mvc_AdminRouter::generate($type),
] );
if( 'theme' === $type ){
$link['name'] = __('Themes','loco-translate');
}
else {
$link['name'] = __('Plugins','loco-translate');
}
$nav[] = $link;
}
// Add actual bundle page, href may be unset to show as current page if needed
$nav->add (
$bundle->getName(),
Loco_mvc_AdminRouter::generate( $type.'-view', [ 'bundle' => $bundle->getHandle() ] )
);
// client code will add current page
return $nav;
}
/**
* Create a basic breadcrumb comprising title only
*/
public static function createSimple( string $name ):self {
$nav = new Loco_admin_Navigation;
$nav->add($name);
return $nav;
}
}

View File

@@ -0,0 +1,35 @@
<?php
/**
*
*/
abstract class Loco_admin_RedirectController extends Loco_mvc_AdminController {
/**
* Get full URL for redirecting to.
* @var string
*/
abstract public function getLocation();
/**
* {@inheritdoc}
*/
public function init(){
$location = $this->getLocation();
if( $location && wp_redirect($location) ){
// @codeCoverageIgnoreStart
exit;
}
}
/**
* @internal
*/
public function render(){
return 'Failed to redirect';
}
}

View File

@@ -0,0 +1,95 @@
<?php
/**
* Highest level Loco admin screen.
*/
class Loco_admin_RootController extends Loco_admin_list_BaseController {
/**
* {@inheritdoc}
*/
public function getHelpTabs(){
return [
__('Overview','loco-translate') => $this->viewSnippet('tab-home'),
];
}
/**
* Render main entry home screen
*/
public function render(){
// translators: home screen title where %s is the version number
$this->set('title', sprintf( __('Loco Translate %s','loco-translate'), loco_plugin_version() ) );
// Show currently active theme on home page
$theme = Loco_package_Theme::create( get_stylesheet() );
$this->set('theme', $this->bundleParam($theme) );
// Show plugins that have currently loaded translations
$bundles = [];
foreach( Loco_package_Listener::singleton()->getPlugins() as $bundle ){
try {
$bundles[] = $this->bundleParam($bundle);
}
catch( Exception $e ){
// bundle should exist if we heard it. reduce to debug notice
Loco_error_AdminNotices::debug( $e->getMessage() );
}
}
$this->set('plugins', $bundles );
// Show recently "used' bundles
$bundles = [];
$recent = Loco_data_RecentItems::get();
// filter in lieu of plugin setting
$maxlen = apply_filters('loco_num_recent_bundles', 10 );
foreach( $recent->getBundles(0,$maxlen) as $id ){
try {
$bundle = Loco_package_Bundle::fromId($id);
$bundles[] = $this->bundleParam($bundle);
}
catch( Exception $e ){
// possible that bundle ID changed since being saved in recent items list
}
}
$this->set('recent', $bundles );
// current locale and related links
$locale = Loco_Locale::parse( get_locale() );
$api = new Loco_api_WordPressTranslations;
$tag = (string) $locale;
$this->set( 'siteLocale', new Loco_mvc_ViewParams( [
'code' => $tag,
'name' => ( $name = $locale->ensureName($api) ),
'attr' => 'class="'.$locale->getIcon().'" lang="'.$locale->lang.'"',
'link' => '<a href="'.esc_url(Loco_mvc_AdminRouter::generate('lang-view', ['locale'=>$tag] )).'">'.esc_html($name).'</a>',
//'opts' => admin_url('options-general.php').'#WPLANG',
] ) );
// user's "admin" language may differ and is worth showing
if( function_exists('get_user_locale') ){
$locale = Loco_Locale::parse( get_user_locale() );
$alt = (string) $locale;
if( $tag !== $alt ){
$this->set( 'adminLocale', new Loco_mvc_ViewParams( [
'name' => ( $name = $locale->ensureName($api) ),
'link' => '<a href="'.esc_url(Loco_mvc_AdminRouter::generate('lang-view', ['locale'=>$tag] )).'">'.esc_html($name).'</a>',
] ) );
}
}
$this->set('title', __('Welcome to Loco Translate','loco-translate') );
// Deprecation warnings:
// At time of writing, WordPress below 5.2 accounts for about a quarter, whereas PHP below is 5.6 half of that.
/* if( version_compare(PHP_VERSION,'5.6.20','<') || version_compare($GLOBALS['wp_version'],'5.2','<') ){
Loco_error_AdminNotices::warn('The next release of Loco Translate will require at least WordPress 5.2 and PHP 5.6.20'); // @codeCoverageIgnore
}*/
return $this->view('admin/root');
}
}

View File

@@ -0,0 +1,162 @@
<?php
/**
* Base controller for any admin screen related to a bundle
*/
abstract class Loco_admin_bundle_BaseController extends Loco_mvc_AdminController {
private ?Loco_package_Bundle $bundle = null;
private ?Loco_package_Project $project = null;
public function getBundle():Loco_package_Bundle {
if( ! $this->bundle ){
$type = $this->get('type');
$handle = $this->get('bundle')??'';
$this->bundle = Loco_package_Bundle::createType( $type, $handle );
}
return $this->bundle;
}
/**
* Get current project's text domain if available
*/
public function getDomain():string {
$project = $this->getOptionalProject();
if( $project instanceof Loco_package_Project ){
return $project->getDomain()->getName();
}
return '';
}
/**
* Commit bundle config to database
*/
protected function saveBundle():self {
$custom = new Loco_config_CustomSaved;
if( $custom->setBundle($this->bundle)->persist() ){
Loco_error_AdminNotices::success( __('Configuration saved','loco-translate') );
}
// invalidate bundle in memory so next fetch is re-configured from DB
$this->bundle = null;
return $this;
}
/**
* Remove bundle config from database
*/
protected function resetBundle():self {
$option = $this->bundle->getCustomConfig();
if( $option && $option->remove() ){
Loco_error_AdminNotices::success( __('Configuration reset','loco-translate') );
// invalidate bundle in memory so next fetch falls back to auto-config
$this->bundle = null;
}
return $this;
}
protected function getProject():Loco_package_Project{
if( ! $this->project ){
$bundle = $this->getBundle();
$domain = $this->get('domain');
if( ! $domain ){
throw new Loco_error_Exception( sprintf('Translation set not known in %s', $bundle ) );
}
$this->project = $bundle->getProjectById($domain);
if( ! $this->project ){
throw new Loco_error_Exception( sprintf('Unknown translation set: %s not in %s', json_encode($domain), $bundle ) );
}
}
return $this->project;
}
protected function getOptionalProject():?Loco_package_Project {
try {
return $this->getProject();
}
catch( Throwable $e ){
return null;
}
}
protected function prepareNavigation():Loco_admin_Navigation {
$bundle = $this->getBundle();
// navigate up to bundle listing page
$breadcrumb = Loco_admin_Navigation::createBreadcrumb( $bundle );
$this->set( 'breadcrumb', $breadcrumb );
// navigate between bundle view siblings
$tabs = new Loco_admin_Navigation;
$this->set( 'tabs', $tabs );
$actions = [
'view' => __('Overview','loco-translate'),
'setup' => __('Setup','loco-translate'),
'conf' => __('Advanced','loco-translate'),
];
$suffix = $this->get('action');
$prefix = strtolower( $this->get('type') );
$getarg = array_intersect_key( $_GET, ['bundle'=>''] );
foreach( $actions as $action => $name ){
$href = Loco_mvc_AdminRouter::generate( $prefix.'-'.$action, $getarg );
$tabs->add( $name, $href, $action === $suffix );
}
return $breadcrumb;
}
/**
* Prepare file system connect
* @param string $type "create", "update", "delete"
* @param string $relpath Path relative to wp-content
*/
protected function prepareFsConnect( string $type, string $relpath ):Loco_mvc_HiddenFields {
$fields = new Loco_mvc_HiddenFields( [
'auth' => $type,
'path' => $relpath,
'loco-nonce' => wp_create_nonce('fsConnect'),
'_fs_nonce' => wp_create_nonce('filesystem-credentials'), // <- WP 4.7.5 added security fix
] ) ;
$this->set('fsFields', $fields );
// may have fs credentials saved in session
try {
if( Loco_data_Settings::get()->fs_persist ){
$session = Loco_data_Session::get();
if( isset($session['loco-fs']) ){
$fields['connection_type'] = $session['loco-fs']['connection_type'];
}
}
}
catch( Exception $e ){
Loco_error_AdminNotices::debug( $e->getMessage() );
}
// Run pre-checks that may determine file should not be written
if( $relpath ){
$file = new Loco_fs_File( $relpath );
$file->normalize( loco_constant('WP_CONTENT_DIR') );
// total file system block makes connection type irrelevant
try {
$api = new Loco_api_WordPressFileSystem;
$api->preAuthorize($file);
}
catch( Loco_error_WriteException $e ){
$this->set('fsLocked', $e->getMessage() );
}
}
return $fields;
}
}

View File

@@ -0,0 +1,142 @@
<?php
/**
* Bundle configuration page
*/
class Loco_admin_bundle_ConfController extends Loco_admin_bundle_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$this->enqueueStyle('config');
$this->enqueueScript('config');
$bundle = $this->getBundle();
// translators: where %s is a plugin or theme
$this->set( 'title', sprintf( __('Configure %s','loco-translate'),$bundle->getName() ) );
$post = Loco_mvc_PostParams::get();
// always set a nonce for current bundle
$nonce = $this->setNonce( $this->get('_route').'-'.$this->get('bundle') );
$this->set('nonce', $nonce );
try {
// Save configuration if posted, and security check passes
if( $post->has('conf') && $this->checkNonce($nonce->action) ){
if( ! $post->name ){
$post->name = $bundle->getName();
}
$model = new Loco_config_FormModel;
$model->loadForm( $post );
// configure bundle from model in full
$bundle->clear();
$reader = new Loco_config_BundleReader( $bundle );
$reader->loadModel( $model );
$this->saveBundle();
}
// Delete configuration if posted
else if( $post->has('unconf') && $this->checkNonce($nonce->action) ){
$this->resetBundle();
}
}
catch( Exception $e ){
Loco_error_AdminNotices::warn( $e->getMessage() );
}
}
/**
* {@inheritdoc}
*/
public function getHelpTabs(){
return [
__('Advanced tab','loco-translate') => $this->viewSnippet('tab-bundle-conf'),
];
}
/**
* {@inheritdoc}
*/
public function render() {
$parent = null;
$bundle = $this->getBundle();
$default = $bundle->getDefaultProject();
$base = $bundle->getDirectoryPath();
// parent themes are inherited into bundle, we don't want them in the child theme config
if( $bundle->isTheme() && ( $parent = $bundle->getParent() ) ){
$this->set( 'parent', new Loco_mvc_ViewParams( [
'name' => $parent->getName(),
'href' => Loco_mvc_AdminRouter::generate('theme-conf', [ 'bundle' => $parent->getSlug() ] + $_GET ),
] ) );
}
// render postdata straight back to form if sent
$data = Loco_mvc_PostParams::get();
// else build initial data from current bundle state
if( ! $data->has('conf') ){
// create single default set for totally unconfigured bundles
if( 0 === count($bundle) ){
$bundle->createDefault('');
}
$writer = new Loco_config_BundleWriter($bundle);
$data = $writer->toForm();
// removed parent bundle from config form, as they are inherited
/* @var Loco_package_Project $project */
foreach( $bundle as $i => $project ){
if( $parent && $parent->hasProject($project) ){
// warn if child theme uses parent theme's text domain (but allowing to render so we don't get an empty form.
if( $project === $default ){
Loco_error_AdminNotices::warn( __("Child theme declares the same Text Domain as the parent theme",'loco-translate') );
}
// else safe to remove parent theme configuration as it should be held in its own bundle
else {
$data['conf'][$i]['removed'] = true;
}
}
}
}
// build config blocks for form
$i = 0;
$conf = [];
foreach( $data['conf'] as $raw ){
if( empty($raw['removed']) ){
$slug = $raw['slug'];
$domain = $raw['domain'] or $domain = 'untitled';
$raw['prefix'] = sprintf('conf[%u]', $i++ );
$raw['short'] = ! $slug || ( $slug === $domain ) ? $domain : $domain.'→'.$slug;
$conf[] = new Loco_mvc_ViewParams( $raw );
}
}
// bundle level configs
$name = $bundle->getName();
$excl = $data['exclude'];
// access to type of configuration that's currently saved
$this->set('saved', $bundle->isConfigured() );
// link to author if there are config problems
$info = $bundle->getHeaderInfo();
$this->set('author', $info->getAuthorLink() );
// link for downloading current configuration XML file
$args = [
'path' => 'loco.xml',
'action' => 'loco_download',
'bundle' => $bundle->getHandle(),
'type' => $bundle->getType()
];
$this->set( 'xmlUrl', Loco_mvc_AjaxRouter::generate( 'DownloadConf', $args ) );
$this->set( 'manUrl', apply_filters('loco_external','https://localise.biz/wordpress/plugin/manual/bundle-config') );
$this->prepareNavigation()->add( __('Advanced configuration','loco-translate') );
return $this->view('admin/bundle/conf', compact('conf','base','name','excl') );
}
}

View File

@@ -0,0 +1,177 @@
<?php
/**
* Pseudo-bundle view, lists all files available in a single locale
*/
class Loco_admin_bundle_LocaleController extends Loco_mvc_AdminController {
/**
* @var Loco_Locale
*/
private $locale;
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$tag = $this->get('locale');
$locale = Loco_Locale::parse($tag);
if( $locale->isValid() ){
$api = new Loco_api_WordPressTranslations;
$this->set('title', $locale->ensureName($api) );
$this->locale = $locale;
$this->enqueueStyle('locale')->enqueueStyle('fileinfo');
}
}
/**
* {@inheritdoc}
*/
public function getHelpTabs(){
return [
__('Overview','loco-translate') => $this->viewSnippet('tab-locale-view'),
];
}
/**
* {@inheritdoc}
*/
public function render(){
// locale already parsed during init (for page title)
$locale = $this->locale;
if( ! $locale || ! $locale->isValid() ){
throw new Loco_error_Exception('Invalid locale argument');
}
// language may not be "installed" but we still want to inspect available files
$api = new Loco_api_WordPressTranslations;
$installed = $api->isInstalled($locale);
$tag = (string) $locale;
$package = new Loco_package_Locale( $locale );
// search for base language, unless it's a separate, installed language
if( $locale->lang !== (string) $locale ){
$fallback = new Loco_Locale($locale->lang);
if( ! $api->isInstalled($fallback) ){
$package->addLocale($fallback);
}
}
// Get PO files for this locale
$files = $package->findLocaleFiles();
$translations = [];
$modified = 0;
$npofiles = 0;
$nfiles = 0;
// source locale means we want to see POT instead of translations
if( 'en_US' === $tag ){
$files = $package->findTemplateFiles()->augment($files);
}
/* @var Loco_fs_File $file */
foreach( $files as $file ){
$nfiles++;
if( 'pot' !== $file->extension() ){
$npofiles++;
}
$modified = max( $modified, $file->modified() );
$project = $package->getProject($file);
// do similarly to Loco_admin_bundle_ViewController::createFileParams
$meta = Loco_gettext_Metadata::load($file);
$dir = new Loco_fs_LocaleDirectory( $file->dirname() );
// arguments for deep link into project
$slug = $project->getSlug();
$domain = $project->getDomain()->getName();
$bundle = $project->getBundle();
$type = strtolower( $bundle->getType() );
$args = [
// 'locale' => $tag,
'bundle' => $bundle->getHandle(),
'domain' => $project->getId(),
'path' => $meta->getPath(false),
];
// append data required for PO table row, except use bundle data instead of locale data
$translations[$type][] = new Loco_mvc_ViewParams( [
// bundle info
'title' => $project->getName(),
'domain' => $domain,
'short' => ! $slug || $project->isDomainDefault() ? $domain : $domain.'→'.$slug,
// file info
'meta' => $meta,
'name' => $file->basename(),
'time' => $file->modified(),
'type' => strtoupper( $file->extension() ),
'todo' => $meta->countIncomplete(),
'total' => $meta->getTotal(),
// author / system / custom / other
'store' => $dir->getTypeLabel( $dir->getTypeId() ),
// links
'view' => Loco_mvc_AdminRouter::generate( $type.'-file-view', $args ),
'info' => Loco_mvc_AdminRouter::generate( $type.'-file-info', $args ),
'edit' => Loco_mvc_AdminRouter::generate( $type.'-file-edit', $args ),
'move' => Loco_mvc_AdminRouter::generate( $type.'-file-move', $args ),
'delete' => Loco_mvc_AdminRouter::generate( $type.'-file-delete', $args ),
'copy' => Loco_mvc_AdminRouter::generate( $type.'-msginit', $args ),
] );
}
$title = __( 'Installed languages', 'loco-translate' );
$breadcrumb = new Loco_admin_Navigation;
$breadcrumb->add( $title, Loco_mvc_AdminRouter::generate('lang') );
//$breadcrumb->add( $locale->getName() );
$breadcrumb->add( $tag );
// It's unlikely that an "installed" language would have no files, but could happen if only MO on disk
if( 0 === $nfiles ){
return $this->view('admin/errors/no-locale', compact('breadcrumb','locale') );
}
// files may be available for language even if not installed (i.e. no core files on disk)
if( ! $installed || ! isset($translations['core']) && 'en_US' !== $tag ){
Loco_error_AdminNotices::warn( __('No core translation files are installed for this language','loco-translate') )
->addLink('https://codex.wordpress.org/Installing_WordPress_in_Your_Language', __('Documentation','loco-translate') );
}
// Translated type labels and "See all <type>" links
$types = [
'core' => new Loco_mvc_ViewParams( [
'name' => __('WordPress Core','loco-translate'),
'text' => __('See all core translations','loco-translate'),
'href' => Loco_mvc_AdminRouter::generate('core')
] ),
'theme' => new Loco_mvc_ViewParams( [
'name' => __('Themes','loco-translate'),
'text' => __('See all themes','loco-translate'),
'href' => Loco_mvc_AdminRouter::generate('theme')
] ),
'plugin' => new Loco_mvc_ViewParams( [
'name' => __('Plugins','loco-translate'),
'text' => __('See all plugins','loco-translate'),
'href' => Loco_mvc_AdminRouter::generate('plugin')
] ),
];
$this->set( 'locale', new Loco_mvc_ViewParams( [
'code' => $tag,
'name' => $locale->getName(),
'attr' => 'class="'.$locale->getIcon().'" lang="'.$locale->lang.'"',
] ) );
// Sort each translation set alphabetically by bundle name...
foreach( array_keys($translations) as $type ){
usort( $translations[$type], function( ArrayAccess $a, ArrayAccess $b ):int {
return strcasecmp($a['title'],$b['title']);
} );
}
return $this->view( 'admin/bundle/locale', compact('breadcrumb','translations','types','npofiles','modified') );
}
}

View File

@@ -0,0 +1,262 @@
<?php
/**
*
*/
class Loco_admin_bundle_SetupController extends Loco_admin_bundle_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$bundle = $this->getBundle();
// translators: where %s is a plugin or theme
$this->set( 'title', sprintf( __('Set up %s','loco-translate'),$bundle->getName() ) );
}
/**
* {@inheritdoc}
*/
public function getHelpTabs(){
return [
__('Setup tab','loco-translate') => $this->viewSnippet('tab-bundle-setup'),
];
}
/**
* {@inheritdoc}
*/
public function render(){
$this->prepareNavigation()->add( __('Bundle setup','loco-translate') );
$bundle = $this->getBundle();
$action = 'setup:'.$bundle->getId();
// execute auto-configure if posted
$post = Loco_mvc_PostParams::get();
if( $post->has('auto-setup') && $this->checkNonce( 'auto-'.$action) ){
if( 0 === count($bundle) ){
$bundle->createDefault();
}
foreach( $bundle as $project ){
if( ! $project->getPot() && ( $file = $project->guessPot() ) ){
$project->setPot( $file );
}
}
// forcefully add every additional project into bundle
foreach( $bundle->invert() as $project ){
if( ! $project->getPot() && ( $file = $project->guessPot() ) ){
$project->setPot( $file );
}
$bundle[] = $project;
}
$this->saveBundle();
$bundle = $this->getBundle();
$this->set('auto', null );
}
// execute XML-based config if posted
else if( $post->has('xml-setup') && $this->checkNonce( 'xml-'.$action) ){
$bundle->clear();
$model = new Loco_config_XMLModel;
$model->loadXml( trim( $post['xml-content'] ) );
$reader = new Loco_config_BundleReader($bundle);
$reader->loadModel( $model );
$this->saveBundle();
$bundle = $this->getBundle();
$this->set('xml', null );
}
// execute JSON-based config if posted
else if( $post->has('json-setup') && $this->checkNonce( 'json-'.$action) ){
$bundle->clear();
$model = new Loco_config_ArrayModel;
$model->loadJson( trim( $post['json-content'] ) );
$reader = new Loco_config_BundleReader($bundle);
$reader->loadModel( $model );
$this->saveBundle();
$bundle = $this->getBundle();
$this->set('json', null );
}
// execute reset if posted
else if( $post->has('reset-setup') && $this->checkNonce( 'reset-'.$action) ){
$this->resetBundle();
$bundle = $this->getBundle();
}
// bundle author links
$info = $bundle->getHeaderInfo();
$this->set( 'credit', $info->getAuthorCredit() );
// render according to current configuration method (save type)
$configured = $this->get('force') or $configured = $bundle->isConfigured();
$notices = new ArrayIterator;
$this->set('notices', $notices );
// collect configuration warnings
foreach( $bundle as $project ){
$potfile = $project->getPot();
if( ! $potfile ){
$notices[] = sprintf('No translation template for the "%s" text domain', $project->getSlug() );
}
}
// if extra files found consider incomplete
if( $bundle->isTheme() || ( $bundle->isPlugin() && ! $bundle->isSingleFile() ) ){
$unknown = Loco_package_Inverter::export($bundle);
$n = 0;
foreach( $unknown as $ext => $files ){
$n += count($files);
}
if( $n ){
// translators: %s is a quantity of files which were found, but whose context is unknown
$notices[] = sprintf( _n("%s file can't be matched to a known set of strings","%s files can't be matched to a known set of strings",$n,'loco-translate'), number_format_i18n($n) );
}
}
// display setup options if at least one option specified
$doconf = false;
// enable form to invoke auto-configuration
if( $this->get('auto') ){
$fields = new Loco_mvc_HiddenFields();
$fields->setNonce( 'auto-'.$action );
$this->set('autoFields', $fields );
$doconf = true;
}
// enable form to paste XML config
if( $this->get('xml') ){
$fields = new Loco_mvc_HiddenFields();
$fields->setNonce( 'xml-'.$action );
$this->set('xmlFields', $fields );
$doconf = true;
}
/*/ JSON config via remote lookup has been scrapped
if( $this->get('json') ){
$fields = new Loco_mvc_HiddenFields( [
'json-content' => '',
'version' => $info->Version,
] );
$fields->setNonce( 'json-'.$action );
$this->set('jsonFields', $fields );
// other information for looking up bundle via api
$this->set('vendorSlug', $bundle->getSlug() );
// remote config is done via JavaScript
$this->enqueueScript('setup');
$apiBase = apply_filters( 'loco_api_url', 'https://localise.biz/api' );
$this->set('js', new Loco_mvc_ViewParams( [
'apiUrl' => $apiBase.'/wp/'.strtolower( $bundle->getType() ),
] ) );
$doconf = true;
}*/
// display configurator if configuring
if( $doconf ){
return $this->view( 'admin/bundle/setup/conf' );
}
// Add some debugging information on all screens except config
// this used to be accessed via the Debug tab, which is removed
if( loco_debugging() && count($bundle) ){
$this->set('debug', $this->getDebug($bundle) );
}
// set configurator links back to self with required option ...
if( ! $configured || ! count($bundle) ){
return $this->view( 'admin/bundle/setup/none' );
}
if( 'db' === $configured ){
// form for resetting config
$fields = new Loco_mvc_HiddenFields();
$fields->setNonce( 'reset-'.$action );
$this->set( 'reset', $fields );
return $this->view('admin/bundle/setup/saved');
}
if( 'internal' === $configured ){
return $this->view('admin/bundle/setup/core');
}
if( 'file' === $configured ){
return $this->view('admin/bundle/setup/author');
}
if( count($notices) ){
return $this->view('admin/bundle/setup/partial');
}
return $this->view('admin/bundle/setup/meta');
}
/**
* @return Loco_mvc_ViewParams
*/
private function getDebug( Loco_package_Bundle $bundle ){
$debug = new Loco_mvc_ViewParams;
// XML config
$writer = new Loco_config_BundleWriter($bundle);
$debug['xml'] = $writer->toXml();
// general notes, followed by related warnings
$notes = [];
$warns = [];
// show auto-detected settings, either assumed (by wp) or declared (by author)
if( 'meta' === $bundle->isConfigured() ){
// Text Domain:
$native = $bundle->getHeaderInfo();
$domain = $native->TextDomain;
if( $domain ){
// Translators: %s will be replaced with a text domain, e.g. "loco-translate"
$notes[] = sprintf( __('WordPress says the primary text domain is "%s"','loco-translate'), $domain );
// WordPress 4.6 changes mean this header could be a fallback and not actually declared by the author
if( $bundle->isPlugin() ) {
$map = [ 'TextDomain' => 'Text Domain' ];
$raw = get_file_data( $bundle->getBootstrapPath(), $map, 'plugin' );
if( ! isset($raw['TextDomain']) || '' === $raw['TextDomain'] ) {
// Translators: This warning is shown when a text domain has defaulted to same as the folder name (or slug)
$warns[] = __("This plugin doesn't declare a text domain. It's assumed to be the same as the slug, but this could be wrong",'loco-translate');
}
}
// Warn if WordPress-assumed text domain is not configured. plugin/theme headers won't be translated
$domains = $bundle->getDomains();
if ( ! isset($domains[$domain ]) && ! isset($domains['*']) ) {
$warns[] = __("This text domain is not in Loco Translate's bundle configuration",'loco-translate');
}
}
else {
$warns[] = __("This bundle does't declare a text domain; try configuring it in the Advanced tab",'loco-translate');
}
// Domain Path:
$path = $native->DomainPath;
if( $path ){
// Translators: %s will be replaced with a relative path like "/languages"
$notes[] = sprintf( __('The domain path is declared by the author as "%s"','loco-translate'), $path );
}
else {
$guess = new Loco_fs_Directory( $bundle->getDirectoryPath().'/languages' );
if( $guess->readable() ){
$notes[] = __('The default "languages" domain path has been detected','loco-translate');
}
else {
$warns[] = __("This bundle doesn't declare a domain path. Add one via the Advanced tab if needed",'loco-translate');
}
}
}
$debug['notices'] = [ 'info' => $notes, 'warning' => $warns ];
return $debug;
}
}

View File

@@ -0,0 +1,351 @@
<?php
/**
* Bundle overview.
* First tier bundle view showing resources across all projects
*/
class Loco_admin_bundle_ViewController extends Loco_admin_bundle_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$bundle = $this->getBundle();
$this->set('title', $bundle->getName() );
$this->enqueueStyle('bundle');
}
/**
* {@inheritdoc}
*/
public function getHelpTabs(){
return [
__('Overview','loco-translate') => $this->viewSnippet('tab-bundle-view'),
];
}
/**
* Generate a link for a specific file resource within a project
* @return string
*/
private function getResourceLink( $page, Loco_package_Project $project, Loco_gettext_Metadata $meta ){
return $this->getProjectLink( $page, $project, [
'path' => $meta->getPath(false),
] );
}
/**
* Generate a link for a project, but without being for a specific file
* @return string
*/
private function getProjectLink( $page, Loco_package_Project $project, array $args = [] ){
$args['bundle'] = $this->get('bundle');
$args['domain'] = $project->getId();
$route = strtolower( $this->get('type') ).'-'.$page;
return Loco_mvc_AdminRouter::generate( $route, $args );
}
/**
* Initialize view parameters for a project
* @return Loco_mvc_ViewParams
*/
private function createProjectParams( Loco_package_Project $project ){
$name = $project->getName();
$domain = $project->getDomain()->getName();
$slug = $project->getSlug();
$p = new Loco_mvc_ViewParams( [
'id' => $project->getId(),
'name' => $name,
'slug' => $slug,
'domain' => $domain,
'short' => ! $slug || $project->isDomainDefault() ? $domain : $domain.'→'.$slug,
'warnings' => [],
] );
// POT template file
$pot = null;
$file = $project->getPot();
if( $file && $file->exists() ){
$pot = Loco_gettext_Metadata::load($file);
$p['pot'] = new Loco_mvc_ViewParams( [
// POT info
'name' => $file->basename(),
'time' => $file->modified(),
// POT links
'info' => $this->getResourceLink('file-info', $project, $pot ),
'edit' => $this->getResourceLink('file-edit', $project, $pot ),
] );
}
// PO/MO files
$po = $project->findLocaleFiles('po');
$mo = $project->findLocaleFiles('mo');
$p['po'] = $this->createProjectPairs( $project, $po, $mo );
// also pull invalid files so everything is available to the UI
$mo = $project->findNotLocaleFiles('mo');
$po = $project->findNotLocaleFiles('po')->augment( $project->findNotLocaleFiles('pot') );
$p['_po'] = $this->createProjectPairs( $project, $po, $mo );
// offer msginit unless plugin settings disallows optional POT
if( $pot || 2 > Loco_data_Settings::get()->pot_expected ){
$p['nav'][] = new Loco_mvc_ViewParams( [
'href' => $this->getProjectLink('msginit', $project ),
'name' => __('New language','loco-translate'),
'icon' => 'add',
] );
}
// Always offer PO file upload
$p['nav'][] = new Loco_mvc_ViewParams( [
'href' => $this->getProjectLink('upload', $project ),
'name' => __('Upload PO','loco-translate'),
'icon' => 'upload',
] );
// prevent editing of POT when config prohibits
if( $pot ){
if( $project->isPotLocked() || 1 < Loco_data_Settings::get()->pot_protect ) {
$p['nav'][] = new Loco_mvc_ViewParams( [
'href' => $this->getResourceLink('file-view', $project, $pot ),
'name' => __('View template','loco-translate'),
'icon' => 'file',
] );
}
// offer template editing if permitted
else {
$p['nav'][] = new Loco_mvc_ViewParams( [
'href' => $this->getResourceLink('file-edit', $project, $pot ),
'name' => __('Edit template','loco-translate'),
'icon' => 'pencil',
] );
}
}
// else offer creation of new Template
else {
$p['nav'][] = new Loco_mvc_ViewParams( [
'href' => $this->getProjectLink('xgettext', $project ),
'name' => __('Create template','loco-translate'),
'icon' => 'add',
] );
}
// foreach locale, establish if text domain is installed in system location, flag if not.
$installed = [];
foreach( $p['po'] as $pair ){
$lc = $pair['lcode'];
if( $pair['installed'] ){
$installed[$lc] = true;
}
else if( ! array_key_exists($lc,$installed) ){
$installed[$lc] = false;
}
}
$p['installed'] = $installed;
// warning only necessary for WP<6.6 due to `lang_dir_for_domain` fix
if( ! function_exists('wp_get_l10n_php_file_data') && in_array(false,$installed,true) ){
$p['warnings'][] = __('Custom translations may not work without System translations installed','loco-translate');
}
return $p;
}
/**
* Collect PO/MO pairings, ignoring any PO that is in use as a template
* @return array[]
*/
private function createPairs( Loco_fs_FileList $po, Loco_fs_FileList $mo, ?Loco_fs_File $pot = null ):array {
$pairs = [];
/* @var $pofile Loco_fs_LocaleFile */
foreach( $po as $pofile ){
if( $pot && $pofile->equal($pot) ){
continue;
}
$pair = [ $pofile, null ];
$mofile = $pofile->cloneExtension('mo');
if( $mofile->exists() ){
$pair[1] = $mofile;
}
$pairs[] = $pair;
}
/* @var $mofile Loco_fs_LocaleFile */
foreach( $mo as $mofile ){
$pofile = $mofile->cloneExtension('po');
if( $pot && $pofile->equal($pot) ){
continue;
}
if( ! $pofile->exists() ){
$pairs[] = [ null, $mofile ];
}
}
return $pairs;
}
/**
* Initialize view parameters for each row representing a localized resource pair
* @return array collection of entries corresponding to available PO/MO pair.
*/
private function createProjectPairs( Loco_package_Project $project, Loco_fs_LocaleFileList $po, Loco_fs_LocaleFileList $mo ):array {
// populate official locale names for all found, or default to our own
if( $locales = $po->getLocales() + $mo->getLocales() ){
$api = new Loco_api_WordPressTranslations;
foreach( $locales as $locale ){
$locale->ensureName($api);
}
}
// collate as unique [PO,MO] pairs ensuring canonical template excluded
$pairs = $this->createPairs( $po, $mo, $project->getPot() );
$rows = [];
foreach( $pairs as $pair ){
// favour PO file if it exists
list( $pofile, $mofile ) = $pair;
$file = $pofile or $file = $mofile;
// establish locale, or assume invalid
$locale = null;
/* @var Loco_fs_LocaleFile $file */
if( 'pot' !== $file->extension() ){
$tag = $file->getSuffix();
if( isset($locales[$tag]) ){
$locale = $locales[$tag];
}
}
$rows[] = $this->createFileParams( $project, $file, $locale );
}
// Sort PO pairs in alphabetical order, with custom before system, before author
usort( $rows, function( ArrayAccess $a, ArrayAccess $b ):int {
return strcasecmp( $a['lname'], $b['lname'] );
} );
return $rows;
}
private function createFileParams( Loco_package_Project $project, Loco_fs_File $file, ?Loco_Locale $locale = null ):Loco_mvc_ViewParams {
// Pull Gettext meta data from cache if possible
$meta = Loco_gettext_Metadata::load($file);
$dir = new Loco_fs_LocaleDirectory( $file->dirname() );
$dType = $dir->getTypeId();
// routing arguments
$args = [
'path' => $meta->getPath(false),
];
// Return data required for PO table row
return new Loco_mvc_ViewParams( [
// locale info
'lcode' => $locale ? (string) $locale : '',
'lname' => $locale ? $locale->getName() : '',
'lattr' => $locale ? 'class="'.$locale->getIcon().'" lang="'.$locale->lang.'"' : '',
// file info
'meta' => $meta,
'name' => $file->basename(),
'time' => $file->modified(),
'type' => strtoupper( $file->extension() ),
'todo' => $meta->countIncomplete(),
'total' => $meta->getTotal(),
// author / system / custom / other
'installed' => 'wplang' === $dType,
'store' => $dir->getTypeLabel($dType),
// links
'view' => $this->getProjectLink('file-view', $project, $args ),
'info' => $this->getProjectLink('file-info', $project, $args ),
'edit' => $this->getProjectLink('file-edit', $project, $args ),
'move' => $this->getProjectLink('file-move', $project, $args ),
'delete' => $this->getProjectLink('file-delete', $project, $args ),
'copy' => $this->getProjectLink('msginit', $project, $args ),
] );
}
/**
* Prepare view parameters for all projects in a bundle
* @return Loco_mvc_ViewParams[]
*/
private function createBundleListing( Loco_package_Bundle $bundle ){
$projects = [];
/* @var $project Loco_package_Project */
foreach( $bundle as $project ){
$projects[] = $this->createProjectParams($project);
}
return $projects;
}
/**
* {@inheritdoc}
*/
public function render(){
$this->prepareNavigation();
$bundle = $this->getBundle();
$this->set('name', $bundle->getName() );
// bundle may not be fully configured
$configured = $bundle->isConfigured();
// Hello Dolly is an exception. don't show unless configured deliberately
if( 'Hello Dolly' === $bundle->getName() && 'hello.php' === basename($bundle->getHandle()) ){
if( ! $configured || 'meta' === $configured ){
$this->set( 'redirect', Loco_mvc_AdminRouter::generate('core-view') );
return $this->view('admin/bundle/alias');
}
}
// Collect all configured projects
$projects = $this->createBundleListing( $bundle );
$unknown = [];
// sniff additional unknown files if bundle is a theme or directory-based plugin that's been auto-detected
if( 'file' === $configured || 'internal' === $configured ){
// presumed complete
}
else if( $bundle->isTheme() || ( $bundle->isPlugin() && ! $bundle->isSingleFile() ) ){
// TODO This needs abstracting into the Loco_package_Inverter class
$prefixes = [];
$po = new Loco_fs_LocaleFileList;
$mo = new Loco_fs_LocaleFileList;
$prefs = Loco_data_Preferences::get();
foreach( Loco_package_Inverter::export($bundle) as $ext => $files ){
$list = 'mo' === $ext ? $mo : $po;
foreach( $files as $file ){
$file = new Loco_fs_LocaleFile($file);
$locale = $file->getLocale();
if( $prefs && ! $prefs->has_locale($locale) ){
continue;
}
$list->addLocalized( $file );
// Only look in system locations if locale is valid and domain/prefix available
if( $locale->isValid() ){
$domain = $file->getPrefix();
if( $domain ) {
$prefixes[$domain] = true;
}
}
}
}
// pick up given files in system locations only
foreach( $prefixes as $domain => $_bool ){
$dummy = new Loco_package_Project( $bundle, new Loco_package_TextDomain($domain), '' );
$bundle->addProject( $dummy ); // <- required to configure locations
$dummy->excludeTargetPath( $bundle->getDirectoryPath() );
$po->augment( $dummy->findLocaleFiles('po') );
$mo->augment( $dummy->findLocaleFiles('mo') );
}
// a fake project is required to disable functions that require a configured project
$dummy = new Loco_package_Project( $bundle, new Loco_package_TextDomain(''), '' );
$unknown = $this->createProjectPairs( $dummy, $po, $mo );
}
$this->set('projects', $projects );
$this->set('unknown', $unknown );
return $this->view( 'admin/bundle/view' );
}
}

View File

@@ -0,0 +1,83 @@
<?php
/**
* API keys/settings screen
*/
class Loco_admin_config_ApisController extends Loco_admin_config_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$this->set( 'title', __('API keys','loco-translate') );
// Collect configurable API keys bundled with plugin
$apis = [];
foreach( Loco_api_Providers::builtin() as $api ){
$apis[ $api['id'] ] = new Loco_mvc_ViewParams($api);
}
// Add any additional API hooks for information only
$hooked = [];
foreach( Loco_api_Providers::export() as $api ){
$id = $api['id'];
if( ! array_key_exists($id,$apis) ){
$hooked[ $id ] = new Loco_mvc_ViewParams($api);
}
}
$this->set('apis',$apis);
$this->set('hooked',$hooked);
// handle save action
$nonce = $this->setNonce('save-apis');
try {
if( $this->checkNonce($nonce->action) ){
$post = Loco_mvc_PostParams::get();
if( $post->has('api') ){
// Save only options in post. Avoids overwrite of missing site options
$data = [];
$filter = [];
foreach( $apis as $id => $api ){
$fields = $post->api[$id]??null;
if( is_array($fields) ){
foreach( $fields as $prop => $value ){
$apis[$id][$prop] = $value;
$prop = $id.'_api_'.$prop;
$data[$prop] = $value;
$filter[] = $prop;
}
}
}
if( $filter ){
Loco_data_Settings::get()->populate($data,$filter)->persistIfDirty();
Loco_error_AdminNotices::success( __('Settings saved','loco-translate') );
}
}
}
}
catch( Loco_error_Exception $e ){
Loco_error_AdminNotices::add($e);
}
}
/**
* {@inheritdoc}
*/
public function render(){
$title = __('Plugin settings','loco-translate');
$breadcrumb = new Loco_admin_Navigation;
$breadcrumb->add( $title );
// common ui elements / labels
$this->set( 'ui', new Loco_mvc_ViewParams( [
'api_key' => __('API key','loco-translate'),
'api_url' => __('API URL','loco-translate'),
'api_region' => __('API region','loco-translate'),
] ) );
return $this->view('admin/config/apis', compact('breadcrumb') );
}
}

View File

@@ -0,0 +1,45 @@
<?php
/**
* Base controller for global plugin configuration screens
*/
abstract class Loco_admin_config_BaseController extends Loco_mvc_AdminController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
// navigate between config view siblings, but only if privileged user
if( current_user_can('manage_options') ){
$tabs = new Loco_admin_Navigation;
$this->set( 'tabs', $tabs );
$actions = [
'' => __('Site options','loco-translate'),
'user' => __('User options','loco-translate'),
'apis' => __('API keys','loco-translate'),
'version' => __('Version','loco-translate'),
'debug' => __('System','loco-translate'),
];
$suffix = (string) $this->get('action');
foreach( $actions as $action => $name ){
$href = Loco_mvc_AdminRouter::generate( 'config-'.$action, $_GET );
$tabs->add( $name, $href, $action === $suffix );
}
}
}
/**
* {@inheritdoc}
*/
public function getHelpTabs(){
return [
__('Overview','loco-translate') => $this->viewSnippet('tab-config'),
__('API keys','loco-translate') => $this->viewSnippet('tab-config-apis'),
];
}
}

View File

@@ -0,0 +1,190 @@
<?php
/**
* Plugin config check (system diagnostics)
*/
class Loco_admin_config_DebugController extends Loco_admin_config_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$this->set( 'title', __('Debug','loco-translate') );
}
/**
* @param string $raw
* @return int
*/
private function memory_size( $raw ){
$bytes = wp_convert_hr_to_bytes($raw);
return Loco_mvc_FileParams::renderBytes($bytes);
}
/**
* Get path relative to WordPress ABSPATH
* @param string $path
* @return string
*/
private function rel_path( $path ){
if( is_string($path) && $path && '/' === $path[0] ){
$file = new Loco_fs_File( $path );
$path = $file->getRelativePath(ABSPATH);
}
else if( ! $path ){
$path = '(none)';
}
return $path;
}
private function file_params( Loco_fs_File $file ){
$ctx = new Loco_fs_FileWriter($file);
return new Loco_mvc_ViewParams(['path'=>$this->rel_path($file->getPath()), 'writable'=>$ctx->writable()]);
}
/**
* {@inheritdoc}
*/
public function render(){
$title = __('System diagnostics','loco-translate');
$breadcrumb = new Loco_admin_Navigation;
$breadcrumb->add( $title );
// extensions that are normally enabled in PHP by default
loco_check_extension('json');
loco_check_extension('ctype');
// product versions:
$versions = new Loco_mvc_ViewParams( [
'Loco Translate' => loco_plugin_version(),
'WordPress' => $GLOBALS['wp_version'],
'PHP' => phpversion().' ('.PHP_SAPI.')',
'Server' => isset($_SERVER['SERVER_SOFTWARE']) ? $_SERVER['SERVER_SOFTWARE'] : ( function_exists('apache_get_version') ? apache_get_version() : '' ),
'jQuery' => '...',
] );
// we want to know about modules in case there are security mods installed known to break functionality
if( function_exists('apache_get_modules') && ( $mods = preg_grep('/^mod_/',apache_get_modules() ) ) ){
$versions['Server'] .= ' + '.implode(', ',$mods);
}
// Add Xdebug version if installed
if( extension_loaded('xdebug') ){
$versions['PHP'] .= ' + Xdebug '. phpversion('xdebug');
}
// byte code cache (currently only checking for Zend OPcache)
if( function_exists('opcache_get_configuration') && ini_get('opcache.enable') ){
$info = opcache_get_configuration();
$vers = $info['version'];
$versions[ $vers['opcache_product_name'] ] = ' '.$vers['version'];
}
// utf8 / encoding:
$cs = get_option('blog_charset');
$encoding = new Loco_mvc_ViewParams( [
'OK' => "\xCE\x9F\xCE\x9A",
'tick' => "\xE2\x9C\x93",
'json' => json_decode('"\\u039f\\u039a \\u2713"'),
'charset' => $cs.' '.( preg_match('/^utf-?8$/i',$cs) ? "\xE2\x9C\x93" : '(not recommended)' ),
'mbstring' => loco_check_extension('mbstring') ? "\xCE\x9F\xCE\x9A \xE2\x9C\x93" : 'No',
] );
// Sanity check mbstring.func_overload
if( 2 !== strlen("\xC2\xA3") ){
$encoding->mbstring = 'Error, disable mbstring.func_overload';
}
// PHP / env memory settings:
$memory = new Loco_mvc_PostParams( [
'WP_MEMORY_LIMIT' => $this->memory_size( loco_constant('WP_MEMORY_LIMIT') ),
'WP_MAX_MEMORY_LIMIT' => $this->memory_size( loco_constant('WP_MAX_MEMORY_LIMIT') ),
'PHP memory_limit' => $this->memory_size( ini_get('memory_limit') ),
'PHP post_max_size' => $this->memory_size( ini_get('post_max_size') ),
//'PHP upload_max_filesize' => $this->memory_size( ini_get('upload_max_filesize') ),
'PHP max_execution_time' => (string) ini_get('max_execution_time'),
] );
// Check if raising memory limit works (wp>=4.6)
if( function_exists('wp_is_ini_value_changeable') && wp_is_ini_value_changeable('memory_limit') ){
$memory['PHP memory_limit'] .= ' (changeable)';
}
// Ajaxing:
$this->enqueueScript('system');
$this->set( 'js', new Loco_mvc_ViewParams( [
'nonces' => [ 'ping' => wp_create_nonce('ping'), 'apis' => wp_create_nonce('apis') ],
] ) );
// Third party API integrations:
$apis = [];
$jsapis = [];
foreach( Loco_api_Providers::sort( Loco_api_Providers::export() ) as $api ){
$apis[] = new Loco_mvc_ViewParams($api);
$jsapis[] = $api;
}
if( $apis ){
$this->set('apis',$apis);
$jsconf = $this->get('js');
$jsconf['apis'] = $jsapis;
}
// File system access
$ctx = new Loco_fs_FileWriter( new Loco_fs_Directory(WP_LANG_DIR) );
$fsp = Loco_data_Settings::get()->fs_protect;
$fs = new Loco_mvc_PostParams( [
'disabled' => $ctx->disabled(),
'fs_protect' => 1 === $fsp ? 'Warn' : ( $fsp ? 'Block' : 'Off' ),
] );
// important locations, starting with LOCO_LANG_DIR
$locations = [
'WP_LANG_DIR' => $this->file_params( new Loco_fs_Directory( loco_constant('WP_LANG_DIR') ) ),
'LOCO_LANG_DIR' => $this->file_params( new Loco_fs_Directory( loco_constant('LOCO_LANG_DIR') ) ),
];
// WP_TEMP_DIR takes precedence over sys_get_temp_dir in WordPress get_temp_dir();
if( defined('WP_TEMP_DIR') ){
$locations['WP_TEMP_DIR'] = $this->file_params( new Loco_fs_Directory(WP_TEMP_DIR) );
}
$locations['PHP sys_temp_dir'] = $this->file_params( new Loco_fs_Directory( sys_get_temp_dir() ) );
$locations['PHP upload_tmp_dir'] = $this->file_params( new Loco_fs_Directory( ini_get('upload_tmp_dir') ) );
$locations['PHP error_log'] = $this->file_params( new Loco_fs_Directory( ini_get('error_log') ) );
// Debug and error log settings
$debug = new Loco_mvc_ViewParams( [
'WP_DEBUG' => loco_constant('WP_DEBUG') ? 'On' : 'Off',
'WP_DEBUG_LOG' => loco_constant('WP_DEBUG_LOG') ? 'On' : 'Off',
'WP_DEBUG_DISPLAY' => loco_constant('WP_DEBUG_DISPLAY') ? 'On' : 'Off',
'PHP display_errors' => ini_get('display_errors') ? 'On' : 'Off',
'PHP log_errors' => ini_get('log_errors') ? 'On' : 'Off',
] );
/* Output buffering settings
$this->set('ob', new Loco_mvc_ViewParams( array(
'output_handler' => ini_get('output_handler'),
'zlib.output_compression' => ini_get('zlib.output_compression'),
'zlib.output_compression_level' => ini_get('zlib.output_compression_level'),
'zlib.output_handler' => ini_get('zlib.output_handler'),
) ) );*/
// alert to known system setting problems:
if( version_compare(PHP_VERSION,'7.4','<') ){
// phpcs:disable -- PHP version is checked prior to deprecated function call.
if( get_magic_quotes_gpc() ){
Loco_error_AdminNotices::info('You have "magic_quotes_gpc" enabled. We recommend you disable this in PHP');
}
if( get_magic_quotes_runtime() ){
Loco_error_AdminNotices::info('You have "magic_quotes_runtime" enabled. We recommend you disable this in PHP');
}
if( version_compare(PHP_VERSION,'5.6.20','<') ){
Loco_error_AdminNotices::info('Your PHP version is very old. We recommend you upgrade');
}
// phpcs:enable
}
return $this->view('admin/config/debug', compact('breadcrumb','versions','encoding','memory','fs','locations','debug') );
}
}

View File

@@ -0,0 +1,49 @@
<?php
/**
* User-level plugin preferences
*/
class Loco_admin_config_PrefsController extends Loco_admin_config_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$this->set( 'title', __('User options','loco-translate') );
// user preference options
$opts = Loco_data_Preferences::get();
$this->set( 'opts', $opts );
// handle save action
$nonce = $this->setNonce('save-prefs');
try {
if( $this->checkNonce($nonce->action) ){
$post = Loco_mvc_PostParams::get();
if( $post->has('opts') ){
$opts->populate( $post->opts )->persist();
Loco_error_AdminNotices::success( __('Settings saved','loco-translate') );
}
}
}
catch( Loco_error_Exception $e ){
Loco_error_AdminNotices::add($e);
}
}
/**
* {@inheritdoc}
*/
public function render(){
$title = __('Plugin settings','loco-translate');
$breadcrumb = new Loco_admin_Navigation;
$breadcrumb->add( $title );
return $this->view('admin/config/prefs', compact('breadcrumb') );
}
}

View File

@@ -0,0 +1,86 @@
<?php
/**
* Site-wide Loco options (plugin settings)
*/
class Loco_admin_config_SettingsController extends Loco_admin_config_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
// set current plugin options and defaults for placeholders
$opts = Loco_data_Settings::get();
$this->set( 'opts', $opts );
$this->set( 'dflt', Loco_data_Settings::create() );
// roles and capabilities
$perms = new Loco_data_Permissions;
// handle save action
$nonce = $this->setNonce('save-config');
try {
if( $this->checkNonce($nonce->action) ){
$post = Loco_mvc_PostParams::get();
if( $post->has('opts') ){
$opts->populate( $post->opts )->persist();
$perms->populate( $post->has('caps') ? $post->caps : [] );
// done update
Loco_error_AdminNotices::success( __('Settings saved','loco-translate') );
// remove saved params from session if persistent options unset
if( ! $opts['fs_persist'] ){
$session = Loco_data_Session::get();
if( isset($session['loco-fs']) ){
unset( $session['loco-fs'] );
$session->persist();
}
}
}
}
}
catch( Loco_error_Exception $e ){
Loco_error_AdminNotices::add($e);
}
$this->set('caps', $caps = new Loco_mvc_ViewParams );
// there is no distinct role for network admin, so we'll fake it for UI
if( is_multisite() ){
$caps[''] = new Loco_mvc_ViewParams( [
'label' => __('Super Admin','loco-translate'),
'name' => 'dummy-admin-cap',
'attrs' => 'checked disabled'
] );
}
foreach( $perms->getRoles() as $id => $role ){
$caps[$id] = new Loco_mvc_ViewParams( [
'value' => '1',
'label' => $perms->getRoleName($id),
'name' => 'caps['.$id.'][loco_admin]',
'attrs' => $perms->isProtectedRole($role) ? 'checked disabled ' : ( $role->has_cap('loco_admin') ? 'checked ' : '' ),
] );
}
// allow/deny warning levels
$this->set('verbose', new Loco_mvc_ViewParams( [
0 => __('Allow','loco-translate'),
1 => __('Allow (with warning)','loco-translate'),
2 => __('Disallow','loco-translate'),
] ) );
}
/**
* {@inheritdoc}
*/
public function render(){
$title = __('Plugin settings','loco-translate');
$breadcrumb = new Loco_admin_Navigation;
$breadcrumb->add( $title );
return $this->view('admin/config/settings', compact('breadcrumb') );
}
}

View File

@@ -0,0 +1,71 @@
<?php
/**
* Plugin version / upgrade screen
*/
class Loco_admin_config_VersionController extends Loco_admin_config_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$this->set( 'title', __('Version','loco-translate') );
}
/**
* {@inheritdoc}
*/
public function render(){
$title = __('Plugin settings','loco-translate');
$breadcrumb = new Loco_admin_Navigation;
$breadcrumb->add( $title );
$this->setLocoUpdate('0');
// current plugin version
$version = loco_plugin_version();
if( $updates = get_site_transient('update_plugins') ){
$key = loco_plugin_self();
if( isset($updates->response[$key]) ){
$latest = $updates->response[$key]->new_version;
// if current version is lower than latest, prompt update
if( version_compare($version,$latest,'<') ){
$this->setLocoUpdate($latest);
}
}
}
// notify if running a development snapshot, but only if ahead of latest stable
if( '-dev' === substr($version,-4) ){
$this->set( 'devel', true );
}
// check PHP version is at least 7.4
$phpversion = PHP_VERSION;
if( version_compare($phpversion,'7.4.0','<') ){
$this->set('phpupdate','7.4');
}
// check WordPress version, No plans to increase this until WP bumps their min PHP requirement.
$wpversion = $GLOBALS['wp_version'];
return $this->view('admin/config/version', compact('breadcrumb','version','phpversion','wpversion') );
}
private function setLocoUpdate( string $version ){
if( $version ){
$action = 'upgrade-plugin_'.loco_plugin_self();
$link = admin_url( 'update.php?action=upgrade-plugin&plugin='.rawurlencode(loco_plugin_self()) );
$this->set('update', $version );
$this->set('update_href', wp_nonce_url( $link, $action ) );
}
else {
$this->set('update','');
$this->set('update_href','');
}
}
}

View File

@@ -0,0 +1,174 @@
<?php
/**
* Base class for a file resource belonging to a bundle
* Root > List > Bundle > Resource
*/
abstract class Loco_admin_file_BaseController extends Loco_admin_bundle_BaseController {
/**
* @var Loco_Locale
*/
private $locale;
/**
* @return Loco_Locale
*/
protected function getLocale(){
return $this->locale;
}
/**
* Check file is valid or return error
* @return string rendered error
*/
protected function getFileError( ?Loco_fs_File $file = null ){
// file must exist for editing
if( is_null($file) || ! $file->exists() ){
return $this->view( 'admin/errors/file-missing', [] );
}
if( $file->isDirectory() ){
$this->set('info', Loco_mvc_FileParams::create($file) );
return $this->view( 'admin/errors/file-isdir', [] );
}
// security validations
try {
Loco_gettext_Data::ext( $file );
}
catch( Exception $e ){
return $this->view( 'admin/errors/file-sec', [ 'reason' => $e->getMessage() ] );
}
return '';
}
/**
* Set template title argument for a file
* @return void
*/
protected function setFileTitle( Loco_fs_File $file, $format = '%s' ){
$name = Loco_mvc_ViewParams::format($format,[$file->basename()]);
// append folder location for translation files
if( in_array( $file->extension(), ['po','mo'] ) ){
$dir = new Loco_fs_LocaleDirectory( $file->dirname() );
$type = $dir->getTypeLabel( $dir->getTypeId() );
$name .= ' ('.mb_strtolower($type,'UTF-8').')';
}
$this->set('title', $name );
}
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
// views at this level are always related to a file
// file is permitted to be missing during this execution.
$path = $this->get('path');
if( ! $path ){
throw new Loco_error_Exception('path argument required');
}
$file = new Loco_fs_LocaleFile( $path );
$file->normalize( loco_constant('WP_CONTENT_DIR') );
$ext = strtolower( $file->extension() );
// POT file has no locale
if( 'pot' === $ext ){
$locale = null;
$localised = false;
}
// else file may have a locale suffix (unless invalid, such as "default.po")
else {
$locale = $file->getLocale();
$localised = $locale->isValid();
}
if( $localised ){
$this->locale = $locale;
$code = (string) $locale;
$this->set( 'locale', new Loco_mvc_ViewParams( [
'code' => $code,
'lang' => $locale->lang,
'icon' => $locale->getIcon(),
'name' => $locale->ensureName( new Loco_api_WordPressTranslations ),
'href' => Loco_mvc_AdminRouter::generate('lang-view', ['locale'=>$code] ),
] ) );
}
else {
$this->set( 'locale', null );
}
$this->set('file', $file );
$this->set('filetype', strtoupper($ext) );
$this->set('title', $file->basename() );
// navigate up to root from this bundle sub view
$bundle = $this->getBundle();
$breadcrumb = Loco_admin_Navigation::createBreadcrumb( $bundle );
$this->set( 'breadcrumb', $breadcrumb );
// navigate between sub view siblings for this resource
$tabs = new Loco_admin_Navigation;
$this->set( 'tabs', $tabs );
$actions = [
'file-edit' => __('Editor','loco-translate'),
'file-view' => __('Source','loco-translate'),
'file-info' => __('File info','loco-translate'),
'file-diff' => __('Restore','loco-translate'),
'file-move' => $localised ? __('Relocate','loco-translate') : null,
'file-delete' => __('Delete','loco-translate'),
];
$suffix = $this->get('action');
$prefix = $this->get('type');
$args = array_intersect_key($_GET,['path'=>1,'bundle'=>1,'domain'=>1]);
foreach( $actions as $action => $name ){
if( is_string($name) ){
$href = Loco_mvc_AdminRouter::generate( $prefix.'-'.$action, $args );
$tabs->add( $name, $href, $action === $suffix );
}
}
// Provide common language creation link if project scope is valid
$project = $this->getOptionalProject();
if( $project ){
$args = [ 'bundle' => $bundle->getHandle(), 'domain' => $project->getId() ];
$this->set( 'msginit', new Loco_mvc_ViewParams( [
'href' => Loco_mvc_AdminRouter::generate( $prefix.'-msginit', $args ),
'text' => __('New language','loco-translate'),
] ) );
}
}
/**
* {@inheritdoc}
*/
public function view( $tpl, array $args = [] ){
if( $breadcrumb = $this->get('breadcrumb') ){
// Add project name into breadcrumb if not the same as bundle name
try {
$project = $this->getProject();
if( $project->getName() !== $this->getBundle()->getName() ){
$breadcrumb->add( $project->getName() );
}
}
catch( Loco_error_Exception $e ){
// ignore missing project in breadcrumb
}
// Always add page title as final breadcrumb element
$breadcrumb->add( $this->get('title')?:'Untitled' );
}
return parent::view( $tpl, $args );
}
}

View File

@@ -0,0 +1,118 @@
<?php
/**
* File delete function
*/
class Loco_admin_file_DeleteController extends Loco_admin_file_BaseController {
/**
* Expand single path to all files that will be deleted
* @param Loco_fs_File $file primary file being deleted, probably the PO
* @return array
*/
private function expandFiles( Loco_fs_File $file ){
try {
$siblings = new Loco_fs_Siblings( $file );
}
catch( InvalidArgumentException $e ){
$ext = $file->extension();
throw new Loco_error_Exception( sprintf('Refusing to delete a %s file', strtoupper($ext) ) );
}
$siblings->setDomain( $this->getDomain() );
return $siblings->expand();
}
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$file = $this->get('file');
// set up form for delete confirmation
if( $file->exists() && ! $file->isDirectory() ){
// nonce action will be specific to file for extra security
// TODO could also add file MD5 to avoid deletion after changes made.
$path = $file->getPath();
$action = 'delete:'.$path;
// set up view now in case of late failure
$fields = new Loco_mvc_HiddenFields( [] );
$fields->setNonce( $action );
$this->set( 'hidden', $fields );
// attempt delete if valid nonce posted back
if( $this->checkNonce($action) ){
$api = new Loco_api_WordPressFileSystem;
// delete dependant files first, so master still exists if others fail
$files = array_reverse( $this->expandFiles($file) );
try {
/* @var $trash Loco_fs_File */
foreach( $files as $trash ){
$api->authorizeDelete($trash);
$trash->unlink();
}
// flash message for display after redirect
try {
$n = count( $files );
// translators: %u is a number of files which were successfully deleted
Loco_data_Session::get()->flash('success', sprintf( _n('%u file deleted','%u files deleted',$n,'loco-translate'),$n) );
Loco_data_Session::close();
}
catch( Exception $e ){
// tolerate session failure
}
// redirect to bundle overview
$href = Loco_mvc_AdminRouter::generate( $this->get('type').'-view', [ 'bundle' => $this->get('bundle') ] );
if( wp_redirect($href) ){
exit;
}
}
catch( Loco_error_Exception $e ){
Loco_error_AdminNotices::add( $e );
}
}
}
// set page title before render sets inline title
$bundle = $this->getBundle();
// translators: Page title where %s is the name of a file to be deleted
$this->set('title', sprintf( __('Delete %s','loco-translate'), $file->basename() ).' &lsaquo; '.$bundle->getName() );
}
/**
* {@inheritdoc}
*/
public function render(){
$file = $this->get('file');
if( $fail = $this->getFileError($file) ){
return $fail;
}
$files = $this->expandFiles( $file );
$info = Loco_mvc_FileParams::create($file);
$this->set( 'info', $info );
// phpcs:ignore -- duplicate string
$this->setFileTitle( $file, __('Delete %s','loco-translate') );
// warn about additional files that will be deleted along with this
if( $deps = array_slice($files,1) ){
$count = count($deps);
// translators: Warning that deleting a file will also delete others. %s indicates that quantity.
$this->set('warn', sprintf( _n( '%s dependent file will also be deleted', '%s dependent files will also be deleted', $count, 'loco-translate' ), $count ) );
$infos = [];
foreach( $deps as $depfile ){
$infos[] = Loco_mvc_FileParams::create( $depfile );
}
$this->set('deps', $infos );
}
$this->prepareFsConnect( 'delete', $this->get('path') );
$this->enqueueScript('delete');
return $this->view('admin/file/delete');
}
}

View File

@@ -0,0 +1,143 @@
<?php
/**
* File revisions and rollback
*/
class Loco_admin_file_DiffController extends Loco_admin_file_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$this->enqueueStyle('podiff');
$pofile = $this->get('file');
if( $pofile->exists() && ! $pofile->isDirectory() ){
$path = $pofile->getPath();
$action = 'restore:'.$path;
// set up view now in case of late failure
$fields = new Loco_mvc_HiddenFields( [] );
$fields->setNonce($action);
$this->set( 'hidden', $fields );
// attempt rollback if valid nonce posted back with backup path
if( $this->checkNonce($action) ){
try {
$post = Loco_mvc_PostParams::get();
// Restore
if( $post->has('backup') ){
$path = $post->backup;
$target = new Loco_fs_File( $path );
$target->normalize( loco_constant('WP_CONTENT_DIR') );
// Recompile back to current version. Note that restoring a backup also backs up current file
$data = Loco_gettext_Data::fromSource( $target->getContents() );
$compiler = new Loco_gettext_Compiler($pofile);
$compiler->writeAll( $data, $this->getOptionalProject() );
Loco_error_AdminNotices::success( __('File restored','loco-translate') );
}
// Delete an old backup from revision list
else if( $post->has('delete') ){
$path = $post->delete;
$target = new Loco_fs_File( $path );
$target->normalize( loco_constant('WP_CONTENT_DIR') );
$api = new Loco_api_WordPressFileSystem;
$api->authorizeDelete( $target );
$target->unlink();
Loco_error_AdminNotices::success( __('File deleted','loco-translate') );
}
else {
throw new Loco_error_Exception('Nothing selected');
}
}
catch( Loco_error_Exception $e ){
Loco_error_AdminNotices::add( $e );
}
}
}
$bundle = $this->getBundle();
// translators: %s is a file name to be rolled back to a previous version.
$this->set('title', sprintf( __('Restore %s','loco-translate'), $pofile->basename() ).' &lsaquo; '.$bundle->getName() );
}
/**
* {@inheritdoc}
*/
public function render(){
$file = $this->get('file');
if( $fail = $this->getFileError($file) ){
return $fail;
}
$info = Loco_mvc_FileParams::create($file);
$info['mtime'] = $file->modified();
$this->set( 'master', $info );
// translators: Page title where %s refers to a file name
$this->setFileTitle( $file, __('Restore %s','loco-translate') );
$enabled = Loco_data_Settings::get()->num_backups;
$this->set( 'enabled', $enabled );
$files = [];
$wp_content = loco_constant('WP_CONTENT_DIR');
$paths = [ $file->getRelativePath($wp_content) ];
$podate = 'pot' === $file->extension() ? 'POT-Creation-Date' : 'PO-Revision-Date';
$backups = new Loco_fs_Revisions($file);
foreach( $backups->getPaths() as $path ){
$tmp = new Loco_fs_File( $path );
$info = Loco_mvc_FileParams::create($tmp);
// time file was snapshotted is actually the time the next version was updated
// $info['mtime'] = $backups->getTimestamp($path);
// pull "real" update time, meaning when the revision was last updated as current version
try {
$head = Loco_gettext_Data::head($tmp);
$value = $head->trimmed($podate);
if( '' !== $value ){
$info['potime'] = Loco_gettext_Data::parseDate($value);
}
else {
throw new Loco_error_Exception('Backup has no '.$podate.' field');
}
}
catch( Exception $e ){
Loco_error_AdminNotices::debug( $e->getMessage() );
continue;
}
$paths[] = $tmp->getRelativePath($wp_content);
$files[] = $info;
}
// no backups = no restore
if( ! $files ){
return $this->view('admin/errors/no-backups');
}
/*/ warn if current backup settings aren't enough to restore without losing older revisions
$min = count($files) + 1;
if( $enabled < $min ){
$notice = Loco_error_AdminNotices::info('We recommend enabling more backups before restoring');
$notice->addLink( apply_filters('loco_external','https://localise.biz/wordpress/plugin/manual/settings#po'), __('Documentation','loco-translate') )
->addLink( Loco_mvc_AdminRouter::generate('config').'#loco--num-backups', __('Settings') );
}*/
// restore permissions required are create and delete on current location
$this->prepareFsConnect( 'update', $this->get('path') );
// prepare revision arguments for JavaScript
$this->set( 'js', new Loco_mvc_ViewParams( [
'paths' => $paths,
'nonces' => [
'diff' => wp_create_nonce('diff'),
]
] ) );
$this->enqueueScript('podiff');
return $this->view('admin/file/diff', compact('files','backups') );
}
}

View File

@@ -0,0 +1,262 @@
<?php
/**
* PO editor view
*/
class Loco_admin_file_EditController extends Loco_admin_file_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$this->enqueueStyle('editor');
//
$file = $this->get('file');
$bundle = $this->getBundle();
// translators: %1$s is the file name, %2$s is the bundle name
$this->set('title', sprintf( __('Editing %1$s in %2$s','loco-translate'), $file->basename(), $bundle ) );
}
/**
* {@inheritdoc}
*/
public function getHelpTabs(){
return [
__('Overview') => $this->viewSnippet('tab-file-edit'),
];
}
/**
* @param bool $readonly whether po files is in read-only mode
* @return array
*/
private function getNonces( $readonly ){
$nonces = [];
foreach( $readonly ? ['fsReference'] : ['sync','save','fsReference','apis'] as $name ){
$nonces[$name] = wp_create_nonce($name);
}
return $nonces;
}
/**
* {@inheritdoc}
*/
public function render(){
// file must exist for editing
/* @var Loco_fs_File $file */
$file = $this->get('file');
if( $fail = $this->getFileError($file) ){
return $fail;
}
// editor will be rendered
$this->enqueueScript('editor');
// Parse file data into JavaScript for editor
try {
$this->set('modified', $file->modified() );
$data = Loco_gettext_Data::load($file);
}
catch( Exception $e ){
Loco_error_AdminNotices::add( Loco_error_Exception::convert($e) );
$data = Loco_gettext_Data::dummy();
}
$head = $data->getHeaders();
// default is to permit editing of any file
$readonly = false;
// All files must belong to a bundle.
$bundle = $this->getBundle();
// Establish if file belongs to a configured project
try {
$project = $this->getProject();
}
// Fine if not, this just means sync isn't possible.
catch( Loco_error_Exception $e ){
Loco_error_AdminNotices::add( $e );
Loco_error_AdminNotices::debug("Sync is disabled because this file doesn't relate to a known set of translations");
$project = null;
}
// Establish PO/POT edit mode
$potfile = null;
$syncmode = null;
$locale = $this->getLocale();
if( $locale instanceof Loco_Locale ){
// alternative POT file may be forced by sync options
$sync = new Loco_gettext_SyncOptions($head);
$syncmode = $sync->getSyncMode();
if( $sync->hasTemplate() ){
$potfile = $sync->getTemplate();
$potfile->normalize( $bundle->getDirectoryPath() );
}
// else use project-configured template, assuming there is one
// no way to get configured POT if invalid project
else if( $project ){
$potfile = $project->getPot();
// Handle situation where project defines a localised file as the official template
if( $potfile && $potfile->equal($file) ){
$locale = null;
$potfile = null;
}
}
if( $potfile ){
// Validate template file as long as it exists
if( $potfile->exists() ){
try {
$potdata = Loco_gettext_Data::load($potfile);
// If template is pulling JSON files, we must merge them in before msgid comparison
if( $project && $sync->mergeJson() ){
$siblings = new Loco_fs_Siblings($potfile);
$jsons = $siblings->getJsons( $project->getDomain()->getName() );
if( $jsons ){
// using matcher because regular iterator isn't indexed, and additions must be unique
$merged = new Loco_gettext_Matcher($project);
$merged->loadRefs($potdata);
$merged->loadJsons($jsons);
$potdata = $merged->exportPo();
unset($matcher);
}
}
if( ! $potdata->equalSource($data) ){
// translators: %s refers to the name of a POT file
Loco_error_AdminNotices::info( sprintf( __("Translations don't match template. Run sync to update from %s",'loco-translate'), $potfile->basename() ) )
->addLink( apply_filters('loco_external','https://localise.biz/wordpress/plugin/manual/sync'), __('Documentation','loco-translate') );
}
unset($potdata);
}
catch( Exception $e ){
// translators: Where %s is the name of the invalid POT file
Loco_error_AdminNotices::warn( sprintf( __('Translation template is invalid (%s)','loco-translate'), $potfile->basename() ) );
$potfile = null;
}
}
// else template doesn't exist, so sync will be done to source code
else {
// Loco_error_AdminNotices::debug( sprintf( __('Template file not found (%s)','loco-translate'), $potfile->basename() ) );
$potfile = null;
}
}
if( $locale ){
// allow PO file to dictate its own Plural-Forms
try {
$locale->setPluralFormsHeader( $head['Plural-Forms'] );
}
catch( InvalidArgumentException $e ){
// ignore invalid Plural-Forms
}
// fill in missing PO headers now locale is fully resolved
$data->localize($locale);
// If MO file will be compiled, check for library/config problems
if ( 2 !== strlen( "\xC2\xA3" ) ) {
Loco_error_AdminNotices::warn('Your mbstring configuration will result in corrupt MO files. Please ensure mbstring.func_overload is disabled');
}
}
}
// WordPress source locale is always en_US, but filter allows override for purpose of sending to translation APIs.
$tag = apply_filters('loco_api_provider_source', 'en', $file->getPath() );
$source = Loco_Locale::parse($tag);
$settings = Loco_data_Settings::get();
if( is_null($locale) ){
// notify if template is locked (save and sync will be disabled)
if( $project && $project->isPotLocked() ){
$this->set('fsDenied', true );
$readonly = true;
}
// translators: Warning when POT file is opened in the file editor. It can be disabled in settings.
else if( 1 === $settings->pot_protect ){
$e = new Loco_error_Warning( __("This is NOT a translation file. Manual editing of source strings is not recommended.",'loco-translate') );
$e->addLink( Loco_mvc_AdminRouter::generate('config').'#loco--pot-protect', __('Settings','loco-translate') )
->addLink( apply_filters('loco_external','https://localise.biz/wordpress/plugin/manual/templates'), __('Documentation','loco-translate') )
->noLog();
Loco_error_AdminNotices::add($e);
}
}
// back end expects paths relative to wp-content
$wp_content = loco_constant('WP_CONTENT_DIR');
$this->set( 'js', new Loco_mvc_ViewParams( [
'podata' => $data->jsonSerialize(),
'powrap' => (int) $settings->po_width,
'multipart' => (bool) $settings->ajax_files,
'locale' => $locale ? $locale->jsonSerialize() : null,
'source' => $source->jsonSerialize(),
'potpath' => $locale && $potfile ? $potfile->getRelativePath($wp_content) : null,
'syncmode' => $syncmode,
'popath' => $this->get('path'),
'readonly' => $readonly,
'project' => $project ? [
'bundle' => $bundle->getId(),
'domain' => $project->getId(),
] : null,
'nonces' => $this->getNonces($readonly),
'adminUrl' => Loco_mvc_AdminRouter::generate('loco'),
] ) );
$this->set( 'ui', new Loco_mvc_ViewParams( [
// Translators: button for adding a new string when manually editing a POT file
'add' => _x('Add','Editor','loco-translate'),
// Translators: button for removing a string when manually editing a POT file
'del' => _x('Remove','Editor','loco-translate'),
'help' => __('Help','loco-translate'),
// Translators: Button that saves translations to disk
'save' => _x('Save','Editor','loco-translate'),
// Translators: Button that runs in-editor sync/operation
'sync' => _x('Sync','Editor','loco-translate'),
// Translators: Button that reloads current screen
'revert' => _x('Revert','Editor','loco-translate'),
// Translators: Button that opens window for auto-translating
'auto' => _x('Auto','Editor','loco-translate'),
// Translators: Button that validates current translation formatting
'lint' => _x('Check','Editor','loco-translate'),
// Translators: Button for downloading a PO, MO or POT file
'download' => _x('Download','Editor','loco-translate'),
// Translators: Placeholder text for text filter above editor
'filter' => __('Filter translations','loco-translate'),
// Translators: Button that toggles invisible characters
'invs' => _x('Toggle invisibles','Editor','loco-translate'),
// Translators: Button that toggles between "code" and regular text editing modes
'code' => _x('Toggle code view','Editor','loco-translate'),
] ) );
// Download form params
$hidden = new Loco_mvc_HiddenFields( [
'route' => 'download',
'action' => 'loco_download',
'path' => '',
'source' => '',
] );
// zip archive will on;y be available if bundle is configured
if( $bundle && $project ){
$hidden['bundle'] = $bundle->getId();
$hidden['domain'] = $project->getId();
}
$this->set( 'dlFields', $hidden->setNonce('download') );
$this->set( 'dlAction', admin_url('admin-ajax.php','relative') );
// Remote file system required if file is not directly writable
$this->prepareFsConnect( 'update', $this->get('path') );
// ok to render editor as either po or pot
$tpl = $locale ? 'po' : 'pot';
$this->setFileTitle($file);
return $this->view( 'admin/file/edit-'.$tpl, [] );
}
}

View File

@@ -0,0 +1,168 @@
<?php
/**
* Controller to edit PO file header values and sync settings
*/
class Loco_admin_file_HeadController extends Loco_admin_file_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$file = $this->get('file');
/* @var Loco_fs_File $file */
if( $file->exists() && ! $file->isDirectory() ){
// nonce action will be specific to file for extra security
$path = $file->getPath();
$action = 'head:'.$path;
// set up view now in case of late failure
$fields = new Loco_mvc_HiddenFields( [] );
$fields->setNonce( $action );
$fields['auth'] = 'update';
$fields['path'] = $this->get('path');
$this->set('hidden',$fields );
// attempt update if valid nonce posted back
while( $this->checkNonce($action) ) {
$data = Loco_gettext_Data::load($file);
// check some headers prior ro updating
$head = $data->getHeaders();
$plurals = $head['Plural-Forms'];
// in advanced mode we will set all headers from form as-is.
$post = Loco_mvc_PostParams::get();
if( $post->has('headers') ){
$raw = (array) $post->headers;
$head = new LocoPoHeaders($raw);
$data->setHeaders($head);
// modifying character encoding is not currently supported
if( 'UTF-8' !== $head->getCharset() ){
Loco_error_AdminNotices::warn('Loco Translate only supports UTF-8 encoded files');
$head['Content-Type'] = 'text/plain; charset=UTF-8';
}
}
// in basic mode we save PO sync settings only
else if( 'po' !== $file->extension() ){
throw new Exception( 'Sync settings apply to PO files only' );
}
else if( ! $post->has('conf') ){
throw new Exception( 'Unexpected post data' );
}
else {
$conf = new Loco_gettext_SyncOptions($head);
$raw = (array) $post->conf;
$conf->setTemplate( $raw['template'] );
$mode = isset($raw['mode']) ? $raw['mode'] : 'pot';
if( isset($raw['json']) ){
$mode .= ',json';
}
$conf->setSyncMode($mode);
}
// Validate and remove redundant headers
$conf = new Loco_gettext_SyncOptions($head);
$head = $conf->getHeaders();
// Render PO without modifying sort order
if( $file instanceof Loco_fs_LocaleFile && $file->getLocale()->isValid() ){
$compiler = new Loco_gettext_Compiler($file);
$compiler->writePo($data);
// If we save the PO we should recompile MO, but only actually required if plural forms have changed
if( $head['Plural-Forms'] !== $plurals ){
$compiler->writeMo($data);
}
}
// else save just the actual file. Probably .pot, or wrongly named .po file.
else {
$api = new Loco_api_WordPressFileSystem;
$api->authorizeUpdate($file);
$file->putContents( $data->msgcat() );
}
// flash message for display after redirect
try {
// translators: Success notice where %s is a file extension, e.g. "PO"
Loco_data_Session::get()->flash('success',sprintf( __('%s file saved','loco-translate'), strtoupper($file->extension()) ));
Loco_data_Session::close();
}
catch( Exception $e ){
// tolerate session failure
}
if( wp_redirect($_SERVER['REQUEST_URI']) ){
exit;
}
break;
}
}
$bundle = $this->getBundle();
$this->set('title', 'Configure '.$file->basename().' &lsaquo; '.$bundle->getName() );
}
/**
* {@inheritdoc}
*/
public function render(){
$file = $this->get('file');
$fail = $this->getFileError($file);
if( is_string($fail) && '' !== $fail ){
return $fail;
}
// parse PO header
$head = Loco_gettext_Data::head($file);
$this->set('head',$head);
// Remote file system required if file is not directly writable
$this->prepareFsConnect( 'update', $this->get('path') );
$this->enqueueScript('head');
// localized files only can have sync settings
$localized = $file instanceof Loco_fs_LocaleFile && $file->getLocale()->isValid();
// Advanced mode shows all headers in one form
$this->setFileTitle($file);
if( $this->get('advanced') || ! $localized ){
return $this->view('admin/file/head');
}
// link to advanced mode and display sync settings form (PO only)
$this->set('advanced', $_SERVER['REQUEST_URI'].'&advanced=1' );
$conf = new Loco_gettext_SyncOptions($head);
$this->set('conf', $conf );
// perform some basic validation of sync mode
if( $conf->hasTemplate() ){
$potfile = $conf->getTemplate();
}
else {
$potfile = new Loco_fs_LocaleFile( (string) $this->getProject()->getPot() );
}
if( $conf->mergeMsgstr() && 'po' !== $potfile->extension() ){
Loco_error_AdminNotices::warn('Copying translations requires template is a PO file');
}
if( $conf->mergeJson() && ! $potfile->getLocale()->isValid() ){
Loco_error_AdminNotices::warn('Merging JSON files requires template has a localized file path');
}
// may or may not already have a custom template with a known locale
$this->set('potName','--');
if( $conf->hasTemplate() ){
$file = $conf->getTemplate();
$file->normalize( $this->getBundle()->getDirectoryPath() );
if( ! $file->exists() ){
Loco_error_AdminNotices::warn('Configured template does not currently exist');
}
$this->set('potName', $file->basename() );
}
// force basic POT mode for unconfigured templates
else if( '' === $conf->getSyncMode() ){
$conf->setSyncMode('pot');
}
return $this->view('admin/file/conf');
}
}

View File

@@ -0,0 +1,218 @@
<?php
/**
* File info / management view.
*/
class Loco_admin_file_InfoController extends Loco_admin_file_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$this->enqueueStyle('fileinfo');
//
$file = $this->get('file');
$bundle = $this->getBundle();
$this->set('title', $file->basename().' &lsaquo; '.$bundle->getName() );
}
/**
* {@inheritdoc}
*/
public function getHelpTabs(){
return [
__('Overview','loco-translate') => $this->viewSnippet('tab-file-info'),
];
}
/**
* {@inheritdoc}
*/
public function render(){
/* @var Loco_fs_LocaleFile $file */
$file = $this->get('file');
$this->setFileTitle($file);
if( $fail = $this->getFileError($file) ){
return $fail;
}
// file info
$ext = strtolower( $file->extension() );
$finfo = Loco_mvc_FileParams::create( $file );
$this->set('file', $finfo );
$finfo['type'] = strtoupper($ext);
if( $file->exists() ){
$finfo['existent'] = true;
$finfo['writable'] = $file->writable();
$finfo['deletable'] = $file->deletable();
$finfo['mtime'] = $file->modified();
// Notify if file is managed by WordPress
$api = new Loco_api_WordPressFileSystem;
if( $api->isAutoUpdatable($file) ){
$finfo['autoupdate'] = true;
}
}
// location info
$dir = new Loco_fs_LocaleDirectory( $file->dirname() );
$dinfo = Loco_mvc_FileParams::create( $dir );
$this->set('dir', $dinfo );
$dinfo['type'] = $dir->getTypeId();
if( $dir->exists() && $dir->isDirectory() ){
$dinfo['existent'] = true;
$dinfo['writable'] = $dir->writable();
}
// secure download link
$args = new Loco_mvc_HiddenFields( [
'route' => 'download',
'action' => 'loco_download',
'path' => $file->getRelativePath(loco_constant('WP_CONTENT_DIR')),
] );
$args->setNonce('download');
$finfo['download'] = $args->getHref( admin_url('admin-ajax.php','relative') );
// allow link to modify headers/settings
$finfo['configure'] = str_replace('file-info','file-head',$_SERVER['REQUEST_URI']);
// collect note worthy problems with file headers
$debugging = loco_debugging();
$debug = [];
// get the name of the web server for information purposes
$this->set('httpd', Loco_compat_PosixExtension::getHttpdUser() );
// unknown file template if required
$locale = null;
$project = null;
$tpl = 'admin/file/info-other';
// we should know the project the file belongs to, but permitting orphans for debugging
try {
$project = $this->getProject();
$template = $project->getPot();
$isTemplate = $template && $file->equal($template);
$this->set('isTemplate', $isTemplate );
$this->set('project', $project );
}
catch( Loco_error_Exception $e ){
$debug[] = $e->getMessage();
$isTemplate = false;
$template = null;
}
// file will be Gettext most likely
if( 'pot' === $ext || 'po' === $ext || 'mo' === $ext ){
// treat as template until locale verified
$tpl = 'admin/file/info-pot';
// don't attempt to pull locale of template file
if( 'pot' !== $ext && ! $isTemplate ){
$locale = $file->getLocale();
if( $locale->isValid() ){
// find PO/MO counter parts
if( 'po' === $ext ){
$tpl = 'admin/file/info-po';
$sibling = $file->cloneExtension('mo');
}
else {
$tpl = 'admin/file/info-mo';
$sibling = $file->cloneExtension('po');
}
$info = Loco_mvc_FileParams::create($sibling);
$this->set( 'sibling', $info );
if( $sibling->exists() ){
$info['existent'] = true;
$info['writable'] = $sibling->writable();
}
}
}
// Do full parse to get stats and headers
try {
$data = Loco_gettext_Data::load($file);
$head = $data->getHeaders();
$author = $head->trimmed('Last-Translator') or $author = __('Unknown author','loco-translate');
$this->set( 'author', $author );
// date headers may not be same as file modification time (files copied to server etc..)
$podate = $head->trimmed( $locale ? 'PO-Revision-Date' : 'POT-Creation-Date' );
$potime = Loco_gettext_Data::parseDate($podate) or $potime = $file->modified();
$this->set('potime', $potime );
// access to meta stats, normally cached on listing pages
$meta = Loco_gettext_Metadata::create($file,$data);
$this->set( 'meta', $meta );
// allow PO header to specify alternative template for sync
$opts = new Loco_gettext_SyncOptions($head);
if( $opts->hasTemplate() ){
$altpot = $opts->getTemplate();
$altpot->normalize( $this->getBundle()->getDirectoryPath() );
if( $altpot->exists() && ( ! $template || ! $template->equal($altpot) ) ){
$template = $altpot;
}
}
// establish whether PO is in sync with POT
if( $template && ! $isTemplate && 'po' === $ext && $template->exists() ){
try {
$this->set('potfile', new Loco_mvc_FileParams( [
'synced' => Loco_gettext_Data::load($template)->equalSource($data),
], $template ) );
}
catch( Exception $e ){
// ignore invalid template in this context
}
}
if( $debugging ){
// missing or invalid headers are tolerated but developers should be notified
if( ! count($head) ){
$debug[] = __('File does not have a valid header','loco-translate');
}
// Language header sanity checks, raising developer (debug) warnings
if( $locale ){
$value = $head->trimmed('Language');
if( '' !== $value ){
if( Loco_Locale::parse($value)->__toString() !== $locale->__toString() ){
// translators: Warning that the language in the file header (1) does not match the file name (2)
$debug[]= sprintf( __('Language header is "%1$s" but file name contains "%2$s"','loco-translate'), $value, $locale );
}
}
$value = $head->trimmed('Plural-Forms');
if( '' !== $value ){
try {
$locale->setPluralFormsHeader($value);
}
catch( InvalidArgumentException $e ){
$debug[] = sprintf('Plural-Forms header is invalid, "%s"',$value);
}
}
}
// Other sanity checks
if( $project && $head->has('Project-Id-Version') ){
$inProj = $project->getName();
$inHead = $head->trimmed('Project-Id-Version');
if( false === strpos($inProj,$inHead) && false === strpos($inHead,$inProj) ) {
$debug[] = sprintf( 'Project-Id-Version header is "%s" but project is "%s"', $inHead, $inProj );
}
}
}
// Count source text for templates only (assumed English)
if( 'admin/file/info-pot' === $tpl ){
$counter = new Loco_gettext_WordCount($data);
$this->set('words', $counter->count() );
}
}
catch( Loco_error_Exception $e ){
$this->set('error', $e->getMessage() );
$tpl = 'admin/file/info-other';
}
}
if( $debugging && $debug ){
$this->set( 'debug', new Loco_mvc_ViewParams($debug) );
}
return $this->view( $tpl );
}
}

View File

@@ -0,0 +1,198 @@
<?php
/**
* Translation set relocation tool.
* Moves PO/MO pair and all related files to a new location
*/
class Loco_admin_file_MoveController extends Loco_admin_file_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$file = $this->get('file');
/* @var Loco_fs_File $file */
if( $file->exists() && ! $file->isDirectory() ){
$files = new Loco_fs_Siblings($file);
$files->setDomain( $this->getDomain() );
// nonce action will be specific to file for extra security
$path = $file->getPath();
$action = 'move:'.$path;
// set up view now in case of late failure
$fields = new Loco_mvc_HiddenFields( [] );
$fields->setNonce( $action );
$fields['auth'] = 'move';
$fields['path'] = $this->get('path');
$this->set('hidden',$fields );
// attempt move if valid nonce posted back
while( $this->checkNonce($action) ){
$post = Loco_mvc_PostParams::get();
// Chosen location should be valid as a posted "dest" parameter
if( ! $post->has('dest') ){
Loco_error_AdminNotices::err('No destination posted');
break;
}
$target = new Loco_fs_LocaleFile( $post->dest );
$ext = $target->extension();
// could be a directory when we wanted the full path to the file
if( $target->isDirectory() ){
Loco_error_AdminNotices::err('Enter the full path to the .'.$file->extension().' file, not the directory');
break;
}
// primary file extension should only be permitted to change between po and pot
if( $ext !== $file->extension() && 'po' !== $ext && 'pot' !== $ext ){
Loco_error_AdminNotices::err('Invalid file extension, .po or .pot only');
break;
}
$target->normalize( loco_constant('WP_CONTENT_DIR') );
$target_dir = $target->getParent()->getPath();
// Primary file gives template remapping, so all files are renamed with same stub.
// this can only be one of three things: (en -> en) or (foo-en -> en) or (en -> foo-en)
// suffix will then consist of file extension, plus any other stuff like backup file date.
$target_base = $target->filename();
$source_snip = strlen( $file->filename() );
// buffer all files to move to preempt write failures
$movable = [];
$api = new Loco_api_WordPressFileSystem;
foreach( $files->expand() as $source ){
$suffix = substr( $source->basename(), $source_snip ); // <- e.g. "-backup.po~"
$target = new Loco_fs_File( $target_dir.'/'.$target_base.$suffix );
// permit valid change of file extension on primary source file (po/pot)
if( $source === $files->getSource() && $target->extension() !== $ext ){
$target = $target->cloneExtension($ext);
}
if( ! $api->authorizeMove($source,$target) ) {
Loco_error_AdminNotices::err('Failed to authorize relocation of '.$source->basename() );
break 2;
}
$movable[] = [$source,$target];
}
// commit moves. If any fail we'll have separated the files, which is bad
$count = 0;
$total = count($movable);
foreach( $movable as $pair ){
try {
$pair[0]->move( $pair[1] );
$count++;
}
catch( Loco_error_Exception $e ){
Loco_error_AdminNotices::add($e);
}
}
// flash messages for display after redirect
try {
if( $count ) {
// translators: %s is the quantity of files which were successfully moved
Loco_data_Session::get()->flash( 'success', sprintf( _n( '%s file moved', '%s files moved', $total, 'loco-translate' ), $total ) );
}
if( $total > $count ){
$diff = $total - $count;
// translators: %s is the quantity of files which failed to be moved
Loco_data_Session::get()->flash( 'error', sprintf( _n( '%s file could not be moved', '%s files could not be moved', $diff, 'loco-translate' ), $diff ) );
}
Loco_data_Session::close();
}
catch( Exception $e ){
// tolerate session failure
}
// redirect to bundle overview
$href = Loco_mvc_AdminRouter::generate( $this->get('type').'-view', [ 'bundle' => $this->get('bundle') ] );
if( wp_redirect($href) ){
exit;
}
// end pseudo loop
break;
}
}
// set page title before render sets inline title
$bundle = $this->getBundle();
// translators: Page title where %s is the name of a file to be moved
$this->set('title', sprintf( __('Move %s','loco-translate'), $file->basename() ).' &lsaquo; '.$bundle->getName() );
}
/**
* {@inheritdoc}
*/
public function render(){
$file = $this->get('file');
if( $fail = $this->getFileError($file) ){
return $fail;
}
// relocation requires knowing text domain and locale
$files = new Loco_fs_Siblings($file);
try {
$project = $this->getProject();
$files->setDomain( $project->getDomain()->getName() );
}
catch( Loco_error_Exception $e ){
Loco_error_AdminNotices::warn($e->getMessage());
$project = null;
}
$file = new Loco_fs_LocaleFile( $files->getSource() );
$locale = $file->getLocale();
// switch between canonical move and custom file path mode
$custom = is_null($project) || $this->get('custom') || 'po' !== $file->extension() || ! $locale->isValid();
// common page elements:
$this->set('files',$files->expand() );
// phpcs:ignore -- duplicate string
$this->setFileTitle($file,__('Move %s','loco-translate'));
$this->enqueueScript('move');
// set info for existing file location
$content_dir = loco_constant('WP_CONTENT_DIR');
$current = $file->getRelativePath($content_dir);
$parent = new Loco_fs_LocaleDirectory( $file->dirname() );
$typeId = $parent->getTypeId();
$this->set('current', new Loco_mvc_ViewParams([
'path' => $parent->getRelativePath($content_dir),
'type' => $parent->getTypeLabel($typeId),
]) );
// moving files will require deletion permission on current file location
// plus write permission on target location, but we don't know what that is yet.
$fields = $this->prepareFsConnect('move',$current);
$fields['path'] = '';
$fields['dest'] = '';
// custom file move template (POT mode)
if( $custom ){
$this->get('hidden')->offsetSet('custom','1');
$this->set('file', Loco_mvc_FileParams::create($file) );
return $this->view('admin/file/move-pot');
}
// establish valid locations for translation set, which may include current:
$filechoice = $project->initLocaleFiles($locale);
// start with current location so always first in list
$locations = [];
$locations[$typeId] = new Loco_mvc_ViewParams( [
'label' => $parent->getTypeLabel($typeId),
'paths' => [ new Loco_mvc_ViewParams( [
'path' => $current,
'active' => true,
] ) ]
] );
/* @var Loco_fs_File $pofile */
foreach( $filechoice as $pofile ){
$relpath = $pofile->getRelativePath($content_dir);
if( $current === $relpath ){
continue;
}
// initialize location type (system, etc..)
$parent = new Loco_fs_LocaleDirectory( $pofile->dirname() );
$typeId = $parent->getTypeId();
if( ! isset($locations[$typeId]) ){
$locations[$typeId] = new Loco_mvc_ViewParams( [
'label' => $parent->getTypeLabel($typeId),
'paths' => [],
] );
}
$choice = new Loco_mvc_ViewParams( [
'path' => $relpath,
] );
$locations[$typeId]['paths'][] = $choice;
}
$this->set('locations', $locations );
$this->set('advanced', $_SERVER['REQUEST_URI'].'&custom=1' );
return $this->view('admin/file/move-po');
}
}

View File

@@ -0,0 +1,125 @@
<?php
/**
* File view / source formatted view.
*/
class Loco_admin_file_ViewController extends Loco_admin_file_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$this->enqueueStyle('poview');
//
$file = $this->get('file');
$bundle = $this->getBundle();
$this->set( 'title', 'Source of '.$file->basename().' &lsaquo; '.$bundle->getName() );
}
/**
* {@inheritdoc}
*/
public function getHelpTabs(){
return [
__('Overview') => $this->viewSnippet('tab-file-view'),
];
}
private function getUtf8Source( Loco_fs_File $file, LocoPoHeaders $head ){
$src = $file->getContents();
try {
$src = loco_remove_bom( $src, $cs );
if( '' === $cs ){
$cs = $head->getCharset();
}
if( '' !== $cs ){
$src = loco_convert_utf8($src,$cs,false);
}
}
catch ( Exception $e ){
Loco_error_AdminNotices::debug( $e->getMessage() );
}
return $src;
}
/**
* {@inheritdoc}
*/
public function render(){
// file must exist for editing
/* @var Loco_fs_File $file */
$file = $this->get('file');
$this->setFileTitle($file);
$type = strtolower( $file->extension() );
if( $fail = $this->getFileError($file) ){
return $fail;
}
// Establish if file belongs to a configured project
$bundle = null;
$project = null;
try {
$bundle = $this->getBundle();
$project = $this->getProject();
}
catch( Exception $e ){
Loco_error_AdminNotices::debug( $e->getMessage() );
}
// Parse data before rendering, so we know it's a valid Gettext format
try {
$this->set('modified', $file->modified() );
$data = Loco_gettext_Data::load( $file );
}
catch( Loco_error_ParseException $e ){
Loco_error_AdminNotices::add( Loco_error_Exception::convert($e) );
$data = Loco_gettext_Data::dummy();
}
$this->set( 'meta', Loco_gettext_Metadata::create($file, $data) );
// binary MO will be hex-formatted in template
if( 'mo' === $type ){
$this->set('bin', $file->getContents() );
return $this->view('admin/file/view-mo' );
}
// l10n.php files are unlikely to be encountered without a po or mo, but still..
if( 'php'=== $type ){
return $this->view('admin/file/view-php', ['phps'=>$file->getContents()] );
}
// else is a PO or POT file
$this->enqueueScript('poview');//->enqueueScript('min/highlight');
$lines = preg_split('/\\n|\\r\\n?/', $this->getUtf8Source( $file, $data->getHeaders() ) );
$this->set( 'lines', $lines );
// ajax parameters required for pulling reference sources
$this->set('js', new Loco_mvc_ViewParams( [
'popath' => $this->get('path'),
'nonces' => [
'fsReference' => wp_create_nonce('fsReference'),
],
'project' => $bundle ? [
'bundle' => $bundle->getId(),
'domain' => $project ? $project->getId() : '',
] : null,
] ) );
// treat as PO if file name has locale
if( $this->getLocale() ){
return $this->view('admin/file/view-po' );
}
// else view as POT
return $this->view('admin/file/view-pot' );
}
}

View File

@@ -0,0 +1,408 @@
<?php
/**
* pre-msginit function. Prepares arguments for creating a new PO language file
*/
class Loco_admin_init_InitPoController extends Loco_admin_bundle_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$this->enqueueStyle('poinit');
//
$bundle = $this->getBundle();
$this->set('title', __('New language','loco-translate').' &lsaquo; '.$bundle );
}
/**
* {@inheritdoc}
*/
public function getHelpTabs(){
return [
__('Overview') => $this->viewSnippet('tab-init-po'),
];
}
/**
* Sort to the left the best option for saving new translation files.
* This favours custom locations, with system secondary. No other locations will be pre-selected.
* @param Loco_mvc_ViewParams[] $choices
* @return Loco_mvc_ViewParams|null
*/
private function sortPreferred( array $choices ){
usort( $choices, [__CLASS__,'_onSortPreferred'] );
foreach( $choices as $choice ){
if( $choice['disabled'] || $choice['copying'] || $choice['exists']){
continue;
}
// avoid promoting the Author location
$t = $choice['dirtype'];
if( 'theme' === $t || 'plugin' === $t){
continue;
}
// pre-select this. It should be a writable custom or system file.
return $choice;
}
return null;
}
/**
* @internal
* @return int
*/
public static function _onSortPreferred( Loco_mvc_ViewParams $a, Loco_mvc_ViewParams $b ){
$x = self::scoreFileChoice($a);
$y = self::scoreFileChoice($b);
return $x === $y ? 0 : ( $x > $y ? -1 : 1 );
}
/**
* Score an individual file choice for sorting preferred
* @return int
*/
private static function scoreFileChoice( Loco_mvc_ViewParams $p ){
$score = 0;
if( $p['writable'] ){
$score++;
}
if( $p['disabled'] ){
$score -= 2;
}
if( $p['systype'] ){
$score--;
}
return $score;
}
/**
* @internal
* @param int $a
* @param int $b
* @return int
*/
private static function compareLocationKeys( $a, $b ){
static $order = ['custom' => 4, 'wplang' => 3, 'theme' => 2, 'plugin' => 2, 'other' => 1 ];
$x = $order[$a];
$y = $order[$b];
return $x === $y ? 0 : ( $x > $y ? -1 : 1 );
}
/**
* {@inheritdoc}
*/
public function render(){
$breadcrumb = $this->prepareNavigation();
// "new" tab is confusing when no project-scope navigation
// $this->get('tabs')->add( __('New PO','loco-translate'), '', true );
// bundle mandatory, but project optional
$bundle = $this->getBundle();
try {
$project = $this->getProject();
$slug = $project->getSlug();
$domain = (string) $project->getDomain();
// translators: %s refers to the slug/handle of a theme or plugin
$subhead = sprintf( __('Initializing new translations in "%s"','loco-translate'), $slug?:$domain );
}
catch( Loco_error_Exception $e ){
$project = null;
$subhead = __('Initializing new translations in unknown set','loco-translate');
}
$title = __('New language','loco-translate');
$this->set('subhead', $subhead );
// navigate up to bundle listing page
$breadcrumb->add( $title );
$this->set( 'breadcrumb', $breadcrumb );
// default locale is a placeholder
$localeTag = '';
$locale = new Loco_Locale('zxx');
$content_dir = untrailingslashit( loco_constant('WP_CONTENT_DIR') );
$extracting = (bool) $this->get('extract');
$copying = false;
$sourcedir = '';
// Permit using any provided file a template instead of POT
if( $potpath = $this->get('path') ){
$potfile = new Loco_fs_LocaleFile($potpath);
$potfile->normalize($content_dir);
if( ! $potfile->exists() ){
throw new Loco_error_Exception('Forced template argument must exist');
}
$copying = true;
$sourcedir = $potfile->dirname();
// forced source could be a POT (although UI would normally prevent it)
if( $potfile->getSuffix() ){
$locale = $potfile->getLocale();
if( $locale->isValid() ){
$this->set('sourceLocale', $locale );
$localeTag = (string) $locale;
}
}
}
// else project not configured. UI should prevent this by not offering msginit
else if( ! $project ){
throw new Loco_error_Exception('Cannot add new language to unconfigured set');
}
// else POT file may or may not be known, and may or may not exist
else {
$potfile = $project->getPot();
}
$locales = [];
$installed = [];
$api = new Loco_api_WordPressTranslations;
$prefs = Loco_data_Preferences::get();
// pull installed list first, this will include en_US and any non-standard languages installed
foreach( $api->getInstalledCore() as $tag ){
$tmp = Loco_Locale::parse($tag);
if( $tmp->isValid() && $prefs->has_locale($tmp) ){
$tag = (string) $tmp;
// We may not have names for these, so just the language tag will show
$installed[$tag] = new Loco_mvc_ViewParams( [
'value' => $tag,
'icon' => $tmp->getIcon(),
'label' => $tmp->ensureName($api),
'selected' => $tag === $localeTag ? ' selected' : ''
] );
}
}
// pull the same list of "available" languages as used in WordPress settings
foreach( $api->getAvailableCore() as $tag => $tmp ){
if( ! array_key_exists($tag,$installed) && $prefs->has_locale($tmp) ){
$locales[$tag] = new Loco_mvc_ViewParams( [
'value' => $tag,
'icon' => $tmp->getIcon(),
'label' => $tmp->ensureName($api),
'selected' => $tag === $localeTag ? ' selected' : ''
] );
}
}
// two locale lists built for "installed" and "available" dropdowns. Else populate custom field.
$this->set('locales', $locales );
$this->set('installed', $installed );
if( $localeTag && ! array_key_exists($localeTag,$installed) && ! array_key_exists($localeTag,$locales) ){
$this->set('custom', $localeTag );
}
// Critical that user selects the correct save location:
if( $project ){
$filechoice = $project->initLocaleFiles( $locale );
}
// without configured project we will only allow save to same location
else {
$filechoice = new Loco_fs_FileList;
}
// Define suitable prompts for extracting from source, creating a template, or (preferred) copy an existing PO.
$prompts = [];
if( ! $copying ){
$args = array_intersect_key( $_GET,['bundle'=>'','domain'=>'']);
// link to extract a new template if none exists
$prompts['xgettext'] = new Loco_mvc_ViewParams( [
'link' => Loco_mvc_AdminRouter::generate( $this->get('type' ).'-xgettext', $args ),
'text' => __('Create template','loco-translate'),
] );
// link to direct extraction here, if not already doing
$prompts['skip'] = new Loco_mvc_ViewParams( [
'link' => Loco_mvc_AdminRouter::generate( $this->get('_route'), $args + ['extract'=>'1'] ),
'text' => __('Skip template','loco-translate'),
] );
// link to copy an existing PO file, if there's an installed one
/* @var Loco_fs_LocaleFile $pofile */
foreach( $project->findLocaleFiles('po') as $pofile ){
$dir = new Loco_fs_LocaleDirectory( $pofile->dirname() );
if( 'wplang' !== $dir->getTypeId() ){
continue;
}
// we don't know yet what locale the user will select, so pick first PO.
$args = array_intersect_key( $_GET,['bundle'=>'','domain'=>'']);
$args['path'] = $pofile->getRelativePath($content_dir);
$prompts['copy'] = new Loco_mvc_FileParams( [
'link' => Loco_mvc_AdminRouter::generate( $this->get('type' ).'-msginit', $args ),
'text' => __('Copy PO file','loco-translate'),
], $pofile );
break;
}
}
// show information about POT file if we are initializing from template
if( $potfile && $potfile->exists() ){
$meta = Loco_gettext_Metadata::load($potfile);
$total = $meta->getTotal();
// translators: 1: Number of strings; 2: Name of POT file; e.g. "100 strings found in file.pot"
$summary = sprintf( _n('%1$s string found in %2$s','%1$s strings found in %2$s',$total,'loco-translate'), number_format($total), $potfile->basename() );
$this->set( 'pot', new Loco_mvc_ViewParams( [
'name' => $potfile->basename(),
'path' => $meta->getPath(false),
] ) );
// if copying an existing PO file, we can fairly safely establish the correct prefixing
if( $copying ){
$prefix = $potfile->getPrefix();
$poname = $prefix ? sprintf('%s-%s.po',$prefix,$locale) : sprintf('%s.po',$locale);
$pofile = new Loco_fs_LocaleFile( $poname );
$pofile->normalize( $potfile->dirname() );
$filechoice->add( $pofile );
}
}
// else not initializing from template- prompt to copy existing PO file
else if( array_key_exists('copy',$prompts) && ! $extracting ){
$this->set('copy', $prompts['copy'] );
$this->set('skip', $prompts['skip'] );
$this->set('ext', $prompts['xgettext'] );
return $this->view('admin/init/init-copy');
}
// else prompt to extract from source (advanced).
else if( 2 > Loco_data_Settings::get()->pot_expected ){
// if allowing source extraction without warning show brief description of source files
if( $extracting || 0 === Loco_data_Settings::get()->pot_expected ){
// Tokenizer required for string extraction
if( ! loco_check_extension('tokenizer') ){
return $this->view('admin/errors/no-tokenizer');
}
$nfiles = count( $project->findSourceFiles() );
// translators: Were %s is number of source files that will be scanned
$summary = sprintf( _n('%s source file will be scanned for translatable strings','%s source files will be scanned for translatable strings',$nfiles,'loco-translate'), number_format_i18n($nfiles) );
$extracting = true;
}
// else prompt for template creation before continuing
else {
// POT could still be defined, it might just not exist yet
if( $potfile ){
$this->set('pot', Loco_mvc_FileParams::create($potfile) );
}
// else offer assignment of a new file
else {
$this->set( 'conf', new Loco_mvc_ViewParams( [
'link' => Loco_mvc_AdminRouter::generate( $this->get('type').'-conf', array_intersect_key($_GET,['bundle'=>'']) ),
'text' => __('Assign template','loco-translate'),
] ) );
}
$this->set('ext', $prompts['xgettext'] );
$this->set('skip', $prompts['skip'] );
return $this->view('admin/init/init-prompt');
}
}
else {
throw new Loco_error_Exception('Plugin settings disallow missing templates');
}
$this->set( 'summary', $summary );
// group established locations into types (official, etc..)
// there is no point checking whether any of these file exist, because we don't know what language will be chosen yet.
$sortable = [];
$locations = [];
$fs_failure = null;
/* @var Loco_fs_LocaleFile $pofile */
foreach( $filechoice as $pofile ){
$parent = new Loco_fs_LocaleDirectory( $pofile->dirname() );
$systype = $parent->getUpdateType();
$typeId = $parent->getTypeId();
if( ! isset($locations[$typeId]) ){
$locations[$typeId] = new Loco_mvc_ViewParams( [
'label' => $parent->getTypeLabel( $typeId ),
'paths' => [],
] );
}
// folder may be unwritable (requiring connect to create file) or may be denied under security settings
try {
$context = $parent->getWriteContext()->authorize();
$writable = $context->writable();
$disabled = false;
}
catch( Loco_error_WriteException $e ){
$fs_failure = $e->getMessage();
$writable = false;
$disabled = true;
}
$suffix = $pofile->getSuffix().'.po';
$choice = new Loco_mvc_ViewParams( [
'checked' => '',
'writable' => $writable,
'disabled' => $disabled,
'systype' => $systype,
'dirtype' => $typeId,
'parent' => Loco_mvc_FileParams::create($parent),
'hidden' => $pofile->getRelativePath($content_dir),
'holder' => str_replace( $suffix, '<span>{locale}</span>.po', $pofile->basename() ),
'copying' => $sourcedir === $parent->getPath(),
'exists' => $copying && $pofile->exists(),
] );
$sortable[] = $choice;
$locations[$typeId]['paths'][] = $choice;
}
// display locations in runtime preference order
uksort( $locations, [__CLASS__,'compareLocationKeys'] );
$this->set( 'locations', $locations );
// pre-select best option, excluding the current source if copying
$preferred = $this->sortPreferred( $sortable );
if( $preferred instanceof ArrayAccess ){
$preferred['checked'] = 'checked';
}
// else show total lock message. probably file mods disallowed
else if( $fs_failure ){
$this->set('fsLocked', $fs_failure );
}
// hidden fields to pass through to Ajax endpoint
$this->set('hidden', new Loco_mvc_HiddenFields( [
'action' => 'loco_json',
'route' => 'msginit',
'loco-nonce' => $this->setNonce('msginit')->value,
'type' => $bundle->getType(),
'bundle' => $bundle->getHandle(),
'domain' => $project ? $project->getId() : '',
'source' => $potpath,
] ) );
$this->set('help', new Loco_mvc_ViewParams( [
'href' => apply_filters('loco_external','https://localise.biz/wordpress/plugin/manual/msginit'),
'text' => __("What's this?",'loco-translate'),
] ) );
// add a prompt if there's a better option for creating a new PO
if( ! $copying ){
if( array_key_exists('copy',$prompts) ){
$prompt = $prompts['copy'];
// translators: %s will be replaced with a PO file name
$prompt['text'] = sprintf( __('Copy %s instead','loco-translate'), $prompt->__get('name') );
}
else {
$prompt = $prompts['xgettext'];
$prompt['text'] = __('Create template instead','loco-translate');
}
if( $extracting ){
$prompt['title'] = __("You're creating translations directly from source code",'loco-translate');
$this->set('prompt',$prompt);
}
else if( array_key_exists('copy',$prompts) ){
$prompt['title'] = __("You're creating translations from a POT file",'loco-translate');
$this->set('prompt',$prompt);
}
}
// file system prompts will be handled when paths are selected (i.e. we don't have one yet)
$this->prepareFsConnect( 'create', '' );
$this->enqueueScript('poinit');
return $this->view('admin/init/init-po');
}
}

View File

@@ -0,0 +1,140 @@
<?php
/**
* pre-xgettext function. Initializes a new PO file for a given locale
*/
class Loco_admin_init_InitPotController extends Loco_admin_bundle_BaseController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$this->enqueueStyle('poinit');
//
$bundle = $this->getBundle();
$this->set('title', __('New template','loco-translate').' &lsaquo; '.$bundle );
}
/**
* {@inheritdoc}
*/
public function getHelpTabs(){
return [
__('Overview') => $this->viewSnippet('tab-init-pot'),
];
}
/**
* {@inheritdoc}
*/
public function render(){
$breadcrumb = $this->prepareNavigation();
// "new" tab is confusing when no project-scope navigation
// $this->get('tabs')->add( __('New POT','loco-translate'), '', true );
$bundle = $this->getBundle();
$project = $this->getProject();
$domain = (string) $project->getDomain();
$this->set('domain', $domain );
// Tokenizer required for string extraction
if( ! loco_check_extension('tokenizer') ){
return $this->view('admin/errors/no-tokenizer');
}
// Establish default POT path whether it exists or not
$pot = $project->getPot();
// POT should actually not exist at this stage. It should be edited instead.
if( $pot->exists() ){
throw new Loco_error_Exception( __('Template file already exists','loco-translate') );
}
// Bundle may deliberately lock template to avoid end-user tampering
// it makes little sense to do so when template doesn't exist, but we will honour the setting anyway.
if( $project->isPotLocked() ){
throw new Loco_error_Exception('Template is protected from updates by the bundle configuration');
}
// Just warn if POT writing will fail when saved, but still show screen
$dir = $pot->getParent();
// Avoiding full source scan until actioned, but calculate size to manage expectations
$bytes = 0;
$nfiles = 0;
$nskip = 0;
$largest = 0;
$sources = $project->findSourceFiles();
// skip files larger than configured maximum
$opts = Loco_data_Settings::get();
$max = wp_convert_hr_to_bytes( $opts->max_php_size );
/* @var $sourceFile Loco_fs_File */
foreach( $sources as $sourceFile ){
$nfiles++;
$fsize = $sourceFile->size();
$largest = max( $largest, $fsize );
if( $fsize > $max ){
$nskip += 1;
// uncomment to log which files are too large to be scanned
// Loco_error_AdminNotices::debug( sprintf('%s is %s',$sourceFile,Loco_mvc_FileParams::renderBytes($fsize)) );
}
else {
$bytes += $fsize;
}
}
$this->set( 'scan', new Loco_mvc_ViewParams( [
'bytes' => $bytes,
'count' => $nfiles,
'skip' => $nskip,
'size' => Loco_mvc_FileParams::renderBytes($bytes),
'large' => Loco_mvc_FileParams::renderBytes($max),
'largest' => Loco_mvc_FileParams::renderBytes($largest),
] ) );
// file metadata
$this->set('pot', Loco_mvc_FileParams::create( $pot ) );
$this->set('dir', Loco_mvc_FileParams::create( $dir ) );
$title = __('New template file','loco-translate');
// translators: %s refers to the name of a translation set (theme, plugin or core component)
$subhead = sprintf( __('New translations template for "%s"','loco-translate'), $project );
$this->set('subhead', $subhead );
// navigate up to bundle listing page
$breadcrumb->add( $title );
$this->set( 'breadcrumb', $breadcrumb );
// ajax service takes the target directory path
$content_dir = loco_constant('WP_CONTENT_DIR');
$target_path = $pot->getParent()->getRelativePath($content_dir);
// hidden fields to pass through to Ajax endpoint
$this->set( 'hidden', new Loco_mvc_ViewParams( [
'action' => 'loco_json',
'route' => 'xgettext',
'loco-nonce' => $this->setNonce('xgettext')->value,
'type' => $bundle->getType(),
'bundle' => $bundle->getHandle(),
'domain' => $project->getId(),
'path' => $target_path,
'name' => $pot->basename(),
] ) );
// File system connect required if location not writable
$relpath = $pot->getRelativePath($content_dir);
$this->prepareFsConnect('create', $relpath );
$this->enqueueScript('potinit');
return $this->view( 'admin/init/init-pot' );
}
}

View File

@@ -0,0 +1,88 @@
<?php
/**
* File upload initializer.
* Uploads a PO file to the bundle and compiles MO.
*/
class Loco_admin_init_UploadController extends Loco_admin_bundle_BaseController {
/**
* {@inheritdoc}
**/
public function init() {
parent::init();
// Use Ajax controller for standard postback
if( $this->checkNonce('upload') ){
try {
$ctrl = new Loco_ajax_UploadController;
$ctrl->_init($_POST);
$href = $ctrl->process( Loco_mvc_PostParams::get() );
if( wp_redirect($href) ){
exit;
}
}
catch( Exception $e ){
Loco_error_AdminNotices::add( Loco_error_Exception::convert($e) );
}
}
// Set page title before render sets inline title
$bundle = $this->getBundle();
$this->set('title', __('Upload','loco-translate').' &lsaquo; '.$bundle->getName() );
}
/**
* {@inheritdoc}
*/
public function render(){
// file upload requires a properly configured project
$bundle = $this->getBundle();
$project = $this->getProject();
$fields = new Loco_mvc_HiddenFields( [
'path' => '',
'auth' => 'upload',
'type' => $bundle->getType(),
'domain' => $project->getId(),
'bundle' => $bundle->getHandle(),
] );
$fields->setNonce('upload');
$this->set('hidden',$fields);
$this->prepareFsConnect('upload','');
// standard bundle navigation with link back to overview
$breadcrumb = $this->prepareNavigation();
$breadcrumb->add( __('Upload a translation file','loco-translate') );
$this->set( 'breadcrumb', $breadcrumb );
// we won't know the locale until the file is uploaded, so use a dummy for location choice
$locale = new Loco_Locale('zxx');
$filechoice = $this->getProject()->initLocaleFiles($locale);
//
$locations = [];
/* @var Loco_fs_LocaleFile $pofile */
foreach( $filechoice as $pofile ){
// initialize location type (system, etc..)
$parent = new Loco_fs_LocaleDirectory( $pofile->dirname() );
$typeId = $parent->getTypeId();
if( ! isset($locations[$typeId]) ){
$locations[$typeId] = new Loco_mvc_ViewParams( [
'label' => $parent->getTypeLabel($typeId),
'paths' => [],
] );
}
$locations[$typeId]['paths'][] = new Loco_mvc_ViewParams( [
'parent' => Loco_mvc_FileParams::create($parent),
'holder' => str_replace('zxx.po','{locale}</span>.po', $pofile->basename() ),
] );
}
// we don't know what the specifics will be until a location is chosen and a file is presented.
$this->set('locale',get_locale());
$this->set('locations', $locations );
// file upload will be done via ajax if possible
$settings = Loco_data_Settings::get();
$this->set('js',new Loco_mvc_ViewParams( [
'multipart' => (bool) $settings->ajax_files,
'nonces' => [ 'upload' => $fields->getNonce() ],
] ) );
$this->enqueueScript('upload');
return $this->view('admin/init/upload');
}
}

View File

@@ -0,0 +1,81 @@
<?php
/**
* Common controller for listing of all bundle types
*/
abstract class Loco_admin_list_BaseController extends Loco_mvc_AdminController {
private $bundles = [];
/**
* Build renderable bundle variables
* @param Loco_package_Bundle
* @return Loco_mvc_ViewParams
*/
protected function bundleParam( Loco_package_Bundle $bundle ){
$handle = $bundle->getHandle();
$default = $bundle->getDefaultProject();
return new Loco_mvc_ViewParams( [
'id' => $bundle->getId(),
'name' => $bundle->getName(),
'dflt' => $default ? $default->getDomain() : '--',
'size' => count( $bundle ),
'save' => $bundle->isConfigured(),
'type' => $type = strtolower( $bundle->getType() ),
'view' => Loco_mvc_AdminRouter::generate( $type.'-view', [ 'bundle' => $handle ] ),
'time' => $bundle->getLastUpdated(),
] );
}
/**
* Add bundle to enabled or disabled list, depending on whether it is configured
* @param Loco_package_Bundle
*/
protected function addBundle( Loco_package_Bundle $bundle ){
$this->bundles[] = $this->bundleParam($bundle);
}
/**
* {@inheritdoc}
*/
public function getHelpTabs(){
return [
__('Overview','loco-translate') => $this->viewSnippet('tab-list-bundles'),
];
}
/**
* {@inheritdoc}
*/
public function render(){
// breadcrumb is just the root
$here = new Loco_admin_Navigation( [
new Loco_mvc_ViewParams( [ 'name' => $this->get('title') ] ),
] );
/*/ tab between the types of bundles
$types = array (
'' => __('Home','loco-translate'),
'theme' => __('Themes','loco-translate'),
'plugin' => __('Plugins','loco-translate'),
);
$current = $this->get('_route');
$tabs = new Loco_admin_Navigation;
foreach( $types as $type => $name ){
$href = Loco_mvc_AdminRouter::generate($type);
$tabs->add( $name, $href, $type === $current );
}
*/
return $this->view( 'admin/list/bundles', [
'bundles' => $this->bundles,
'breadcrumb' => $here,
] );
}
}

View File

@@ -0,0 +1,16 @@
<?php
/**
* Dummy controller skips "core" list view, rendering the core projects directly as a single bundle.
* Route: loco-core -> loco-core-view
*/
class Loco_admin_list_CoreController extends Loco_admin_RedirectController {
/**
* {@inheritdoc}
*/
public function getLocation(){
return Loco_mvc_AdminRouter::generate('core-view');
}
}

View File

@@ -0,0 +1,118 @@
<?php
/**
* Lists all installed locales.
* WordPress decides what is "installed" based on presence of core translation files
*/
class Loco_admin_list_LocalesController extends Loco_mvc_AdminController {
/**
* {@inheritdoc}
*/
public function init(){
parent::init();
$this->enqueueStyle('locale');
}
/**
* {@inheritdoc}
*/
public function getHelpTabs(){
return [
__('Overview','loco-translate') => $this->viewSnippet('tab-list-locales'),
];
}
/**
* {@inheritdoc}
*/
public function render(){
$this->set( 'title', __( 'Installed languages', 'loco-translate' ) );
$used = [];
$locales = [];
$api = new Loco_api_WordPressTranslations;
$active = get_locale();
// list which sites have each language as their WPLANG setting
if( $multisite = is_multisite() ){
$this->set('multisite',true);
/* @var WP_Site $site */
foreach( get_sites() as $site ){
$id = (int) $site->blog_id;
$tag = get_blog_option( $id, 'WPLANG') or $tag = 'en_US';
$name = get_blog_option( $id, 'blogname' );
$used[$tag][] = $name;
}
}
// else single site shows tick instead of site name
else {
$used[$active][] = '✓';
}
// add installed languages to file crawler
$finder = new Loco_package_Locale;
// Pull "installed" languages (including en_US)
foreach( $api->getInstalledCore() as $tag ){
$locale = Loco_Locale::parse($tag);
if( $locale->isValid() ){
$tag = (string) $locale;
$finder->addLocale($locale);
$args = [ 'locale' => $tag ];
$locales[$tag] = new Loco_mvc_ViewParams( [
'nfiles' => 0,
'time' => 0,
'lcode' => $tag,
'lname' => $locale->ensureName($api),
'lattr' => 'class="'.$locale->getIcon().'" lang="'.$locale->lang.'"',
'href' => Loco_mvc_AdminRouter::generate('lang-view',$args),
'used' => isset($used[$tag]) ? implode( ', ', $used[$tag] ) : ( $multisite ? '--' : '' ),
'active' => $active === $tag,
] );
}
}
// Count up unique PO files
foreach( $finder->findLocaleFiles() as $file ){
if( preg_match('/(?:^|-)([_a-zA-Z]+).po$/', $file->basename(), $r ) ){
$locale = Loco_Locale::parse($r[1]);
if( $locale->isValid() ){
$tag = (string) $locale;
if( array_key_exists($tag,$locales) ){
$locales[$tag]['nfiles']++;
$locales[$tag]['time'] = max( $locales[$tag]['time'], $file->modified() );
}
else {
Loco_error_Debug::trace('%s found on disk, but not an installed language',$tag);
}
}
}
}
// POT files are in en_US locale
$tag = 'en_US';
if( array_key_exists($tag,$locales) ){
foreach( $finder->findTemplateFiles() as $file ){
$locales[$tag]['nfiles']++;
$locales[$tag]['time'] = max( $locales[$tag]['time'], $file->modified() );
}
}
// sort alphabetically by locale label
usort( $locales, function( ArrayAccess $a, ArrayAccess $b ):int {
return strcasecmp( $a['lname'], $b['lname'] );
} );
$this->set('locales', $locales );
return $this->view( 'admin/list/locales' );
}
}

View File

@@ -0,0 +1,31 @@
<?php
/**
* List all bundles of type "plugin"
* Route: loco-plugin
*/
class Loco_admin_list_PluginsController extends Loco_admin_list_BaseController {
public function render(){
$this->set( 'type', 'plugin' );
$this->set( 'title', __( 'Translate plugins', 'loco-translate' ) );
foreach( Loco_package_Plugin::get_plugins() as $handle => $data ){
try {
$bundle = Loco_package_Plugin::create( $handle );
$this->addBundle($bundle);
}
// @codeCoverageIgnoreStart
catch( Exception $e ){
$bundle = new Loco_package_Plugin( $handle, $handle );
$this->addBundle( $bundle );
}
// @codeCoverageIgnoreEnd
}
return parent::render();
}
}

View File

@@ -0,0 +1,24 @@
<?php
/**
* List all bundles of type "theme"
* Route: loco-theme
*/
class Loco_admin_list_ThemesController extends Loco_admin_list_BaseController {
public function render(){
$this->set('type', 'theme' );
$this->set('title', __( 'Translate themes', 'loco-translate' ) );
foreach( Loco_package_Theme::getAll() as $bundle ){
$this->addBundle( $bundle );
}
return parent::render();
}
}