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,555 @@
<?php
/**
* Represents a WordPress locale
*
* @property string $lang
* @property string $region
* @property string $variant
*/
class Loco_Locale implements JsonSerializable {
/**
* Language subtags
* @var string[]
*/
private array $tag;
/**
* Cached composite tag
*/
private ?string $_tag = null;
/**
* Cached icon css class
*/
private ?string $icon = null;
/**
* Name in English
*/
private string $name = '';
/**
* Name in language of self
*/
private ?string $_name = null;
/**
* Plural equation expressed in terms of "n"
*/
private string $pluraleq;
/**
* Cache of plural forms mapped optionally to CLDR mnemonic tags
*/
private ?array $plurals = null;
/**
* Validity cache
*/
private ?bool $valid = null;
/**
* @param string $tag Full language tag
*/
public static function parse( string $tag ):self {
$locale = new Loco_Locale('');
try {
$locale->setSubtags( loco_parse_wp_locale($tag) );
}
catch( Exception $e ){
// isValid should return false
}
do_action( 'loco_parse_locale', $locale, $tag );
return $locale;
}
/**
* Construct from subtags NOT from composite tag. See self::parse
* Note that this skips normalization and validation steps
*/
public function __construct( string $lang = '', string $region = '', string $variant = '' ){
if( 1 == func_num_args() && isset($lang[3]) ){
throw new BadMethodCallException('Did you mean Loco_Locale::parse('.var_export($lang,1).') ?');
}
$this->tag = compact('lang','region','variant');
}
/**
* Allow read access to subtags
* @internal
* @param string $t subtag
* @return string
*/
public function __get( $t ){
return $this->tag[ $t ] ?? '';
}
/**
* Allow write access to subtags
* @internal
* @param string $t subtag, e.g. "lang"
* @param string $s subtag value, e.g. "en"
* @return void
*/
public function __set( $t, $s ){
if( isset($this->tag[$t]) ){
$this->tag[$t] = $s;
$this->setSubtags( $this->tag );
}
}
/**
* Set subtags as produced from loco_parse_wp_locale
* @param string[] $tag
*/
public function setSubtags( array $tag ):self {
$this->valid = false;
$default = [ 'lang' => '', 'region' => '', 'variant' => '' ];
// disallow setting of unsupported tags
if( $bad = array_diff_key($tag, $default) ){
throw new Loco_error_LocaleException('Unsupported subtags: '.implode(',',$bad) );
}
$tag += $default;
// language tag is minimum requirement
if( ! $tag['lang'] ){
throw new Loco_error_LocaleException('Locale must have a language');
}
// no UN codes in WordPress
if( preg_match('/^\\d+$/',$tag['region']) ){
throw new Loco_error_LocaleException('Numeric regions not supported');
}
// non-standard variant code. e.g. formal/informal
if( is_array($tag['variant']) ){
$tag['variant'] = implode('_',$tag['variant']);
}
// normalize case
$tag['lang'] = strtolower($tag['lang']);
$tag['region'] = strtoupper($tag['region']);
$tag['variant'] = strtolower($tag['variant']);
// set subtags and invalidate cache of language tag
$this->tag = $tag;
$this->_tag = null;
$this->icon = null;
$this->valid = true;
return $this;
}
/**
* Ensure correct casing of subtags
*/
public function normalize():self {
try {
$this->setSubtags( $this->tag );
}
catch( Loco_error_LocaleException $e ){
$this->_tag = '';
$this->icon = null;
$this->name = 'Invalid locale';
$this->_name = null;
}
return $this;
}
/**
* @return string
*/
public function __toString(){
$str = $this->_tag;
if( is_null($str) ){
$str = implode('_',array_filter($this->tag));
$this->_tag = $str;
}
return $str;
}
/**
* @param bool $translate whether to get name in current display language
*/
public function getName( bool $translate = true ):?string{
$name = $this->name;
// use canonical native name only when current language matches
// deliberately not matching whole tag such that fr_CA would show native name of fr_FR
if( $translate ){
$locale = self::parse( function_exists('get_user_locale') ? get_user_locale() : get_locale() );
if( $this->lang === $locale->lang && $this->_name ){
$name = $this->_name;
}
/*/ Note that no dynamic translation of English name is performed, but can be filtered with loco_parse_locale
else {
$name = __($name,'loco-translate-languages');
}*/
}
if( is_string($name) && '' !== $name ){
return $name;
}
return null;
}
/**
* Get canonical native name as defined by WordPress
*/
public function getNativeName():?string {
$name = $this->_name;
if( is_string($name) && '' !== $name ){
return $name;
}
return null;
}
/**
* Get CSS class for locale icon
*/
public function getIcon(): ?string {
$icon = $this->icon;
if( is_null($icon) ){
$tag = [];
if( ! $this->tag['lang'] ){
$tag[] = 'lang lang-zxx';
}
foreach( $this->tag as $class => $code ){
if( $code ){
$tag[] = $class.' '.$class.'-'.$code;
}
}
$icon = strtolower( implode(' ',$tag) );
$this->icon = $icon;
}
return $icon;
}
/**
* Force custom icon, or reset. Used in tests.
*/
public function setIcon( string $css ):self {
$this->icon = $css ?: null;
return $this;
}
/**
* Set custom locale name, and optional translation
*/
public function setName( string $english_name, string $native_name = '' ):self {
$this->name = apply_filters('loco_locale_name', $english_name, $native_name );
$this->_name = $native_name ?: null;
return $this;
}
/**
* Test whether locale is valid
*/
public function isValid():bool {
if( is_null($this->valid) ){
$this->normalize();
}
return $this->valid;
}
/**
* Resolve this locale's "official" name from WordPress's translation api
* @return string English name currently set
*/
public function fetchName( Loco_api_WordPressTranslations $api ): ?string {
$tag = (string) $this;
// pull from WordPress translations API if network allowed
$locale = $api->getLocale($tag);
if( $locale ){
$this->setName( $locale->getName(false), $locale->getNativeName() );
}
return $this->getName(false);
}
/**
* Resolve this locale's name from compiled Loco data
* @return string English name currently set
*/
public function buildName(): ?string {
// should at least have a language or not valid
if( $this->isValid() ){
$code = $this->tag['lang'];
$db = Loco_data_CompiledData::get('languages');
if( $name = $db[$code] ){
// if variant is present add only that in brackets (no lookup required)
if( $code = $this->tag['variant'] ){
$name .= ' ('.ucfirst($code).')';
}
// else add region in brackets if present
else if( $code = $this->tag['region'] ){
$db = Loco_data_CompiledData::get('regions');
if( $extra = $db[$code] ){
$name .= ' ('.$extra.')';
}
else {
$name .= ' ('.$code.')';
}
}
$this->setName( $name );
}
}
else {
$this->setName( __('Invalid locale','loco-translate') );
}
return $this->getName();
}
/**
* Ensure locale has a label, even if it has to fall back to language code or error
*/
public function ensureName( Loco_api_WordPressTranslations $api ):string {
$name = $this->getName();
if( ! $name ){
$name = $this->fetchName($api);
// failing that, build own name from components
if( ! $name ){
$name = $this->buildName();
// last resort, use tag as name
if( ! $name ){
$name = (string) $this;
$this->setName( $name );
}
}
}
return $name;
}
#[ReturnTypeWillChange]
public function jsonSerialize():array{
$a = $this->tag;
$a['label'] = $this->getName();
// plural data expected by editor
$p = $this->getPluralData();
$a['pluraleq'] = $p[0];
$a['nplurals'] = count($p[1]);
$a['plurals'] = $this->getPluralForms();
// tone setting may be used by some external translation providers
$a['tone'] = $this->getFormality();
return $a;
}
private function getPluralData():array {
if( is_null($this->plurals) ){
$lc = strtolower($this->lang);
$db = Loco_data_CompiledData::get('plurals');
$id = $lc && isset($db[$lc]) ? $db[$lc] : 0;
list( $this->pluraleq, $this->plurals ) = $db[''][$id];
}
return [ $this->pluraleq, $this->plurals ];
}
/**
* Get translated plural form labels
* @return string[]
*/
public function getPluralForms(): array {
list( , $plurals ) = $this->getPluralData();
$nplurals = count($plurals);
// Languages with no plural forms, where n always yields 0. The UI doesn't show a label for this.
if( 1 === $nplurals ){
return [ 'All' ];
}
// Germanic plurals can show singular/plural as per source string text boxes
// Note that french style plurals include n=0 under the "Single", but we will show "Single (0,1)"
if( 2 === $nplurals ){
$l10n = [
'one' => _x('Single','Editor','loco-translate'),
'other' => _x('Plural',"Editor",'loco-translate'),
];
}
// else translate all implemented plural forms and show sample numbers if useful:
// for meaning of categories, see http://cldr.unicode.org/index/cldr-spec/plural-rules
else {
$l10n = [
// Translators: Plural category for zero quantity
'zero' => _x('Zero','Plural category','loco-translate'),
// Translators: Plural category for singular quantity
'one' => _x('One','Plural category','loco-translate'),
// Translators: Plural category used in some multi-plural languages
'two' => _x('Two','Plural category','loco-translate'),
// Translators: Plural category used in some multi-plural languages
'few' => _x('Few','Plural category','loco-translate'),
// Translators: Plural category used in some multi-plural languages
'many' => _x('Many','Plural category','loco-translate'),
// Translators: General plural category not covered by other forms
'other' => _x('Other','Plural category','loco-translate'),
];
}
// process labels to be shown in editor tab, appending sample values of `n` if useful
$labels = [];
foreach( $plurals as $sample => $tag ){
if( is_int($sample) ){
$sample = sprintf('%u',$sample);
}
// if CLDR tag is to be used we'll need to translate it
if( array_key_exists($tag,$l10n) ){
$name = $l10n[$tag];
}
else {
$name = $tag;
}
// show just samples if no name
if( '' === $name ){
$labels[] = $sample;
}
// show just name if label is numeric, or samples are redundant
else if(
preg_match('/\\d/',$name) ||
( 'one' === $tag && '1' === $sample ) ||
( 'two' === $tag && '2' === $sample ) ||
( 'zero' === $tag && '0' === $sample ) ||
( 'other' === $tag && 2 === $nplurals )
){
$labels[] = $name;
}
// else both - most common for standard CLDR forms
else {
$labels[] = sprintf('%s (%s)', $name, $sample );
}
}
return $labels;
}
/**
* Get PO style Plural-Forms header value comprising number of forms and integer equation for n
*/
public function getPluralFormsHeader(): string {
list( $equation, $forms ) = $this->getPluralData();
return sprintf('nplurals=%u; plural=%s;', count($forms), $equation );
}
/**
* Apply PO style Plural-Forms header.
* @param string $str header value e.g. "nplurals=2; plural=n != 1;"
* @return void
*/
public function setPluralFormsHeader( string $str ){
if( ! preg_match('#^nplurals=(\\d);\\s*plural=([-+/*%!=<>|&?:()n\\d ]+);?$#', $str, $match ) ){
throw new InvalidArgumentException('Invalid Plural-Forms header, '.json_encode($str) );
}
$nplurals = (int) $match[1];
$pluraleq = trim( $match[2],' ');
// single form requires no further inspection
if( 2 > $nplurals ){
$this->pluraleq = '0';
$this->plurals = ['other'];
return;
}
// Override new equation in all cases
$previous = $this->getPluralData()[0];
$this->pluraleq = $pluraleq;
// quit asap if plural forms being set aren't changing anything
if( $nplurals === count($this->plurals) && self::hashPlural($previous) === self::hashPlural($pluraleq) ){
return;
}
// compile sample keys as per built-in CLDR rule for this language
$keys = [];
$formula = new Plural_Forms($pluraleq);
$ns = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,20,21,22,30,31,32,100,101,102,103,104,111,200,201,202,301,302];
for( $i = 0; $i < $nplurals; $i++ ){
$sample = [];
$suffix = '';
foreach( $ns as $j => $n ){
if( is_null($n) || $formula->execute($n) !== $i ){
continue;
}
$ns[$j] = null;
if( array_key_exists(2,$sample) ){
$suffix = "\xE2\x80\xA6";
break;
}
else {
$sample[] = $n;
}
}
$keys[] = implode(',',$sample).$suffix;
}
// cast to string for comparison due to PHP forcing integer keys in this->plurals
$expect = implode('|',$keys);
$actual = implode('|',array_keys($this->plurals));
// use mnemonic tags only if they match the default (CLDR) tags for the current language
if( $expect !== $actual ){
// exception when two forms only and the first accepts n=1 and second n=2
if( 2 === $nplurals && 0 === $formula->execute(1) && 1 === $formula->execute(2) ){
$tags = ['one','other'];
}
// blanking CLDR tags means only samples will be used as labels
else {
$tags = array_fill(0,$nplurals,'');
// Translators: Shown when a PO file's Plural-Forms header has a different formula from the Unicode CLDR rules
Loco_error_AdminNotices::info( __('Plural forms differ from Loco Translate\'s built in rules for this language','loco-translate') );
}
// set new plural forms
$this->plurals = array_combine($keys,$tags);
}
}
/**
* Crude normalizer for a plural equation such that similar formulae can be compared.
* @param string $str original plural equation
* @return string signature for comparison
*/
private static function hashPlural( string $str ):string {
return trim( str_replace([' ','<>'],['','!='],$str), '()' );
}
/**
* Get formality setting, whether implied or explicit.
* @return string either "", "formal" or "informal"
*/
public function getFormality():string {
$value = '';
$tag = $this->__toString();
$variant = $this->variant;
if( '' === $variant ){
// if a formal variant exists, tone may be implied informal
$d = Loco_data_CompiledData::get('locales');
if( $d->offsetExists($tag.'_formal') ){
if( ! $d->offsetExists($tag.'_informal') ) {
$value = 'informal';
}
}
// if an informal variant exists, tone may be implied formal
else if( $d->offsetExists($tag.'_informal') ){
if( ! $d->offsetExists($tag.'_formal') ) {
$value = 'formal';
}
}
}
else if( 'formal' === $variant || 'informal' === $variant ){
$value = $variant;
}
return apply_filters('loco_locale_formality',$value,$tag);
}
}
// Depends on compiled library
if( ! function_exists('loco_parse_wp_locale') ){
loco_require_lib('compiled/locales.php');
}

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();
}
}

View File

@@ -0,0 +1,106 @@
<?php
/**
* Ajax "apis" route, for handing off Ajax requests to hooked API integrations.
*/
class Loco_ajax_ApisController extends Loco_mvc_AjaxController {
/**
* {@inheritdoc}
*/
public function render(){
$post = $this->validate();
// Fire an event so translation apis can register their hooks as lazily as possible
do_action('loco_api_ajax');
// Get request renders API modal contents:
if( 0 === $post->count() ){
$apis = Loco_api_Providers::configured();
$this->set('apis',$apis);
// modal views for batch-translate and suggest feature
$modal = new Loco_mvc_View;
$modal->set('apis',$apis);
// help buttons
$locale = $this->get('locale');
$modal->set( 'help', new Loco_mvc_ViewParams( [
'text' => __('Help','loco-translate'),
'href' => apply_filters('loco_external','https://localise.biz/wordpress/plugin/manual/providers'),
] ) );
$modal->set('prof', new Loco_mvc_ViewParams( [
'text' => __('Need a human?','loco-translate'),
'href' => apply_filters('loco_external','https://localise.biz/wordpress/translation?l='.$locale),
] ) );
// render auto-translate modal or prompt for configuration
if( $apis ){
$html = $modal->render('ajax/modal-apis-batch');
}
else {
$html = $modal->render('ajax/modal-apis-empty');
}
$this->set('html',$html);
return parent::render();
}
// else API client id should be posted to perform operation
$hook = (string) $post->hook;
// API client must be hooked in using loco_api_providers filter
$config = null;
foreach( Loco_api_Providers::export() as $candidate ){
if( is_array($candidate) && array_key_exists('id',$candidate) && $candidate['id'] === $hook ){
$config = $candidate;
break;
}
}
if( is_null($config) ){
throw new Loco_error_Exception('API not registered: '.$hook );
}
// Get input texts to translate via registered hook. shouldn't be posted if empty.
$sources = $post->sources;
if( ! is_array($sources) || ! $sources ){
throw new Loco_error_Exception('Empty sources posted to '.$hook.' hook');
}
// The front end sends translations detected as HTML separately. This is to support common external apis.
$config['type'] = $post->type;
// We need a locale too, which should be valid as it's the same one loaded into the front end.
$locale = Loco_Locale::parse( (string) $post->locale );
if( ! $locale->isValid() ){
throw new Loco_error_Exception('Invalid locale');
}
// Check if hook is registered
// This is effectively a filter whereby the returned array should be a translation of the input array
$action = 'loco_api_translate_'.$hook;
if( has_filter($action) ){
$targets = apply_filters( $action, [], $sources, $locale, $config );
}
// Use built-in translation vendors if the unique hook isn't registered.
else {
$vendor = $config['vendor'] ?? $hook;
if( 'deepl' === $vendor ){
$targets = Loco_api_DeepL::process( $sources, $locale, $config );
}
else if( Loco_api_ChatGpt::supports($vendor) ){
$targets = Loco_api_ChatGpt::process( $sources, $locale, $config+['vendor'=>$hook] );
}
else {
throw new Loco_error_Exception('API not hooked. Use `add_filter('.var_export($action,1).',...)`');
}
}
// a mid-batch failure that doesn't through an exception might throw the count off
if( count($targets) !== count($sources) ){
$name = $config['name'] ?? $hook;
Loco_error_AdminNotices::warn( sprintf('%s: Got %u translations for %u source strings', $name, count($targets), count($sources) ) );
}
// Response data doesn't need anything except the translations
$this->set('targets',$targets);
return parent::render();
}
}

View File

@@ -0,0 +1,58 @@
<?php
/**
* Ajax "diff" route, for rendering PO/POT file diffs
*/
class Loco_ajax_DiffController extends Loco_mvc_AjaxController {
/**
* {@inheritdoc}
*/
public function render(){
$post = $this->validate();
// require x2 valid files for diffing
if( ! $post->lhs || ! $post->rhs ){
throw new InvalidArgumentException('Path parameters required');
}
$dir = loco_constant('WP_CONTENT_DIR');
$lhs = new Loco_fs_File( $post->lhs ); $lhs->normalize($dir);
$rhs = new Loco_fs_File( $post->rhs ); $rhs->normalize($dir);
// avoid diffing non Gettext source files
$exts = array_flip( [ 'pot', 'pot~', 'po', 'po~' ] );
/* @var $file Loco_fs_File */
foreach( [$lhs,$rhs] as $file ){
if( ! $file->exists() ){
throw new InvalidArgumentException('File paths must exist');
}
if( ! $file->underContentDirectory() ){
throw new InvalidArgumentException('Files must be under '.basename($dir) );
}
$ext = $file->extension();
if( ! isset($exts[$ext]) ){
throw new InvalidArgumentException('Disallowed file extension');
}
}
// OK to diff files as HTML table
$renderer = new Loco_output_DiffRenderer;
$emptysrc = $renderer->_startDiff().$renderer->_endDiff();
$tablesrc = $renderer->renderFiles( $rhs, $lhs );
if( $tablesrc === $emptysrc ){
// translators: Where %s is a file name
$message = __('Revisions are identical, you can delete %s','loco-translate');
$this->set( 'error', sprintf( $message, $rhs->basename() ) );
}
else {
$this->set( 'html', $tablesrc );
}
return parent::render();
}
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* Downloads a bundle configuration as XML or Json
*/
class Loco_ajax_DownloadConfController extends Loco_ajax_common_BundleController {
/**
* {@inheritdoc}
*/
public function render(){
$this->validate();
$bundle = $this->getBundle();
$file = new Loco_fs_File( $this->get('path') );
// Download actual loco.xml file if bundle is configured from it
if( 'file' === $bundle->isConfigured() && 'xml' === $file->extension() ){
$file->normalize( $bundle->getDirectoryPath() );
if( $file->readable() ){
return $file->getContents();
}
}
// else render temporary config file
$writer = new Loco_config_BundleWriter($bundle);
switch( $file->extension() ){
case 'xml':
return $writer->toXml();
case 'json':
return json_encode( $writer->jsonSerialize() );
}
// @codeCoverageIgnoreStart
throw new Loco_error_Exception('Specify either XML or JSON file path');
}
}

View File

@@ -0,0 +1,106 @@
<?php
/**
* Ajax "download" route, for outputting raw gettext file contents.
*/
class Loco_ajax_DownloadController extends Loco_ajax_common_BundleController {
/**
* @return string
*/
private function renderArchive( $path ){
$zipfile = new Loco_fs_File($path);
$pofile = new Loco_fs_DummyFile( '/fake/'.$zipfile->filename().'.po');
// Resolving script refs requires configured project
$bundle = $this->getBundle();
$project = $this->getProject($bundle);
// Create a temporary file for zip, which must work on disk, not in memory
$path = wp_tempnam();
if( ! $path || ! file_exists($path) ){
throw new Loco_error_Exception('Failed to create temporary file for zip archive');
}
register_shutdown_function('unlink',$path);
// initialize zip
// TODO PHP 8.4 Using empty file as ZipArchive is deprecated
loco_check_extension('zip');
$z = new ZipArchive;
$z->open( $path, ZipArchive::CREATE);
$z->setArchiveComment( $bundle->getName() );
$post = Loco_mvc_PostParams::get();
$data = Loco_gettext_Data::fromSource($post->source);
$compiler = new Loco_gettext_Compiler($pofile);
/* @var Loco_fs_DummyFile $file */
foreach( $compiler->writeAll($data,$project) as $file ){
$z->addFromString( $file->basename(), $file->getContents() );
}
$z->close();
return file_get_contents($path);
}
/**
* {@inheritdoc}
*/
public function render(){
$post = $this->validate();
$path = $this->get('path');
// The UI now replaces .mo with .zip, but requires the ZipArchive extension is installed.
if( '.zip' === substr($path,-4) ){
return $this->renderArchive($path);
}
// Below is for direct .po/pot downloads, plus legacy .mo/l10n.php
// mo is only used when zip is not available. php works but not hooked into UI.
$file = new Loco_fs_File($path);
$file->normalize( loco_constant('WP_CONTENT_DIR') );
$ext = Loco_gettext_Data::ext($file);
// posted source must be clean and must parse as whatever the file extension claims to be
$raw = $post->source;
if( is_string($raw) && '' !== $raw ){
// compile source if target is MO
if( 'mo' === $ext ) {
$raw = Loco_gettext_Data::fromSource($raw)->msgfmt();
}
// supporting .l10n.php for WordPress >= 6.5
else if( 'php' === $ext && class_exists('WP_Translation_File_PHP',false) ){
$raw = Loco_gettext_PhpCache::render( Loco_gettext_Data::fromSource($raw) );
}
}
// else file can be output directly if it exists.
// note that files on disk will not be parsed or manipulated. they will download strictly as-is
else if( $file->exists() ){
$raw = $file->getContents();
}
// else we can't do anything except bail
else {
throw new Loco_error_Exception('File not found and no source posted');
}
// Observe UTF-8 BOM setting for PO and POT only
if( 'po' === $ext || 'pot' === $ext ){
$has_bom = "\xEF\xBB\xBF" === substr($raw,0,3);
$use_bom = (bool) Loco_data_Settings::get()->po_utf8_bom;
// only alter file if valid UTF-8. Deferring detection overhead until required
if( $has_bom !== $use_bom && preg_match('//u',$raw) ){
if( $use_bom ){
$raw = "\xEF\xBB\xBF".$raw; // prepend
}
else {
$raw = substr($raw,3); // strip bom
}
}
}
return $raw;
}
}

View File

@@ -0,0 +1,178 @@
<?php
/**
* Ajax service that provides remote server authentication for file system *write* operations
*/
class Loco_ajax_FsConnectController extends Loco_mvc_AjaxController {
/**
* @var Loco_api_WordPressFileSystem
*/
private $api;
/**
* @param Loco_fs_File existing file path (must exist)
* @return bool
*/
private function authorizeDelete( Loco_fs_File $file ){
$files = new Loco_fs_Siblings($file);
// require remote authentication if at least one dependant file is not deletable directly
foreach( $files->expand() as $file ){
if( ! $this->api->authorizeDelete($file) ){
return false;
}
}
// else no dependants failed deletable test
return true;
}
/**
* @param Loco_fs_File file being moved (must exist)
* @param Loco_fs_File target path (should not exist)
* @return bool
*/
private function authorizeMove( Loco_fs_File $source, ?Loco_fs_File $target = null ){
return $this->api->authorizeMove($source,$target);
}
/**
* @param Loco_fs_File $file new file path (should not exist)
* @return bool
*/
private function authorizeCreate( Loco_fs_File $file ){
return $this->api->authorizeCreate($file);
}
/**
* @param Loco_fs_File $file path to update (should exist)
* @return bool
*/
private function authorizeUpdate( Loco_fs_File $file ){
if( ! $this->api->authorizeUpdate($file) ){
return false;
}
// if backups are enabled, we need to be able to create new files too (i.e. update parent directory)
if( Loco_data_Settings::get()->num_backups && ! $this->api->authorizeCopy($file) ){
return false;
}
// updating file will also recompile binary, which may or may not exist
$files = new Loco_fs_Siblings($file);
$mofile = $files->getBinary();
if( $mofile && ! $this->api->authorizeSave($mofile) ){
return false;
}
// else no dependants to update
return true;
}
/**
* @param Loco_fs_File $file path which may exist (update it) or may not (create it)
* @return bool
*/
private function authorizeUpload( Loco_fs_File $file ){
if( $file->exists() ){
return $this->api->authorizeUpdate($file);
}
else {
return $this->api->authorizeCreate($file);
}
}
/**
* {@inheritdoc}
*/
public function render(){
// establish operation being authorized (create,delete,etc..)
$post = $this->validate();
$type = $post->auth;
$func = 'authorize'.ucfirst($type);
$auth = [ $this, $func ];
if( ! is_callable($auth) ){
throw new Loco_error_Exception('Unexpected file operation');
}
// all auth methods require at least one file argument
$file = new Loco_fs_File( $post->path );
$base = loco_constant('WP_CONTENT_DIR');
$file->normalize($base);
$args = [$file];
// some auth methods also require a destination/target (move,copy,etc..)
if( $dest = $post->dest ){
$file = new Loco_fs_File($dest);
$file->normalize($base);
$args[] = $file;
}
// call auth method and respond with status and prompt HTML if connect required
try {
$this->api = new Loco_api_WordPressFileSystem;
if( call_user_func_array($auth,$args) ){
$this->set( 'authed', true );
$this->set( 'valid', $this->api->getOutputCredentials() );
$this->set( 'creds', $this->api->getInputCredentials() );
$this->set( 'method', $this->api->getFileSystem()->method );
$this->set( 'success', __('Connected to remote file system','loco-translate') );
// warning when writing to this location is risky (overwrites during wp update)
if( Loco_data_Settings::get()->fs_protect && $file->getUpdateType() ){
if( 'create' === $type ){
$message = __('This file may be overwritten or deleted when you update WordPress','loco-translate');
}
else if( 'delete' === $type ){
$message = __('This directory is managed by WordPress, be careful what you delete','loco-translate');
}
else if( 'move' === $type ){
$message = __('This directory is managed by WordPress. Removed files may be restored during updates','loco-translate');
}
else {
$message = __('Changes to this file may be overwritten or deleted when you update WordPress','loco-translate');
}
$this->set('warning',$message);
}
}
else {
$this->set( 'authed', false );
// HTML form should be set when authorization failed
$html = $this->api->getForm();
if( '' === $html || ! is_string($html) ){
// this is the only non-error case where form will not be set.
if( 'direct' === loco_constant('FS_METHOD') ){
$html = 'Remote connections are prevented by your WordPress configuration. Direct access only.';
}
// else an unknown error occurred when fetching output from request_filesystem_credentials
else {
$html = 'Failed to get credentials form';
}
// displaying error after clicking "connect" to avoid unnecessary warnings when operation may not be required
$html = '<form><h2>Connection problem</h2><p>'.$html.'.</p></form>';
}
$this->set( 'prompt', $html );
// supporting text based on file operation type explains why auth is required
if( 'create' === $type ){
$message = __('Creating this file requires permission','loco-translate');
}
else if( 'delete' === $type ){
$message = __('Deleting this file requires permission','loco-translate');
}
else if( 'move' === $type ){
$message = __('This move operation requires permission','loco-translate');
}
else {
$message = __('Saving this file requires permission','loco-translate');
}
// message is printed before default text, so needs delimiting.
$this->set('message',$message.'.');
}
}
catch( Loco_error_WriteException $e ){
$this->set('authed', false );
$this->set('reason', $e->getMessage() );
}
return parent::render();
}
}

View File

@@ -0,0 +1,223 @@
<?php
/**
* Ajax service that returns source code for a given file system reference
* Currently this is only PHP, but could theoretically be any file type.
*/
class Loco_ajax_FsReferenceController extends Loco_ajax_common_BundleController {
private function getReferringFile():Loco_fs_File {
$popath = $this->get('path');
if( is_string($popath) && '' !== $popath ){
$pofile = new Loco_fs_File($popath);
$pofile->normalize( loco_constant('WP_CONTENT_DIR') );
if( $pofile->exists() ){
return $pofile;
}
}
throw new InvalidArgumentException('Existent referring file required to resolve reference');
}
private function findSourceFile( string $refpath ):Loco_fs_File {
// Reference may be resolvable via referencing PO file's location
// This also results in validation of referring file, so "path" must be real.
$pofile = $this->getReferringFile();
$search = new Loco_gettext_SearchPaths;
$search->init($pofile);
if( $srcfile = $search->match($refpath) ){
return $srcfile;
}
// check against PO file location when no search paths or search paths failed
$srcfile = new Loco_fs_File($refpath);
$srcfile->normalize( $pofile->dirname() );
if( $srcfile->exists() ){
return $srcfile;
}
// reference may be resolvable via known project roots
try {
$bundle = $this->getBundle();
// Loco extractions will always be relative to bundle root
$srcfile = new Loco_fs_File( $refpath );
$srcfile->normalize( $bundle->getDirectoryPath() );
if( $srcfile->exists() ){
return $srcfile;
}
// check relative to parent theme root
if( $bundle->isTheme() && ( $parent = $bundle->getParent() ) ){
$srcfile = new Loco_fs_File( $refpath );
$srcfile->normalize( $parent->getDirectoryPath() );
if( $srcfile->exists() ){
return $srcfile;
}
}
// final attempt - search all project source roots
// TODO is there too large a risk of false positives? especially with files like index.php
/* @var $root Loco_fs_Directory */
/*foreach( $this->getProject($bundle)->getConfiguredSources() as $root ){
if( $root->isDirectory() ){
$srcfile = new Loco_fs_File( $refpath );
$srcfile->normalize( $root->getPath() );
if( $srcfile->exists() ){
return $srcfile;
}
}
}*/
}
catch( Loco_error_Exception $e ){
// permitted for there to be no bundle or project when viewing orphaned file
}
throw new Loco_error_Exception( sprintf('Failed to find source file matching "%s"',$refpath) );
}
/**
* {@inheritdoc}
*/
public function render(){
$post = $this->validate();
// enforce code_view access setting before doing anything else
$conf = Loco_data_Settings::get();
$code_view = $conf->code_view;
if( 0 === $code_view ){
throw new InvalidArgumentException('Source code viewer is disabled');
}
if( 1 === $code_view && ! current_user_can('manage_options') ){
throw new InvalidArgumentException('Source code viewer requires administrator privileges');
}
// at the very least we need a reference to examine
if( ! $post->has('ref') ){
throw new InvalidArgumentException('ref parameter required');
}
// reference must parse as <path>:<line>
$refpath = $post->ref;
if( preg_match('/^(.+):(\\d+)$/', $refpath, $r ) ){
$refpath = $r[1];
$refline = (int) $r[2];
}
else {
$refline = 0;
}
// find file or fail
$srcfile = $this->findSourceFile($refpath);
// Search utility only checks that reference exists, not whether it's actually a file
if( $srcfile->isDirectory() ){
throw new InvalidArgumentException('File is a directory');
}
// validate allowed source file types, including custom aliases
$ext = strtolower( $srcfile->extension() );
$type = $conf->ext2type($ext,'none');
if( 'none' === $type ){
throw new InvalidArgumentException('File extension disallowed, '.$ext );
}
// Deny access to files outside wp-content and WordPress root, plus sensitive files in the root
if( 'wp-config.php' === $srcfile->basename() || ! ( $srcfile->underContentDirectory() || $srcfile->underWordPressDirectory() ) ){
throw new InvalidArgumentException('File access disallowed');
}
// source code will be HTML-tokenized into multiple lines
$code = [];
// tokenizers require gettext utilities, easiest just to ping the extraction library
if( ! class_exists('Loco_gettext_Extraction') ){
throw new RuntimeException('Failed to load tokenizers'); // @codeCoverageIgnore
}
$extractor = loco_wp_extractor($type,$ext);
// JSON is supported, but only if it parses as a valid i18n schema (e.g. blocks.json)
if( $extractor instanceof LocoWpJsonExtractor ){
$source = $srcfile->getContents();
$extractor->tokenize($source);
// No point highlighting this as blocks|theme.json usually have no line number.
foreach( preg_split( '/\\R/u',$source) as $line ){
$code[] = '<code>'.htmlentities($line,ENT_COMPAT,'UTF-8').'</code>';
}
}
// Else the file will be tokenized as JavaScript or PHP (including Twig and Blade)
else if( $srcfile->size() > wp_convert_hr_to_bytes($conf->max_php_size) ){
throw new Loco_error_Exception('File exceeds maximum setting of '.$conf->max_php_size);
}
else if( ! loco_check_extension('tokenizer') ){
throw new Loco_error_Exception('Cannot validate '.$type.' file without tokenizer extension');
}
// Else always validate that PHP/JS have translatable strings. Other code will be disallowed.
else {
$tokens = $extractor->tokenize( $srcfile->getContents() );
$strings = new LocoExtracted;
$strings->limit(1);
$extractor->extract( $strings, $tokens );
if( 0 === $strings->count() ){
throw new Loco_error_Exception('File access disallowed: No translatable strings found');
}
$thisline = 1;
$tokens->rewind();
$tokens->allow(T_WHITESPACE);
while( $tok = $tokens->advance() ){
if( is_array($tok) ){
[ $t, $str, $startline ] = $tok;
$clss = token_name($t);
// tokens can span multiple lines (whitespace/html/comments)
$lines = preg_split('/\\R/', $str );
}
else {
// scalar symbol will always start on the line that the previous token ended on
$clss = 'T_NONE';
$lines = [ $tok ];
$startline = $thisline;
}
// token can span multiple lines, so include only bytes on required line[s]
foreach( $lines as $i => $line ){
// pad missing lines. $code must be contiguous
$thisline = $startline + $i;
$j = $thisline - 1;
while( count($code) < $j ){
$code[] = '<code class="T_NONE"> </code>';
}
// append highlighted token to current line
$html = '<code class="'.$clss.'">'.htmlentities($line,ENT_COMPAT,'UTF-8').'</code>';
if( isset($code[$j]) ){
$code[$j] .= $html;
}
else {
$code[$j] = $html;
}
}
}
}
// empty source line is either an empty file, or a parsing error
if( [] === $code ){
throw new Loco_error_Exception( sprintf('Failed to produce any lines from %d bytes of %s source', $srcfile->size(), $type) );
}
// allow 0 line reference when line is unknown (e.g. block.json) else it must exist
if( $refline && ! isset($code[$refline-1]) ){
Loco_error_AdminNotices::debug( sprintf('Line %u not in source file', $refline) );
$refline = 1;
}
$this->set('type', $type );
$this->set('line', $refline );
$this->set('path', $srcfile->getRelativePath( loco_constant('WP_CONTENT_DIR') ) );
$this->set('code', $code );
return parent::render();
}
}

View File

@@ -0,0 +1,174 @@
<?php
/**
* Ajax "msginit" route, for initializing new translation files
*/
class Loco_ajax_MsginitController extends Loco_ajax_common_BundleController {
/**
* @return Loco_Locale
*/
private function getLocale(){
if( $this->get('use-selector') ){
$tag = $this->get('select-locale');
}
else {
$tag = $this->get('custom-locale');
}
$locale = Loco_Locale::parse($tag);
if( ! $locale->isValid() ){
throw new Loco_error_LocaleException('Invalid locale');
}
return $locale;
}
/**
* {@inheritdoc}
*/
public function render(){
$post = $this->validate();
$bundle = $this->getBundle();
$project = $this->getProject( $bundle );
$domain = (string) $project->getDomain();
$locale = $this->getLocale();
$suffix = (string) $locale;
// The front end posts a template path, so we must replace the actual locale code
$base = loco_constant('WP_CONTENT_DIR');
$path = $post->path[ $post['select-path'] ];
// The request_filesystem_credentials function will try to access the "path" field later
$_POST['path'] = $path;
$pofile = new Loco_fs_LocaleFile( $path );
if( 'po' !== $pofile->fullExtension() ){
throw new Loco_error_Exception('Disallowed file extension');
}
if( $suffix !== $pofile->getSuffix() ){
$pofile = $pofile->cloneLocale( $locale );
if( $suffix !== $pofile->getSuffix() ){
throw new Loco_error_Exception('Failed to suffix file path with locale code');
}
}
// target PO should not exist yet
$pofile->normalize( $base );
$api = new Loco_api_WordPressFileSystem;
$api->authorizeCreate( $pofile );
// Target MO probably doesn't exist, but we don't want to overwrite it without asking
$mofile = $pofile->cloneExtension('mo');
if( $mofile->exists() ){
throw new Loco_error_Exception( __('MO file exists for this language already. Delete it first','loco-translate') );
}
// Permit forcing of any parsable file as strings template
$source = (string) $post->source;
$compile = false;
$mergejson = false;
if( '' !== $source ){
$translate = ! $post->strip;
$compile = $translate;
$potfile = new Loco_fs_LocaleFile( $source );
$potfile->normalize( $base );
$data = Loco_gettext_Data::load($potfile);
// When copying a PO file we may need to augment with JSON strings
if( $post->json ){
$mergejson = true;
$siblings = new Loco_fs_Siblings($potfile);
$jsons = $siblings->getJsons($domain);
if( $jsons ){
$refs = clone $data;
$merge = new Loco_gettext_Matcher($project);
$merge->loadRefs($refs,$translate);
$merge->loadJsons($jsons);
// resolve faux merge into empty instance
$data->clear();
$merge->mergeValid($refs,$data);
$merge->mergeAdded($data);
}
}
// Remove target strings when copying PO without msgstr fields
if( ! $translate && 'pot' !== $potfile->extension() ){
$data->strip();
}
}
// else parse POT file if project defines one that exists
else {
$potfile = $project->getPot();
if( $potfile->exists() ){
$data = Loco_gettext_Data::load($potfile);
}
// else extract directly from source code, assuming domain passed though from front end
else {
$extr = new Loco_gettext_Extraction( $bundle );
$data = $extr->addProject($project)->includeMeta()->getTemplate($domain);
$potfile = null;
}
}
// Let template define Project-Id-Version, else set header to current project name
$headers = [];
$vers = $data->getHeaders()->{'Project-Id-Version'};
if( ! $vers || 'PACKAGE VERSION' === $vers ){
$headers['Project-Id-Version'] = $project->getName();
}
// fallback header not actually used, but keeping for informational purposes
if( $potfile instanceof Loco_fs_LocaleFile && $post->link ){
$fallback = $potfile->getLocale();
if( $fallback->isValid() ){
$headers['X-Loco-Fallback'] = (string) $fallback;
}
}
// finalize PO data ready to write to new file
$locale->ensureName( new Loco_api_WordPressTranslations );
$data->localize( $locale, $headers );
// save sync options in PO headers if linked to a custom template.
if( $potfile && $post->link ){
$opts = new Loco_gettext_SyncOptions( $data->getHeaders() );
$opts->setTemplate( $potfile->getRelativePath( $bundle->getDirectoryPath() ) );
// legacy behaviour was to sync source AND target strings in the absence of the following
$mode = $post->strip ? 'POT' : 'PO';
// even if no JSONs were merged we need to keep this option in case JSONs are added in future.
if( $mergejson ){
$mode.= ',JSON';
}
$opts->setSyncMode($mode);
}
// compile all files in this set when copying target translation
$compiler = new Loco_gettext_Compiler($pofile);
if( $compile ){
$compiler->writeAll($data,$project);
}
// empty translations don't require compiled files, but adding MO for completeness.
else {
$compiler->writePo($data);
$data->clear();
$compiler->writeMo($data);
}
// return debugging information, used in tests.
$this->set('debug',new Loco_mvc_ViewParams( [
'poname' => $pofile->basename(),
'source' => $potfile ? $potfile->basename() : '',
] ) );
// push recent items on file creation
Loco_data_RecentItems::get()->pushBundle($bundle)->persist();
// front end will redirect to the editor
$type = strtolower( $this->get('type') );
$this->set( 'redirect', Loco_mvc_AdminRouter::generate( sprintf('%s-file-edit',$type), [
'path' => $pofile->getRelativePath($base),
'bundle' => $bundle->getHandle(),
'domain' => $project->getId(),
] ) );
return parent::render();
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* Ajax "ping" route, for testing Ajax responses are working.
*/
class Loco_ajax_PingController extends Loco_mvc_AjaxController {
/**
* {@inheritdoc}
*/
public function render(){
$post = $this->validate();
// echo back bytes posted
if( $post->has('echo') ){
$this->set( 'ping', $post['echo'] );
}
// else just send pong
else {
$this->set( 'ping', 'pong' );
}
// always send tick symbol to check json serializing of unicode
$this->set( 'utf8', "\xE2\x9C\x93" );
return parent::render();
}
}

View File

@@ -0,0 +1,102 @@
<?php
/**
* Ajax "save" route, for saving editor contents to disk
*/
class Loco_ajax_SaveController extends Loco_ajax_common_BundleController {
/**
* {@inheritdoc}
*/
public function render(){
$post = $this->validate();
// path parameter must not be empty
$path = $post->path;
if( ! $path ){
throw new InvalidArgumentException('Path parameter required');
}
// locale must be posted to indicate whether PO or POT
$locale = $post->locale;
if( is_null($locale) ){
throw new InvalidArgumentException('Locale parameter required');
}
$pofile = new Loco_fs_LocaleFile( $path );
$pofile->normalize( loco_constant('WP_CONTENT_DIR') );
// ensure we only deal with PO/POT source files.
// posting of MO file paths is permitted when PO is missing, but we're about to fix that
$ext = strtolower( $pofile->fullExtension() );
if( 'mo' === $ext ){
$pofile = $pofile->cloneExtension('po');
}
else if( 'pot' === $ext ){
$locale = '';
}
else if( 'po' !== $ext ){
throw new Loco_error_Exception('Disallowed file extension');
}
// Prepare compiler for all save operations. PO/MO/JSON, or just POT
$compiler = new Loco_gettext_Compiler($pofile);
// data posted may be either 'multipart/form-data' (recommended for large files)
if( isset($_FILES['po']) ){
$data = Loco_gettext_Data::fromSource( Loco_data_Upload::src('po') );
}
// else 'application/x-www-form-urlencoded' by default
else {
$data = Loco_gettext_Data::fromSource( $post->data );
}
// WordPress-ize some headers that differ from that sent from JavaScript
if( $locale ){
$head = $data->getHeaders();
$head['Language'] = strtr( $locale, '-', '_' );
}
// commit PO file directly to disk
$bytes = $compiler->writePo($data);
$mtime = $pofile->modified();
// start success data with bytes written and timestamp
$this->set('locale', $locale );
$this->set('pobytes', $bytes );
$this->set('poname', $pofile->basename() );
$this->set('modified', $mtime);
$this->set('datetime', Loco_mvc_ViewParams::date_i18n($mtime) );
// add bundle to recent items on file creation
// editor permitted to save files not in a bundle, so catching failures
try {
$bundle = $this->getBundle();
Loco_data_RecentItems::get()->pushBundle($bundle)->persist();
}
catch( Exception $e ){
$bundle = null;
}
// Compile MO and JSON files if PO is localised and not POT (template)
if( $locale ){
$mobytes = $compiler->writeMo($data);
$numjson = 0;
// Project required for JSON writes
if( $bundle ){
$project = $this->getProject($bundle);
$jsons = $compiler->writeJson($project,$data);
$numjson = $jsons->count();
}
$this->set( 'mobytes', $mobytes );
$this->set( 'numjson', $numjson );
}
// Final summary depending on whether MO and JSON compiled
$compiler->getSummary();
return parent::render();
}
}

View File

@@ -0,0 +1,134 @@
<?php
/**
* Ajax "sync" route.
* Extracts strings from source (POT or code) and returns to the browser for in-editor merge.
*/
class Loco_ajax_SyncController extends Loco_mvc_AjaxController {
/**
* {@inheritdoc}
*/
public function render(){
$post = $this->validate();
$bundle = Loco_package_Bundle::fromId( $post->bundle );
$project = $bundle->getProjectById( $post->domain );
if( ! $project instanceof Loco_package_Project ){
throw new Loco_error_Exception('No such project '.$post->domain);
}
// Merging on back end is only required if existing target file exists.
// It always should do, and the editor is not permitted to contain unsaved changes when syncing.
if( ! $post->has('path') ){
throw new Loco_error_Exception('path argument required');
}
$file = new Loco_fs_File( $post->path );
$base = loco_constant('WP_CONTENT_DIR');
$file->normalize($base);
$target = Loco_gettext_Data::load($file);
// POT file always synced with source code
$type = $post->type;
if( 'pot' === $type ){
$potfile = null;
}
// allow front end to configure source file. (will have come from $target headers)
else if( $post->sync ){
$potfile = new Loco_fs_File( $post->sync );
$potfile->normalize($base);
}
// else use project-configured template path (must return a file)
else {
$potfile = $project->getPot();
}
// keep existing behaviour when template is missing, but add warning according to settings.
if( $potfile && ! $potfile->exists() ){
$conf = Loco_data_Settings::get()->pot_expected;
if( 2 === $conf ){
throw new Loco_error_Exception('Plugin settings disallow missing templates');
}
if( 1 === $conf ){
// Translators: %s will be replaced with the name of a missing POT file
Loco_error_AdminNotices::warn( sprintf( __('Falling back to source extraction because %s is missing','loco-translate'), $potfile->basename() ) );
}
$potfile = null;
}
// defaults: no msgstr and no json
$translate = false;
$syncjsons = [];
// Parse existing POT for source
if( $potfile ){
$this->set('pot', $potfile->basename() );
try {
$source = Loco_gettext_Data::load($potfile);
}
catch( Exception $e ){
// translators: Where %s is the name of the invalid POT file
throw new Loco_error_ParseException( sprintf( __('Translation template is invalid (%s)','loco-translate'), $potfile->basename() ) );
}
// Sync options are passed through from editor controller via JS
$opts = new Loco_gettext_SyncOptions( new LocoPoHeaders );
$opts->setSyncMode( $post->mode );
// Only copy msgstr fields from source if it's a user-defined PO template and "copy translations" was selected.
if( 'pot' !== $potfile->extension() ){
$translate = $opts->mergeMsgstr();
}
// Only merge JSON translations if specified. This requires we know the localised path where they will be
if( $opts->mergeJson() ){
$siblings = new Loco_fs_Siblings($potfile);
$syncjsons = $siblings->getJsons( $project->getDomain()->getName() );
}
}
// else extract POT from source code
else {
$this->set('pot', '' );
$domain = (string) $project->getDomain();
$extr = new Loco_gettext_Extraction($bundle);
$extr->addProject($project);
// bail if any files were skipped
if( $list = $extr->getSkipped() ){
$n = count($list);
$maximum = Loco_mvc_FileParams::renderBytes( wp_convert_hr_to_bytes( Loco_data_Settings::get()->max_php_size ) );
$largest = Loco_mvc_FileParams::renderBytes( $extr->getMaxPhpSize() );
// Translators: (1) Number of files (2) Maximum size of file that will be included (3) Size of the largest encountered
$text = _n('%1$s file has been skipped because it\'s %3$s. (Max is %2$s). Check all strings are present before saving.','%1$s files over %2$s have been skipped. (Largest is %3$s). Check all strings are present before saving.',$n,'loco-translate');
$text = sprintf( $text, number_format($n), $maximum, $largest );
// not failing, just warning. Nothing will be saved until user saves editor state
Loco_error_AdminNotices::warn( $text );
}
// Have source strings. These cannot contain any translations.
$source = $extr->includeMeta()->getTemplate($domain);
}
// establish on back end what strings will be added, removed, and which could be fuzzy-matches
$matcher = new Loco_gettext_Matcher($project);
$matcher->loadRefs($source,$translate);
// merging JSONs must be done before fuzzy matching as it may add source strings
if( $syncjsons ) {
$matcher->loadJsons($syncjsons);
}
// Fuzzy matching only applies to syncing PO files. POT files will always do hard sync (add/remove)
if( 'po' === $type ){
$fuzziness = Loco_data_Settings::get()->fuzziness;
$matcher->setFuzziness( (string) $fuzziness );
}
else {
$matcher->setFuzziness('0');
}
// update matches sources, deferring unmatched for deferred fuzzy match
$merged = clone $target;
$merged->clear();
$this->set( 'done', $matcher->merge($target,$merged) );
$merged->sort();
$this->set( 'po', $merged->jsonSerialize() );
return parent::render();
}
}

View File

@@ -0,0 +1,87 @@
<?php
/**
* Ajax "upload" route, for putting translation files to the server
*/
class Loco_ajax_UploadController extends Loco_ajax_common_BundleController {
/**
* {@inheritdoc}
*/
public function render(){
$post = $this->validate();
$href = $this->process( $post );
//
$this->set('redirect',$href);
return parent::render();
}
/**
* Upload processor shared with standard postback controller
* @param Loco_mvc_ViewParams $post script input
* @return string redirect to file edit
*/
public function process( Loco_mvc_ViewParams $post ){
$bundle = $this->getBundle();
$project = $this->getProject( $bundle );
// Chosen folder location should be valid as a posted "dir" parameter
if( ! $post->has('dir') ){
throw new Loco_error_Exception('No destination posted');
}
$base = loco_constant('WP_CONTENT_DIR');
$parent = new Loco_fs_Directory($post->dir);
$parent->normalize($base);
// Loco_error_AdminNotices::debug('Destination set to '.$parent->getPath() );
// Ensure file uploaded ok
if( ! isset($_FILES['f']) ){
throw new Loco_error_Exception('No file posted');
}
$upload = new Loco_data_Upload($_FILES['f']);
// Uploaded file will have a temporary name, so real name extension come from _FILES metadata
$name = $upload->getOriginalName();
$ext = strtolower( pathinfo($name,PATHINFO_EXTENSION) );
// Loco_error_AdminNotices::debug('Have upload: '.$name.' @ '.$upload->getPath() );
switch( $ext ){
case 'po':
case 'mo':
$pomo = Loco_gettext_Data::load($upload,$ext);
break;
default:
throw new Loco_error_Exception('Only PO/MO uploads supported');
}
// PO/MO data is valid.
// get real file name and establish if a locale can be extracted, otherwise get from headers
$dummy = new Loco_fs_LocaleFile($name);
$locale = $dummy->getLocale();
if( ! $locale->isValid() ){
$value = $pomo->getHeaders()->offsetGet('Language');
$locale = Loco_Locale::parse($value);
if( ! $locale->isValid() ){
throw new Loco_error_Exception('Unable to detect language from '.$name );
}
}
// Fail if user presents a wrongly named file. This is to avoid mixing up text domains.
$pofile = $project->initLocaleFile($parent,$locale);
if( $pofile->filename() !== $dummy->filename() ){
throw new Loco_error_Exception( sprintf('File must be named %s', $pofile->filename().'.'.$ext ) );
}
// Avoid processing if uploaded PO file is identical to existing one
if( $pofile->exists() && $pofile->md5() === $upload->md5() ){
throw new Loco_error_Exception( __('Your file is identical to the existing one','loco-translate') );
}
// recompile all files including uploaded one
$compiler = new Loco_gettext_Compiler($pofile);
$compiler->writeAll($pomo,$project);
// push recent items on file creation
Loco_data_RecentItems::get()->pushBundle($bundle)->persist();
// Redirect to edit this PO. Sync may be required and we're not doing automatically here.
$type = strtolower( $this->get('type') );
return Loco_mvc_AdminRouter::generate( sprintf('%s-file-edit',$type), [
'path' => $pofile->getRelativePath($base),
'bundle' => $bundle->getHandle(),
'domain' => $project->getId(),
] );
}
}

View File

@@ -0,0 +1,86 @@
<?php
/**
* Ajax "xgettext" route, for initializing new template file from source code
*/
class Loco_ajax_XgettextController extends Loco_ajax_common_BundleController {
/**
* {@inheritdoc}
*/
public function render(){
$this->validate();
$bundle = $this->getBundle();
$project = $this->getProject( $bundle );
// target location may not be next to POT file at all
$base = loco_constant('WP_CONTENT_DIR');
$target = new Loco_fs_Directory( $this->get('path') );
$target->normalize( $base );
if( $target->exists() && ! $target->isDirectory() ){
throw new Loco_error_Exception('Target is not a directory');
}
// basename should be posted from front end
$name = $this->get('name');
if( ! $name ){
throw new Loco_error_Exception('Front end did not post $name');
}
// POT file should be .pot but we'll allow .po
$potfile = new Loco_fs_File( $target.'/'.$name );
$ext = strtolower( $potfile->fullExtension() );
if( 'pot' !== $ext && 'po' !== $ext ){
throw new Loco_error_Exception('Disallowed file extension');
}
// File shouldn't exist currently
$api = new Loco_api_WordPressFileSystem;
$api->authorizeCreate($potfile);
// Do extraction and grab only given domain's strings
$ext = new Loco_gettext_Extraction( $bundle );
$domain = $project->getDomain()->getName();
$data = $ext->addProject($project)->includeMeta()->getTemplate( $domain );
// additional headers to set in new POT file
$head = $data->getHeaders();
$head['Project-Id-Version'] = $project->getName();
// write POT file to disk returning byte length
$potsize = $potfile->putContents( $data->msgcat(true) );
// set response data for debugging
if( loco_debugging() ){
$this->set( 'debug', [
'potname' => $potfile->basename(),
'potsize' => $potsize,
'total' => $ext->getTotal(),
] );
}
// push recent items on file creation
// TODO push project and locale file
Loco_data_RecentItems::get()->pushBundle( $bundle )->persist();
// put flash message into session to be displayed on redirected page
try {
Loco_data_Session::get()->flash('success', __('Template file created','loco-translate') );
Loco_data_Session::close();
}
catch( Exception $e ){
Loco_error_AdminNotices::debug( $e->getMessage() );
}
// redirect front end to bundle view. Discourages manual editing of template
$type = strtolower( $bundle->getType() );
$href = Loco_mvc_AdminRouter::generate( sprintf('%s-view',$type), [
'bundle' => $bundle->getHandle(),
] );
$hash = '#loco-'.$project->getId();
$this->set( 'redirect', $href.$hash );
return parent::render();
}
}

View File

@@ -0,0 +1,39 @@
<?php
/**
* Common functions for all Ajax actions that operate on a bundle
*/
abstract class Loco_ajax_common_BundleController extends Loco_mvc_AjaxController {
/**
* @return Loco_package_Bundle
*/
protected function getBundle(){
if( $id = $this->get('bundle') ){
// type may be passed as separate argument
if( $type = $this->get('type') ){
return Loco_package_Bundle::createType( $type, $id );
}
// else embedded in standalone bundle identifier
// TODO standardize this across all Ajax end points
return Loco_package_Bundle::fromId($id);
}
// else may have type embedded in bundle
throw new Loco_error_Exception('No bundle identifier posted');
}
/**
* @param Loco_package_Bundle $bundle
* @return Loco_package_Project
*/
protected function getProject( Loco_package_Bundle $bundle ){
$project = $bundle->getProjectById( $this->get('domain') );
if( ! $project ){
throw new Loco_error_Exception('Failed to find translation project');
}
return $project;
}
}

View File

@@ -0,0 +1,252 @@
<?php
/**
* OpenAI compatible "Chat Completions" auto-translation provider.
*/
abstract class Loco_api_ChatGpt extends Loco_api_Client {
public static function supports( string $vendor ): bool {
return Loco_api_Providers::VENDOR_GOOGLE === $vendor || Loco_api_Providers::VENDOR_OPENAI === $vendor || Loco_api_Providers::VENDOR_OROUTE === $vendor;
}
/**
* @param string[][] $items input messages with keys, "source", "context" and "notes"
* @return string[] Translated strings
* @throws Loco_error_Exception
*/
public static function process( array $items, Loco_Locale $locale, array $config ):array {
$targets = [];
// Switch OpenAI compatible provider
$vendor = $config['vendor'] ?? Loco_api_Providers::VENDOR_OPENAI;
$endpoint = [
Loco_api_Providers::VENDOR_OPENAI => 'https://api.openai.com/v1/chat/completions',
Loco_api_Providers::VENDOR_GOOGLE => 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
Loco_api_Providers::VENDOR_OROUTE => 'https://openrouter.ai/api/v1/chat/completions',
][$vendor];
// Switch default model, if not provided
$model = $config['model']??'';
if( '' === $model ){
$model = [
Loco_api_Providers::VENDOR_OPENAI => 'gpt-4.1-nano',
Loco_api_Providers::VENDOR_GOOGLE => 'gemini-2.5-flash-lite',
Loco_api_Providers::VENDOR_OROUTE => 'openai/gpt-4.1-nano',
][$vendor];
}
// Establish temperature; preferring 0.0, but recent GPT models have minimum 1.0
// Range is probably 0.0 -> 2.0, but letting vendor validate valid range as it may vary.
$temperature = $config['temperature'] ?? 0.0;
if( $temperature < 1.0 && str_starts_with($model,'gpt-5') ){
$temperature = 1.0;
}
// GPT wants a wordy language name. We'll handle this with our own data.
$sourceTag = 'en_US';
$sourceLang = 'English';
$targetTag = (string) $locale;
$targetLang = self::wordy_language($locale);
// source language may be overridden by `loco_api_provider_source` hook
$tag = Loco_mvc_PostParams::get()['source'];
if( is_string($tag) && '' !== $tag ){
$locale = Loco_Locale::parse($tag);
if( $locale->isValid() ){
$sourceTag = $tag;
$sourceLang = self::wordy_language($locale);
}
}
// We're finished with locale data. free up some memory.
Loco_data_CompiledData::flush();
// Start prompt with assistant identity and immutable translation instructions
$instructions = [
'Respond only in '.$targetLang,
];
$tone = $locale->getFormality();
if( '' !== $tone ){
$instructions[] = 'Use only the '.$tone.' tone of '.$targetLang;
}
$prompt = "# Identity\n\nYou are a translator that translates from ".$sourceLang.' ('.$sourceTag.') to '.$targetLang.' ('.$targetTag.").\n\n"
. "# Instructions\n\n* ".implode(".\n* ",$instructions ).'.';
// Allow user-defined extended instructions via filter
$custom = apply_filters( 'loco_gpt_prompt', $config['prompt']??'', $locale );
if( is_string($custom) ){
$custom = trim($custom,"\n* ");
if( '' !== $custom ) {
$prompt .= "\n\n* ".$custom;
}
}
// Longer cURL timeout. This API can be slow with many items. 20 seconds and up is not uncommon
add_filter('http_request_timeout', function( $timeout = 20 ){
return max( $timeout, 20 );
} );
// The front end is already splitting large jobs into batches, but we need smaller batches here
$offset = 0;
$totalItems = count($items);
while( $offset < $totalItems ){
$bytes = 0;
$batch = [];
// Fill batch with a soft ceiling of ~5KB. This will keep individual response times down, but script execution can still time-out.
while( $bytes < 5000 && $offset < $totalItems ){
$item = $items[$offset];
$meta = array_filter( [$item['context'], $item['notes']] );
$source = [
'id' => $offset,
'text' => $item['source'],
'context' => implode("\n",$meta),
];
$bytes += strlen( $source['text'].$source['context'] );
$batch[] = $source;
$offset++;
}
// Send batch
// https://platform.openai.com/docs/api-reference/chat/create
$result = wp_remote_request( $endpoint, self::init_request_arguments( $config, [
'model' => $model,
'temperature' => $temperature,
// Start with our base prompt, adding user instruction at [1] and data at [2]
'messages' => [
[ 'role' => 'developer', 'content' => $prompt ],
[ 'role' => 'user', 'content' => 'Translate the `text` properties of the following JSON objects, using the `context` property to identify the meaning' ],
[ 'role' => 'user', 'content' => json_encode($batch,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) ],
],
// Define schema for reliable returning of correct data
// https://openai.com/index/introducing-structured-outputs-in-the-api/
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => 'translations_array',
'strict' => true,
'schema' => [
'type' => 'object',
'properties' => [
'result' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'type' => 'number',
'description' => 'Corresponding id from the input object'
],
'text' => [
'type' => 'string',
'description' => 'Translation text of the corresponding input object',
]
],
'required' => ['id','text'],
'additionalProperties' => false,
],
'description' => 'Translations of the corresponding input array',
],
],
'required' => ['result'],
'additionalProperties' => false,
],
],
],
]) );
// generic response handling
try {
$data = self::decode_response($result);
// all responses have form {choices:[...]}
foreach( $data['choices'] as $choice ){
$blob = $choice['message'] ?? ['role'=>'null'];
if( isset($blob['refusal']) ){
Loco_error_Debug::trace('Refusal: %s', $blob['refusal'] );
continue;
}
if( 'assistant' !== $blob['role'] ){
Loco_error_Debug::trace('Ignoring %s role message', $blob['role'] );
continue;
}
$content = json_decode( trim($blob['content']), true );
if( ! is_array($content) || ! array_key_exists('result',$content) ){
Loco_error_Debug::trace("Content doesn't conform to our schema");
continue;
}
$result = $content['result'];
if( ! is_array($result) || count($result) !== count($batch) ){
Loco_error_Debug::trace("Result array doesn't match our input array");
continue;
}
// expecting translations back in order, but `id` field must match our input.
$i = -1;
foreach( $result as $output ){
$input = $batch[++$i];
$ourId = $input['id'];
$translation = $output['text'];
$gptId = (int) $output['id'];
if( $ourId !== $gptId ){
Loco_error_Debug::trace('Bad id field at [%u] expected %s, got %s', $i, $ourId, $gptId );
$translation = '';
}
$targets[$ourId] = $translation;
}
}
// next batch...
}
catch ( Throwable $e ){
$name = $config['name'] ?? $vendor;
throw new Loco_error_Exception( $name.': '.$e->getMessage() );
}
}
return $targets;
}
private static function wordy_language( Loco_Locale $locale ):string {
$names = Loco_data_CompiledData::get('languages');
return $names[ $locale->lang ] ?? $locale->lang;
}
private static function init_request_arguments( array $config, array $data ):array {
return [
'method' => 'POST',
'redirection' => 0,
'user-agent' => parent::getUserAgent(),
'reject_unsafe_urls' => false,
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer '.$config['key'],
'Origin' => $_SERVER['HTTP_ORIGIN'],
'Referer' => $_SERVER['HTTP_ORIGIN'].'/wp-admin/'
],
'body' => json_encode($data),
];
}
private static function decode_response( $result ):array {
$data = parent::decodeResponse($result);
$status = $result['response']['code'];
if( 200 !== $status ){
$message = $data['error']['message'] ?? null;
if( is_null($message) ){
// Gemini returns array of errors, instead of single object.
foreach( $data as $item ){
$message = $item['error']['message'] ?? null;
if( is_string($message) ){
break;
}
}
}
throw new Exception( sprintf('API returned status %u: %s',$status,$message??'Unknown error') );
}
// all responses have form {choices:[...]}
if( ! array_key_exists('choices',$data) || ! is_array($data['choices']) ){
throw new Exception('API returned unexpected data');
}
return $data;
}
}

View File

@@ -0,0 +1,40 @@
<?php
/**
* Generic API client base class
*/
abstract class Loco_api_Client {
/**
* @param array|WP_Error $result
* @return array
*/
public static function decodeResponse( $result ):array {
if( $result instanceof WP_Error ){
foreach( $result->get_error_messages() as $message ){
throw new Loco_error_Exception($message);
}
}
// always decode response if server says it's JSON
if( 'application/json' === substr($result['headers']['Content-Type'],0,16) ){
$data = json_decode( $result['body'], true );
if( is_array($data) ){
return $data;
}
throw new Loco_error_Exception('Failed to decode JSON response');
}
if( 200 === $result['response']['code'] ){
throw new Loco_error_Exception('Expected JSON Content-Type for 200 response');
}
// else this may be a valid error response
return [];
}
public static function getUserAgent():string {
return apply_filters( 'loco_api_user_agent', sprintf('Loco Translate/%s; wp-%s', loco_plugin_version(), $GLOBALS['wp_version'] ) );
}
}

View File

@@ -0,0 +1,172 @@
<?php
/**
* DeepL back end client, because CORS
*/
abstract class Loco_api_DeepL extends Loco_api_Client {
/**
* @param string[][] $items input messages with keys, "source", "context" and "notes"
*
* @return string[] Translated strings
* @throws Loco_error_Exception
*/
public static function process( array $items, Loco_Locale $locale, array $config ): array {
$api_key = $config['key'];
if( ! is_string($api_key) || '' === $api_key ){
throw new Loco_error_Exception('API key required');
}
// target language requires mapping to DeepL supported value
// https://developers.deepl.com/docs/getting-started/supported-languages#target-languages
$targetLang = strtoupper($locale->lang);
if( $locale->region ){
$variants = [
'EN-GB' => 'EN-GB',
'EN-US' => 'EN-US',
'PT-PT' => 'PT-PT',
'PT-BR' => 'PT-BR',
'ZH-CN' => 'ZH-HANS',
'ZH-SG' => 'ZH-HANS',
'ZH-TW' => 'ZH-HANT',
'ZH-HK' => 'ZH-HANT',
'ZH-MO' => 'ZH-HANT',
// TODO ES-419 - Spanish (Latin American)
];
$tag = $targetLang.'-'.strtoupper($locale->region);
if( array_key_exists($tag,$variants) ){
$targetLang = $variants[$tag];
}
}
// DeepL supported formality
$tones = [
'formal' => 'prefer_more',
'informal' => 'prefer_less',
'' => 'default',
];
$formality = $tones[ $locale->getFormality() ] ?? $tones[''];
// source language may be overridden by `loco_api_provider_source` hook
$sourceLang = 'EN';
$tag = Loco_mvc_PostParams::get()['source'];
if( is_string($tag) && '' !== $tag ){
$source = Loco_Locale::parse($tag);
if( $source->isValid() ){
$sourceLang = strtoupper($source->lang);
}
}
// Unwind all posted sources
// TODO perform placeholder protection here, as per Loco platform.
$sources = [];
foreach( $items as $item ){
$sources[] = $item['source'];
}
// context can only be set per request, for all text values posted,
if( 1 === count($items) ){
$context = trim( implode(' ', [ $items[0]['context']??'', $items[0]['notes']??'' ] ) );
// Detect key verification call here and perform a zero cost /v2/usage request
if( '' === $context && 'OK' === $sources[0] && 'FR' === $targetLang ){
return self::fetch_usage($api_key) ? ['OK'] : [];
}
}
else {
$context = '';
}
// Switch on next-gen (quality_optimized) / classic (latency_optimized) models:
// Note that beta languages don't work with the classic model
$model = $config['model'] ?? 'prefer_quality_optimized';
// make request and parse JSON result
$result = wp_remote_request( self::baseUrl($api_key).'/v2/translate', self::init_request_arguments( $config, [
'source_lang' => apply_filters('loco_deepl_source_lang',$sourceLang),
'target_lang' => apply_filters('loco_deepl_target_lang',$targetLang, $locale),
'formality' => apply_filters('loco_deepl_formality',$formality, $locale),
'enable_beta_languages' => apply_filters('loco_deepl_beta', 'latency_optimized' !== $model ),
'model' => apply_filters('loco_deepl_model',$model),
'preserve_formatting' => '1',
'context' => $context,
'text' => $sources,
] ) );
$data = parent::decodeResponse($result);
$status = $result['response']['code'];
if( 200 !== $status ){
$message = $data['message'] ?? 'Unknown error';
throw new Loco_error_Exception( sprintf('DeepL returned status %u: %s',$status,$message) );
}
// 200 OK:
$targets = [];
$translations = $data['translations']??[];
foreach( $translations as $translation ){
$targets[] = $translation['text'];
}
return $targets;
}
private static function init_request_arguments( array $config, array $data ):array {
return [
'method' => 'POST',
'redirection' => 0,
'user-agent' => parent::getUserAgent(),
'reject_unsafe_urls' => false,
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
'Authorization' => 'DeepL-Auth-Key '.$config['key'],
],
'body' => self::encode_request_body($data),
];
}
/**
* DeepL requires arrays to be specified as repeated arguments
* e.g. text=foo&text=bar&text=baz
*/
private static function encode_request_body( array $data ): string {
$pairs = [];
foreach( $data as $key => $mixed ){
if( is_array($mixed) ){
foreach( $mixed as $scalar ){
$pairs[] = $key.'='.urlencode($scalar);
}
}
else {
$pairs[] = $key.'='.urlencode($mixed);
}
}
return implode('&',$pairs);
}
private static function fetch_usage( string $key ):array {
$data = parent::decodeResponse( wp_remote_request( self::baseUrl($key).'/v2/usage', [
'redirection' => 0,
'user-agent' => parent::getUserAgent(),
'reject_unsafe_urls' => false,
'headers' => [
'Authorization' => 'DeepL-Auth-Key '.$key,
],
] ) );
if( array_key_exists('character_limit',$data) ){
return $data;
}
$message = $data['message'] ?? 'Failed to get usage';
throw new Loco_error_Exception( 'DeepL: '.$message);
}
private static function baseUrl( string $key ):string {
$url = 'https://api';
if( str_ends_with($key,':fx') ){
$url .= '-free';
}
return $url . '.deepl.com';
}
}

View File

@@ -0,0 +1,113 @@
<?php
/**
* Third party API helpers
*/
abstract class Loco_api_Providers {
const VENDOR_OPENAI = 'openai';
const VENDOR_GOOGLE = 'gemini';
const VENDOR_OROUTE = 'openrouter';
/*private static array $vendors = [
self::VENDOR_OPENAI => 'OpenAI',
self::VENDOR_GOOGLE => 'Gemini',
self::VENDOR_OROUTE => 'OpenRouter',
];*/
/*public static function vendorName( string $id ): string{
return self::$vendors[$id] ?? 'Unknown Vendor';
}*/
/**
* Export API credentials for all supported APIs
* @return array[]
*/
public static function export():array {
$apis = [];
foreach( self::builtin() as $a ){
$hook = 'loco_api_provider_'.$a['id'];
$apis[] = apply_filters($hook, $a );
}
return apply_filters( 'loco_api_providers', $apis );
}
/**
* @return array[]
*/
public static function builtin():array {
$settings = Loco_data_Settings::get();
return [
[
'id' => 'deepl',
'name' => 'DeepL Translator',
'key' => $settings->offsetGet('deepl_api_key'),
'url' => 'https://www.deepl.com/translator',
],[
'id' => 'google',
'name' => 'Google Translate',
'key' => $settings->offsetGet('google_api_key'),
'url' => 'https://translate.google.com/',
],[
'id' => 'microsoft',
'name' => 'Microsoft Translator',
'key' => $settings->offsetGet('microsoft_api_key'),
'region' => $settings->offsetGet('microsoft_api_region'),
'url' => 'https://aka.ms/MicrosoftTranslatorAttribution',
],[
'id' => 'lecto',
'name' => 'Lecto AI',
'key' => $settings->offsetGet('lecto_api_key'),
'url' => 'https://lecto.ai/?ref=loco',
],[
'id' => 'openai',
'name' => 'OpenAI',
'key' => $settings->offsetGet('openai_api_key'),
'model' => $settings->offsetGet('openai_api_model'),
'prompt' => $settings->offsetGet('openai_api_prompt'),
'url' => 'https://openai.com/policies/usage-policies/',
]
];
}
/**
* Get only configured APIs, and sort them fairly
* @return array[]
*/
public static function configured():array {
return self::sort( array_filter( self::export(), [__CLASS__,'filterConfigured'] ) );
}
/**
* @internal
* @param $api string[]
*/
private static function filterConfigured( array $api ):bool {
return array_key_exists('key',$api) && is_string($api['key']) && '' !== $api['key'];
}
/**
* @internal
* @param string[] $a
* @param string[] $b
* @return int
*/
private static function compareNames( array $a, array $b ):int {
return strcasecmp($a['name'],$b['name']);
}
/**
* Sort providers alphabetically
* @param array[] $apis
*/
public static function sort( array $apis ):array {
usort( $apis, [__CLASS__,'compareNames'] );
return $apis;
}
}

View File

@@ -0,0 +1,430 @@
<?php
/**
* Abstracts WordPress filesystem connection.
* https://codex.wordpress.org/Filesystem_API
*/
class Loco_api_WordPressFileSystem {
/**
* Currently authenticated file system connection
* @var WP_Filesystem_Direct
*/
private $fs;
/**
* Whether global file modifications have already passed check
* @var bool
*/
private $fs_allowed;
/**
* Credentials form HTML echoed from request_filesystem_credentials
* @var string
*/
private $form = '';
/**
* Credentials posted into the API
* @var array
*/
private $creds_in = [];
/**
* Credentials returned from the API
* @var array
*/
private $creds_out = [];
/**
* Create direct filesystem accessor
* @return WP_Filesystem_Direct
*/
public static function direct(){
// Emulate WP_Filesystem to avoid FS_METHOD and filters overriding "direct" type
if( ! class_exists('WP_Filesystem_Direct',false) ){
require_once ABSPATH.'wp-admin/includes/class-wp-filesystem-base.php';
require_once ABSPATH.'wp-admin/includes/class-wp-filesystem-direct.php';
}
return new WP_Filesystem_Direct(null);
}
/**
* Get HTML form rendered by request_filesystem_credentials
* @return string
*/
public function getForm(){
return $this->form;
}
/**
* Pre-auth checks for superficial file system denials and disconnects any active remotes
* @param Loco_fs_File $file the file you wish to modify
* @throws Loco_error_WriteException
* @return void
*/
public function preAuthorize( Loco_fs_File $file ){
if( ! $this->fs_allowed ){
$file->getWriteContext()->authorize();
$this->fs_allowed = true;
}
// Disconnecting remote file system ensures the auth functions always start with direct file access
$file->getWriteContext()->disconnect();
}
/**
* Authorize for the creation of a file that does not exist
* @param Loco_fs_File $file
* @return bool whether file system is authorized NOT necessarily whether file is creatable
*/
public function authorizeCreate( Loco_fs_File $file ){
$this->preAuthorize($file);
if( $file->exists() ){
// translators: %s refers to the name of a new file to be created, but which already existed
throw new Loco_error_WriteException( sprintf( __('%s already exists in this folder','loco-translate'), $file->basename() ) );
}
return $file->creatable() || $this->authorize($file);
}
/**
* Authorize for the update of a file that does exist
* @param Loco_fs_File $file
* @return bool whether file system is authorized NOT necessarily whether file is updatable
*/
public function authorizeUpdate( Loco_fs_File $file ){
$this->preAuthorize($file);
if( ! $file->exists() ){
throw new Loco_error_WriteException("File doesn't exist, try authorizeCreate");
}
return $file->writable() || $this->authorize($file);
}
/**
* Authorize for update or creation, depending on whether file exists
* @param Loco_fs_File $file
* @return bool
*/
public function authorizeSave( Loco_fs_File $file ){
$this->preAuthorize($file);
return ( $file->exists() ? $file->writable() : $file->creatable() ) || $this->authorize($file);
}
/**
* Authorize for copy (to same directory), meaning source file must exist and directory be writable
* @param Loco_fs_File $file
* @return bool
*/
public function authorizeCopy( Loco_fs_File $file ){
$this->preAuthorize($file);
if( ! $file->exists() ){
throw new Loco_error_WriteException("Can't copy a file that doesn't exist");
}
return $file->creatable() || $this->authorize($file);
}
/**
* Authorize for move (to another path if given).
* @param Loco_fs_File $source file being moved (must exist)
* @param Loco_fs_File|null $target target path (should not exist)
*/
public function authorizeMove( Loco_fs_File $source, ?Loco_fs_File $target = null ):bool {
// source is in charge of its own deletion
$result = $this->authorizeDelete($source);
// target is in charge of copying original which it must also be able to read.
if( $target && ! $this->authorizeCreate($target) ){
$result = false;
}
// value returned will be false if at least one file requires we add credentials
return $result;
}
/**
* Authorize for the removal of an existing file
* @param Loco_fs_File $file
* @return bool whether file system is authorized NOT necessarily whether file is removable
*/
public function authorizeDelete( Loco_fs_File $file ){
$this->preAuthorize($file);
if( ! $file->exists() ){
throw new Loco_error_WriteException("Can't delete a file that doesn't exist");
}
return $file->deletable() || $this->authorize($file);
}
/**
* Connect file to credentials in posted data. Used when established in advance what connection is needed
* @param Loco_fs_File $file
* @return bool whether file system is authorized
*/
public function authorizeConnect( Loco_fs_File $file ){
$this->preAuthorize($file);
// front end may have posted that "direct" connection will work
$post = Loco_mvc_PostParams::get();
if( 'direct' === $post->connection_type ){
return true;
}
return $this->authorize($file);
}
/**
* Wraps `request_filesystem_credentials` negotiation to obtain a remote connection and buffer WordPress form output
* Call before output started, because buffers.
* @param Loco_fs_File $file
* @return bool
*/
private function authorize( Loco_fs_File $file ){
// may already have authorized successfully
if( $this->fs instanceof WP_Filesystem_Base ){
$file->getWriteContext()->connect( $this->fs, false );
return true;
}
// may have already failed authorization
if( $this->form ){
return false;
}
// network access may be disabled
if( ! apply_filters('loco_allow_remote', true ) ){
throw new Loco_error_WriteException('Remote connection required, but network access is disabled');
}
// else begin new auth
$this->fs = null;
$this->form = '';
$this->creds_out = [];
// observe settings held temporarily in session
try {
$session = Loco_data_Session::get();
if( isset($session['loco-fs']) ){
$creds = $session['loco-fs'];
if( is_array($creds) && $this->tryCredentials($creds,$file) ){
$this->creds_in = [];
return true;
}
}
}
catch( Exception $e ){
// tolerate session failure
}
$post = Loco_mvc_PostParams::get();
$dflt = [ 'hostname' => '', 'username' => '', 'password' => '', 'public_key' => '', 'private_key' => '', 'connection_type' => '', '_fs_nonce' => '' ];
$this->creds_in = array_intersect_key( $post->getArrayCopy(), $dflt );
// deliberately circumventing call to `get_filesystem_method`
// risk of WordPress version compatibility issues, but only sane way to force a remote connection
// @codeCoverageIgnoreStart
if( defined('FS_METHOD') && FS_METHOD ){
$type = FS_METHOD;
// forcing direct access means request_filesystem_credentials will never give us a form :(
if( 'direct' === $type ){
Loco_error_AdminNotices::debug('Cannot connect remotely when FS_METHOD is "direct"');
return false;
}
}
// direct filesystem is OK if the front end already posted it
else if( 'direct' === $post->connection_type ){
return true;
}
// else perform same logic as request_filesystem_credentials does to establish type
else if( 'ssh' === $post->connection_type && extension_loaded('ssh2') && function_exists('stream_get_contents') ){
$type = 'ssh2';
}
else if( extension_loaded('ftp') ){
$type = 'ftpext';
}
else if( extension_loaded('sockets') || function_exists('fsockopen') ){
$type = 'ftpsockets';
}
// @codeCoverageIgnoreEnd
else {
$type = '';
}
// context is nonsense here as the system doesn't know what operation we're performing
// testing directory write-permission when we're updating a file, for example.
$context = '/ignore/this';
$type = apply_filters( 'filesystem_method', $type, $post->getArrayCopy(), $context, true );
// the only params we'll pass into form will be those used by the ajax fsConnect end point
$extra = [ 'loco-nonce', 'path', 'auth', 'dest' ];
// capture WordPress output during negotiation.
$buffer = Loco_output_Buffer::start();
$creds = request_filesystem_credentials( '', $type, false, $context, $extra );
if( is_array($creds) ){
// credentials passed through, should allow to connect if they are correct
if( $this->tryCredentials($creds,$file) ){
$this->persistCredentials();
return true;
}
// else there must be an error with the credentials
$error = true;
// pull more useful connection error for display in form
if( isset($GLOBALS['wp_filesystem']) ){
$fs = $GLOBALS['wp_filesystem'];
$GLOBALS['wp_filesystem'] = null;
if( $fs && $fs->errors && $fs->errors->get_error_code() ){
$error = $fs->errors;
}
}
// annoyingly WordPress moves the error notice above the navigation tabs :-/
request_filesystem_credentials( '', $type, $error, $context, $extra );
}
// should now have unauthorized remote connection form
$this->form = (string) $buffer->close();
if( '' === $this->form ){
Loco_error_AdminNotices::debug('Unknown error capturing output from request_filesystem_credentials');
}
return false;
}
/**
* @param array $creds credentials returned from request_filesystem_credentials
* @param Loco_fs_File $file file to authorize write context
* @return bool when credentials connected ok
*/
private function tryCredentials( array $creds, Loco_fs_File $file ){
// lazy construct the file system from current credentials if possible
// in typical WordPress style, after success the object will be held in a global.
if( WP_Filesystem( $creds, '/ignore/this/' ) ){
$this->fs = $GLOBALS['wp_filesystem'];
// hook new file system into write context (specifying that connect has already been performed)
$file->getWriteContext()->connect( $this->fs, false );
$this->creds_out = $creds;
return true;
}
return false;
}
/**
* Set current credentials in session if settings allow
* @return bool whether credentials persisted
*/
private function persistCredentials(){
try {
$settings = Loco_data_Settings::get();
if( $settings['fs_persist'] ){
$session = Loco_data_Session::get();
$session['loco-fs'] = $this->creds_out;
$session->persist();
return true;
}
}
catch( Exception $e ){
// tolerate session failure
Loco_error_AdminNotices::debug( $e->getMessage() );
}
return false;
}
/**
* Get working credentials that resulted in connection
* @return array
*/
public function getOutputCredentials(){
return $this->creds_out;
}
/**
* Get input credentials from original post.
* this is not the same as getCredentials. It is designed for replay only, regardless of success
* Note that input to request_filesystem_credentials is not the same as the output (specifically how hostname:port is handled)
*/
public function getInputCredentials(){
return $this->creds_in;
}
/**
* Get currently configured filesystem API
* @return WP_Filesystem_Direct
*/
public function getFileSystem(){
if( ! $this->fs ){
return self::direct();
}
return $this->fs;
}
/**
* Check if a file is subject to WordPress automatic updates
* @param Loco_fs_File $file
* @return bool
*/
public function isAutoUpdatable( Loco_fs_File $file ){
// all paths safe from auto-updates if auto-updates are completely disabled
if( $this->isAutoUpdateDenied() ){
return false;
}
// Auto-updates aren't denied, so ascertain location "type" and run through the same filters as WP_Automatic_Updater::should_update()
$type = $file->getUpdateType();
if( '' !== $type ){
// Since 5.5.0: "{type}_s_auto_update_enabled" filters auto-update status for themes and plugins
// admins must also enable auto-updates on plugins and themes individually, but not checking that here.
if( function_exists('wp_is_auto_update_enabled_for_type') && ('plugin'===$type||'theme'===$type) ){
$enabled = (bool) apply_filters( "{$type}s_auto_update_enabled", true );
if( $enabled ){
// resolve given file to plugin/theme handle, so we can check if it's been enabled
$bundle = Loco_package_Bundle::fromFile($file);
if( $bundle instanceof Loco_package_Bundle ){
$handle = $bundle->getHandle();
$option = (array) get_site_option( "auto_update_{$type}s", [] );
// var_dump( compact('handle','option') );
if( ! in_array($handle,$option,true) ){
$enabled = false;
}
}
}
return $enabled;
}
// WordPress updater will have {item} from remote API data which we don't have here.
$item = new stdClass;
$item->new_files = false;
$item->autoupdate = true;
$item->disable_autoupdate = false;
return apply_filters( 'auto_update_'.$type, true, $item );
}
// else safe (not auto-updatable)
return false;
}
/**
* Check if system is configured to deny auto-updates
* @return bool
*/
public function isAutoUpdateDenied(){
// WordPress >= 4.8 can disable auto updates completely with "automatic_updater" context
if( function_exists('wp_is_file_mod_allowed') && ! wp_is_file_mod_allowed('automatic_updater') ){
return true;
}
// else simply observe AUTOMATIC_UPDATER_DISABLED constant
if( apply_filters( 'automatic_updater_disabled', loco_constant('AUTOMATIC_UPDATER_DISABLED') ) ) {
return true;
}
// else nothing explicitly denying updates
return false;
}
}

View File

@@ -0,0 +1,171 @@
<?php
/**
* Wrapper for WordPress language availability
*/
class Loco_api_WordPressTranslations {
/**
* Cache of whether network access is allowed
* @var bool
*/
private $enabled;
/**
* Cache of core locale objects
* @var Loco_Locale[]
*/
private $locales;
/**
* Cache of data returned from get_available_languages (not cached by WP)
* @var array
*/
private $installed;
/**
* Hash map of installed languages indexed by tag
* @var array
*/
private $installed_hash;
/**
* Wrap wp_get_available_translations
* @return array[]
*/
private function wp_get_available_translations(){
if( ! function_exists('wp_get_available_translations') ){
require_once ABSPATH.'wp-admin/includes/translation-install.php';
}
// WordPress will raise Warning if offline, and will cache result otherwise.
return wp_get_available_translations();
}
/**
* Get fully fledged locale objects from available core translation data
* @return Loco_Locale[]
*/
public function getAvailableCore(){
$locales = $this->locales;
if( is_null($locales) ){
$locales = [];
// get official locales from API if we have network
$cached = $this->wp_get_available_translations();
if( is_array($cached) && $cached ){
$english_name = 'english_name';
$native_name = 'native_name';
}
// else fall back to bundled data cached
else {
$english_name = 0;
$native_name = 1;
$cached = Loco_data_CompiledData::get('locales');
// debug so we can see on front end that data was offline
// $locales['en-debug'] = ( new Loco_Locale('en','','debug') )->setName('OFFLINE DATA');
}
/* @var string $tag */
foreach( $cached as $tag => $raw ){
$locale = Loco_Locale::parse($tag);
if( $locale->isValid() ){
$locale->setName( $raw[$english_name], $raw[$native_name] );
$locales[ (string) $locale ] = $locale;
}
/* Skip invalid language tags, e.g. "pt_PT_ao90" should be "pt_PT_ao1990"
* No point fixing invalid tags, because core translation files won't match.
else {
Loco_error_AdminNotices::debug( sprintf('Invalid locale: %s', $tag) );
}*/
}
$this->locales = $locales;
}
return $locales;
}
/**
* Wrap get_available_languages
* @return string[]
*/
public function getInstalledCore(){
// wp-includes/l10n.php should always be included at runtime
if( ! is_array($this->installed) ){
$this->installed = get_available_languages();
// en_US is implicitly installed
if( ! in_array('en_US',$this->installed) ){
array_unshift( $this->installed, 'en_US' );
}
}
return $this->installed;
}
/**
* @return array
*/
private function getInstalledHash(){
if( ! is_array($this->installed_hash) ){
$this->installed_hash = array_flip( $this->getInstalledCore() );
}
return $this->installed_hash;
}
/**
* Check if a given locale is installed
* @param string|Loco_Locale
* @return bool
*/
public function isInstalled( $locale ){
return array_key_exists( (string) $locale, $this->getInstalledHash() );
}
/**
* Get WordPress locale data by strictly well-formed language tag
* @param string $tag
* @return Loco_Locale
*/
public function getLocale( $tag ){
$all = $this->getAvailableCore();
return isset($all[$tag]) ? $all[$tag] : null;
}
/**
* Check whether remote API may be disabled for whatever reason, usually debugging.
* @return bool
*/
public function hasNetwork(){
if( is_null($this->enabled) ){
$this->enabled = (bool) apply_filters('loco_allow_remote', true );
}
return $this->enabled;
}
/**
* Wrapper for translations_api
* @param string
* @param array
* @return array[]
*/
public function apiGet( $type, array $args ){
if( ! function_exists('translations_api') ){
require_once ABSPATH.'wp-admin/includes/translation-install.php';
}
$response = translations_api($type,$args);
if( $response instanceof WP_Error ){
$message = 'Unknown error from translations_api';
foreach( $response->get_error_messages() as $message ){
Loco_error_AdminNotices::debug('translations_api error: '.$message);
}
throw new Loco_error_Exception($message);
}
return (array) $response;
}
}

View File

@@ -0,0 +1,133 @@
<?php
/**
* Loco Translate commands
* @codeCoverageIgnore
*/
class Loco_cli_Commands {
/**
* Sync translation files with the available source strings
*
* ## OPTIONS
*
* [<filter>]
* : Restrict to a type of bundle (plugins|themes|core); a single bundle (e.g. plugins:<handle>); or a Text Domain
*
* [--locale=<code>]
* : Restrict to one or more locales. Separate multiple codes with commas.
*
* [--fuzziness=<percent>]
* : Override plugin settings for fuzzy matching tolerance (0-100).
*
* [--noop]
* : Specify dry run. Makes no changes on disk.
*
* [--force]
* : Update even when nothing has changed. Useful for recompiling MO/JSON.
*
* ## EXAMPLES
*
* wp loco sync plugins
*
* @param string[] $args
* @param string[] $opts
*/
public function sync( $args, $opts ){
if( array_key_exists('fuzziness',$opts) ){
Loco_data_Settings::get()->fuzziness = (int) $opts['fuzziness'];
}
try {
Loco_cli_SyncCommand::run (
Loco_cli_Utils::collectProjects( isset($args[0]) ? $args[0] : '' ),
Loco_cli_Utils::collectLocales( isset($opts['locale']) ? $opts['locale'] : '' ),
Loco_cli_Utils::bool($opts,'noop'),
Loco_cli_Utils::bool($opts,'force')
);
}
catch( Loco_error_Exception $e ){
WP_CLI::error( $e->getMessage() );
}
}
/**
* Extract available source strings
*
* ## OPTIONS
*
* [<filter>]
* : Restrict to a type of bundle (plugins|themes|core); a single bundle (e.g. plugins:<handle>); or a Text Domain
*
* [--maxsize=<size>]
* : Override plugin settings for maximum PHP file size
*
* [--noop]
* : Specify dry run. Makes no changes on disk.
*
* [--force]
* : Update even when nothing has changed. Useful for updating meta properties.
*
* ## EXAMPLES
*
* wp loco extract core --maxsize=400K
*
* @param string[] $args
* @param string[] $opts
*/
public function extract( $args, $opts ){
try {
if( array_key_exists('maxsize',$opts) ){
Loco_data_Settings::get()->max_php_size = $opts['maxsize'];
}
Loco_cli_ExtractCommand::run (
Loco_cli_Utils::collectProjects( isset($args[0]) ? $args[0] : '' ),
Loco_cli_Utils::bool($opts,'noop'),
Loco_cli_Utils::bool($opts,'force')
);
}
catch( Loco_error_Exception $e ){
WP_CLI::error( $e->getMessage() );
}
}
/**
* EXPERIMENTAL. Attempts to install translation source files from an external repository.
* Use this to replace *installed* PO files if they are missing or have been purged of script translations.
*
* ## OPTIONS
*
* [<filter>]
* : Restrict to a type of bundle (plugins|themes|core); a single bundle (e.g. plugins:<handle>); or a Text Domain
*
* [--locale=<code>]
* : Restrict to one or more locales. Separate multiple codes with commas.
*
* [--trunk]
* : Install strings for upcoming dev version as opposed to latest stable
*
* ## EXAMPLES
*
* wp loco fetch loco-translate --locale=en_GB
*
* @param string[] $args
* @param string[] $opts
*/
public function fetch( $args, $opts ){
try {
Loco_cli_FetchCommand::run (
Loco_cli_Utils::collectProjects( isset($args[0]) ? $args[0] : '' ),
Loco_cli_Utils::collectLocales( isset($opts['locale']) ? $opts['locale'] : '' ),
[
'trunk' => Loco_cli_Utils::bool($opts,'trunk')
]
);
}
catch( Loco_error_Exception $e ){
WP_CLI::error( $e->getMessage() );
}
}
}

View File

@@ -0,0 +1,95 @@
<?php
/**
* Called from Loco_cli_Commands::extract
*/
abstract class Loco_cli_ExtractCommand {
/**
* @param Loco_package_Project[] $projects project filter
* @param bool $noop whether dry run
* @param bool $force whether to always update
*/
public static function run( array $projects, $noop = true, $force = false ){
if( $force && $noop ){
throw new Loco_error_Exception('--force makes no sense with --noop');
}
// track total number of POT files synced
$updated = 0;
$content_dir = loco_constant('WP_CONTENT_DIR');
foreach( $projects as $project ){
$id = rtrim( $project->getId(), '.' );
WP_CLI::log( sprintf('Extracting "%s" (%s)',$project->getName(),$id) );
// POT file may or may not exist currently
$potfile = $project->getPot();
if( ! $potfile ){
WP_CLI::warning('Skipping undefined POT');
continue;
}
if( $potfile->locked() ){
WP_CLI::warning('Skipping unwritable POT');
Loco_cli_Utils::tabulateFiles( $potfile->getParent(), $potfile );
continue;
}
// Do extraction and grab only given domain's strings
$ext = new Loco_gettext_Extraction( $project->getBundle() );
$domain = $project->getDomain()->getName();
$data = $ext->addProject($project)->includeMeta()->getTemplate( $domain );
Loco_cli_Utils::debug('Extracted %u strings', count($data) );
$list = $ext->getSkipped();
if( $list ){
$current = Loco_data_Settings::get()->max_php_size;
$suggest = ceil( $ext->getMaxPhpSize() / 1024 );
WP_CLI::warning(sprintf('%u source files skipped over %s. Consider running with --maxsize=%uK',count($list),$current,$suggest) );
foreach( $list as $file ) {
$f = new Loco_mvc_FileParams([],$file);
Loco_cli_Utils::debug('%s (%s)', $f->relpath, $f->size );
}
}
// if POT exists check if update is necessary.
$data->sort();
if( $potfile->exists() && ! $force ){
try {
Loco_cli_Utils::debug('Checking if sources have changed since '.date('c',$potfile->modified()) );
$prev = Loco_gettext_Data::fromSource( $potfile->getContents() );
if( $prev->equal($data) ){
WP_CLI::log('No update required for '.$potfile->basename() );
continue;
}
}
catch( Loco_error_ParseException $e ){
Loco_cli_Utils::debug( $e->getMessage().' in '.$potfile->basename() );
}
}
if( $noop ){
WP_CLI::success( sprintf('**DRY RUN** would update %s', $potfile->basename() ) );
continue;
}
// additional headers to set in new POT file
$head = $data->getHeaders();
$head['Project-Id-Version'] = $project->getName();
$head['X-Domain'] = $domain;
// write POT file to disk returning byte length
Loco_cli_Utils::debug('Writing POT file...');
$bytes = $potfile->putContents( $data->msgcat() );
Loco_cli_Utils::debug('%u bytes written to %s',$bytes, $potfile->getRelativePath($content_dir) );
WP_CLI::success( sprintf('Updated %s', $potfile->basename() ) );
$updated++;
}
// sync summary
if( 0 === $updated ){
WP_CLI::log('Nothing updated');
}
else {
WP_CLI::success( sprintf('%u POT files written',$updated) );
}
}
}

View File

@@ -0,0 +1,194 @@
<?php
/**
* Called from Loco_cli_Commands::fetch
*/
abstract class Loco_cli_FetchCommand {
/**
* @param Loco_package_Project[] $projects project filter
* @param Loco_Locale[] $locales locale filter
* @param bool[] $opts switches
*/
public static function run( array $projects, array $locales, array $opts ){
$wp = new Loco_api_WordPressTranslations;
$done = 0;
// fetch for every "installed" locale if none specified
if( ! $locales ){
foreach( $wp->getInstalledCore() as $tag ){
if( 'en_US' === $tag ){
continue;
}
$locale = Loco_Locale::parse($tag);
if( $locale->isValid() ){
$locales[] = $locale;
}
}
if( ! $locales ){
throw new Loco_error_Exception('No installed languages, try with --locale=<code>');
}
}
foreach( $projects as $project ){
$type = strtolower( $project->getBundle()->getType() );
$domain = $project->getDomain()->getName();
$info = $project->getBundle()->getHeaderInfo();
$version = $info->Version;
// Currently only supporting WordPress community translation sources.
$args = [ 'version' => $version ];
if( 'core' !== $type ){
$type.= 's';
if( $project->getSlug() !== $domain ){
WP_CLI::warning( sprintf('Skipping %s, only single text domain %s supported',$project->getId(),$type));
continue;
}
$args['slug'] = $domain;
}
WP_CLI::log( sprintf('Looking up %s v%s..',$project,$version) );
Loco_cli_Utils::debug('Querying WordPress translations API for %s => %s..',$type,json_encode($args) );
$result = $wp->apiGet($type,$args);
// pre-index installable language packs
$packages = [];
foreach( $result['translations'] as $data ){
$packages[$data['language']] = $data['package'];
}
// Translations API does not error when GlotPress project doesn't exist, it just returns empty.
if( ! $packages ){
Loco_cli_Utils::debug('No installable language packs available for %s. Checking if a GlotPress project exists..',$project);
// Ping GlotPress project page. This is the only way we can know if an incomplete project exists
$response = wp_remote_head( sprintf('https://translate.wordpress.org/projects/wp-%s/%s/',$type,$args['slug']) );
$status = wp_remote_retrieve_response_code($response);
if( 404 === $status ){
WP_CLI::warning( sprintf("Skipping %s: 404 from translate.wordpress.org. Probably no GlotPress project.",$project) );
continue;
}
else if( 200 !== $status ){
WP_CLI::warning( sprintf("Status %u from translate.wordpress.org. Skipping %s.",$status,$project) );
}
Loco_cli_Utils::debug('> Ok, looks like GlotPress project exists; probably no locales above the threshold for a package build');
}
// Save path is under "system" location because we are installing from GlotPress
$dir = new Loco_fs_Directory( 'core' === $type ? '.' : $type );
$dir->normalize( loco_constant('WP_LANG_DIR') );
foreach( $locales as $locale ){
$tag = (string) $locale;
if( 'en_US' == $tag ){
WP_CLI::warning('There are no translations in en_US. It is the source locale.');
continue;
}
// Map WP locale codes to GlotPress teams. They differ, naturally.
$team = $locale->lang;
if( $locale->region ){
$team.= '-'.strtolower($locale->region);
}
$gp = Loco_data_CompiledData::get('gp');
if( array_key_exists($team,$gp['aliases']) ){
$team = $gp['aliases'][$team];
}
// variant code (e.g. formal) is a sub-entity and not part of team language id
$variant = $locale->variant;
if( ! $variant ){
$variant = 'default';
}
if( 'core' === $type ){
// core projects are per-version. "dev" being upcoming. Then e.g. 5.6.x for stable
if( $opts['trunk'] || preg_match('/^\\d.\\d-(?:rc|dev|beta)/i',$version) ){
$slug = 'dev';
}
else {
list($major,$minor) = explode('.',$version,3);
$slug = sprintf('%u.%u.x',$major,$minor);
}
// Core projects are sub projects. plugins and themes don't have this
$map = [
'default.' => '',
'default.admin' => '/admin',
'default.admin-network' => '/admin/network',
'continents-cities' => '/cc',
];
$slug .= $map[ $project->getId() ];
$url = 'https://translate.wordpress.org/projects/wp/'.$slug.'/'.$team.'/'.$variant.'/export-translations/?format=po';
}
else {
$slug = $domain;
// plugins are either "stable" or "dev"; themes don't appear to have stability/version slug ??
if( 'plugins' === $type ) {
$slug .= $opts['trunk'] ? '/dev' : '/stable';
}
$url = 'https://translate.wordpress.org/projects/wp-'.$type.'/'.$slug.'/'.$team.'/' . $variant . '/export-translations/?format=po';
}
// Note that this export URL is not a documented API and may change without notice
// TODO We could pass If-Modified-Since with current PO file header, BUT that could not know if existing file is purged or not. Make configurable?
WP_CLI::log( sprintf('Fetching PO from %s..',$url));
$response = wp_remote_get($url);
$status = wp_remote_retrieve_response_code($response);
if( 200 !== $status ){
WP_CLI::warning( sprintf('Status %u from translate.wordpress.org; skipping "%s". Probably no translation team',$status,$tag) );
continue;
}
Loco_cli_Utils::debug('OK, last modified %s', wp_remote_retrieve_header($response,'last-modified') );
/*/ TODO fallback to installable package
if( $packages && ! array_key_exists($tag,$packages) ){
WP_ClI::warning( sprintf('%s is not installable in `%s` (probably not complete enough)',$project,$tag) );
}*/
// Parse PO data to check it's valid, and also because we're going to compile it.
$pobody = wp_remote_retrieve_body($response);
$podata = Loco_gettext_Data::fromSource($pobody);
$response = null;
// keep translations if file already exists in this location.
$pofile = $project->initLocaleFile($dir,$locale);
$info = new Loco_mvc_FileParams( [], $pofile );
Loco_cli_Utils::debug('Saving %s..', $info->relpath );
$compiler = new Loco_gettext_Compiler($pofile);
if( $pofile->exists() ){
$info = new Loco_mvc_FileParams( [], $pofile );
Loco_cli_Utils::debug('PO already exists at %s (%s), merging..',$info->relpath,$info->size);
$original = Loco_gettext_Data::load($pofile);
$matcher = new Loco_gettext_Matcher($project);
$matcher->loadRefs($podata,true);
// downloaded file is in memory can be replaced with merged version
$podata = clone $original;
$podata->clear();
$stats = $matcher->merge($original,$podata);
$original = null;
if( ! $stats['add'] && ! $stats['del'] && ! $stats['fuz'] && ! $stats['trn'] ){
WP_CLI::log( sprintf('%s unchanged in "%s". Skipping %s', $project,$locale,$info->relpath) );
continue;
}
// Overwrite merged PO, which will back up first if configured
Loco_cli_Utils::debug('OK: %u added, %u dropped, %u fuzzy', count($stats['add']), count($stats['del']), count($stats['fuz']) );
$podata->localize($locale);
$compiler->writePo($podata);
}
// Copy PO directly to disk as per remote source
else {
$compiler->writeFile($pofile,$pobody);
$podata->inheritHeader( Loco_gettext_Data::dummy()->localize($locale)->getHeaders() );
}
// Compile new MO and JSON files..
Loco_cli_Utils::debug('Compiling %s.{mo,json}',$pofile->filename() );
$compiler->writeMo($podata);
$compiler->writeJson($project,$podata);
$pofile->clearStat();
WP_CLI::success( sprintf('Fetched %s for "%s": %s PO at %s', $project,$locale,$info->size,$info->relpath) );
Loco_error_AdminNotices::get()->flush();
// clean up memory and ready for next file
unset($podata,$pobody);
$done++;
}
}
if( 0 === $done ){
WP_CLI::success('Completed OK, but no files were installed');
}
}
}

View File

@@ -0,0 +1,181 @@
<?php
/**
* Called from Loco_cli_Commands::sync
*/
abstract class Loco_cli_SyncCommand {
/**
* @param Loco_package_Project[] $projects project filter
* @param Loco_Locale[] $locales locale filter
* @param bool $noop whether dry run
* @param bool $force whether to always update
*/
public static function run( array $projects, array $locales, $noop = true, $force = false ){
if( $force && $noop ){
throw new Loco_error_Exception('--force makes no sense with --noop');
}
$content_dir = loco_constant('WP_CONTENT_DIR');
$wp_locales = new Loco_api_WordPressTranslations;
// track total number of PO files synced, plus MO and JSON files compiled
$updated = 0;
$compiled = 0;
foreach( $projects as $project ){
$id = rtrim( $project->getId(), '.' );
$base_dir = $project->getBundle()->getDirectoryPath();
WP_CLI::log( sprintf('Syncing "%s" (%s)',$project->getName(),$id) );
// Check if project has POT, which will be used as default template unless PO overrides
$pot = null;
$potfile = $project->getPot();
if( $potfile && $potfile->exists() ){
Loco_cli_Utils::debug('Parsing template: %s',$potfile->getRelativePath($content_dir));
try {
$pot = Loco_gettext_Data::fromSource( $potfile->getContents() );
}
catch( Loco_error_ParseException $e ){
WP_CLI::error( $e->getMessage().' in '.$potfile->getRelativePath($content_dir), false );
$potfile = null;
}
}
/* @var Loco_fs_LocaleFile $pofile */
$pofiles = $project->findLocaleFiles('po');
foreach( $pofiles as $pofile ){
$locale = $pofile->getLocale();
$tag = (string) $locale;
if( $locales && ! array_key_exists($tag,$locales) ){
continue;
}
// Preempt write errors and print useful file mode info
$mofile = $pofile->cloneExtension('mo');
if( ! $pofile->writable() || $mofile->locked() ){
WP_CLI::warning('Skipping unwritable: '.self::fname($pofile) );
Loco_cli_Utils::tabulateFiles( $pofile->getParent(), $pofile, $mofile );
continue;
}
// Parsing candidate PO file (definitions)
Loco_cli_Utils::debug('Parsing PO: %s',$pofile->getRelativePath($content_dir));
try {
$def = Loco_gettext_Data::fromSource( $pofile->getContents() );
}
catch( Loco_error_ParseException $e ){
WP_CLI::error( $e->getMessage().' in '.$pofile->getRelativePath($content_dir), false );
continue;
}
// Check if PO defines alternative template (reference)
$ref = $pot;
$head = $def->getHeaders();
$opts = new Loco_gettext_SyncOptions($head);
$translate = $opts->mergeMsgstr();
if( $opts->hasTemplate() ){
$ref = null;
$potfile = $opts->getTemplate();
$potfile->normalize( $base_dir );
if( $potfile->exists() ){
try {
Loco_cli_Utils::debug('> Parsing alternative template: %s',$potfile->getRelativePath($content_dir) );
$ref = Loco_gettext_Data::fromSource( $potfile->getContents() );
}
catch( Loco_error_ParseException $e ){
WP_CLI::error( $e->getMessage().' in '.$potfile->getRelativePath($content_dir), false );
}
}
else {
Loco_cli_Utils::debug('Template not found (%s)', $potfile->basename() );
}
}
if( ! $ref ){
WP_CLI::warning( sprintf('Skipping %s; no valid translation template',$pofile->getRelativePath($content_dir) ) );
continue;
}
// Perform merge if we have a reference file
Loco_cli_Utils::debug('Merging %s <- %s', $pofile->basename(), $potfile->basename() );
$matcher = new Loco_gettext_Matcher($project);
$matcher->loadRefs($ref,$translate );
// Merge jsons if configured and available
if( $opts->mergeJson() ){
$siblings = new Loco_fs_Siblings( $potfile->cloneBasename( $pofile->basename() ) );
$jsons = $siblings->getJsons( $project->getDomain()->getName() );
$njson = $matcher->loadJsons($jsons);
Loco_cli_Utils::debug('> merged %u json files', $njson );
}
// Get fuzzy matching tolerance from plugin settings, can be set temporarily in command line
$fuzziness = Loco_data_Settings::get()->fuzziness;
$matcher->setFuzziness( (string) $fuzziness );
// update matches sources, deferring unmatched for deferred fuzzy match
$po = clone $def;
$po->clear();
$nvalid = count( $matcher->mergeValid($def,$po) );
$nfuzzy = count( $matcher->mergeFuzzy($po) );
$nadded = count( $matcher->mergeAdded($po) );
$ndropped = count( $matcher->redundant() );
// TODO Support --previous to keep old strings, or at least comment them out as #| msgid.....
if( $nfuzzy || $nadded || $ndropped ){
Loco_cli_Utils::debug('> unchanged:%u added:%u fuzzy:%u dropped:%u', $nvalid, $nadded, $nfuzzy, $ndropped );
}
else {
Loco_cli_Utils::debug('> %u identical sources',$nvalid);
}
// File is synced, but may be identical
$po->sort();
if( ! $force && $po->equal($def) ){
WP_CLI::log( sprintf('No update required for %s', self::fname($pofile) ) );
continue;
}
if( $noop ){
WP_CLI::success( sprintf('**DRY RUN** would update %s', self::fname($pofile) ) );
continue;
}
try {
$locale->ensureName($wp_locales);
$po->localize($locale);
$compiler = new Loco_gettext_Compiler($pofile);
$bytes = $compiler->writePo($po);
Loco_cli_Utils::debug('+ %u bytes written to %s',$bytes, $pofile->basename());
$updated++;
// compile MO
$bytes = $compiler->writeMo($po);
if( $bytes ){
Loco_cli_Utils::debug('+ %u bytes written to %s',$bytes, $mofile->basename());
$compiled++;
}
// Done PO/MO pair, now generate JSON fragments as applicable
$jsons = $compiler->writeJson($project,$po);
foreach( $jsons as $file ){
$compiled++;
$param = new Loco_mvc_FileParams([],$file);
Loco_cli_Utils::debug('+ %u bytes written to %s',$param->size,$param->name);
}
// Done compile of this set
Loco_error_AdminNotices::get()->flush();
WP_CLI::success( sprintf('Updated %s', self::fname($pofile) ) );
}
catch( Loco_error_WriteException $e ){
WP_CLI::error( $e->getMessage(), false );
}
}
}
// sync summary
if( 0 === $updated ){
WP_CLI::log('Nothing updated');
}
else {
WP_CLI::success( sprintf('%u PO files synced, %u files compiled',$updated,$compiled) );
}
}
/**
* Debug file name showing directory location
* @param Loco_fs_File
* @return string
*/
private static function fname( Loco_fs_File $file ){
$dir = new Loco_fs_LocaleDirectory( $file->dirname() );
return $file->filename().' ('.$dir->getTypeLabel( $dir->getTypeId() ).')';
}
}

View File

@@ -0,0 +1,161 @@
<?php
/**
* Utility functions for wp cli commands
*/
abstract class Loco_cli_Utils {
/**
* Collect translation sets according to type/domain filter
* @return Loco_package_Project[]
*/
public static function collectProjects( $filter ):array {
$projects = [];
$domain = null;
$slug = null;
// bundle type filter, with optional argument
if( preg_match('/^(plugins|themes|core)(?::(.+))?/i',$filter,$matched) ){
$type = strtolower($matched[1]);
$handle = isset($matched[2]) ? $matched[2] : '';
if( 'plugins' === $type ){
if( $handle ){
$bundles = [ Loco_package_Plugin::create($handle) ];
}
else {
$bundles = Loco_package_Plugin::getAll();
}
}
else if( 'themes' === $type ){
if( $handle ){
$bundles = [ Loco_package_Theme::create($handle) ];
}
else {
$bundles = Loco_package_Theme::getAll();
}
}
else {
$bundles = [ Loco_package_Core::create() ];
$slug = $handle;
}
}
// else fall back to text domain filter
else {
$domain = $filter;
$bundles = [ Loco_package_Core::create() ];
$bundles = array_merge( $bundles, Loco_package_Plugin::getAll() );
$bundles = array_merge( $bundles, Loco_package_Theme::getAll() );
}
/* @var Loco_package_Project $project */
foreach( $bundles as $bundle ){
foreach( $bundle as $project ){
if( $domain && $project->getDomain()->getName() !== $domain ){
continue;
}
if( $slug && $project->getSlug() !== $slug ){
continue;
}
$projects[] = $project;
}
}
if( ! $projects ){
throw new Loco_error_Exception('No translation sets found');
}
return $projects;
}
/**
* Collect locales from one or more language tags
* @param string zero or more language tags
* @return Loco_Locale[]
*/
public static function collectLocales( $tags ){
$locales = [];
if( '' !== $tags ){
$api = new Loco_api_WordPressTranslations;
foreach( preg_split('/[\\s,;]+/i',$tags,-1,PREG_SPLIT_NO_EMPTY) as $tag ){
$locale = Loco_Locale::parse($tag);
if( ! $locale->isValid() ){
throw new Loco_error_Exception('Invalid locale: '.json_encode($tag) );
}
// TODO could expand language-only tags to known WordPress locales e.g. fr -> fr_FR
$locales[ (string) $locale ] = $locale;
$locale->ensureName($api);
}
// empty locales means ALL locales, so refuse to return ALL when filter was non-empty
if( 0 === count($locales) ){
throw new Loco_error_Exception('No valid locales in: '.json_encode($tags) );
}
}
return $locales;
}
/**
* Simple space-padded table
* @param string[][] data rows to print
*/
public static function tabulate( array $t ){
$w = [];
foreach( $t as $y => $row ){
foreach( $row as $x => $value ){
$width = mb_strlen($value,'UTF-8');
$w[$x] = isset($w[$x]) ? max($w[$x],$width) : $width;
}
}
foreach( $t as $y => $row ){
$line = [];
foreach( $w as $x => $width ){
$value = isset($row[$x]) ? $row[$x] : '';
$value = str_pad($value,$width,' ',STR_PAD_RIGHT);
$line[] = $value;
}
self::debug( implode(' ',$line) );
}
}
/**
* Prints file listing to stdout
*/
public static function tabulateFiles(){
$t = [];
/* @var Loco_fs_File $file */
foreach( func_get_args() as $file ){
if( $file instanceof Loco_fs_File && $file->exists() ){
$f = new Loco_mvc_FileParams([],$file);
$t[] = [ $f->owner, $f->group, $f->smode, $f->relpath ];
}
}
self::tabulate($t);
}
/**
* WP_CLI debug logger
*/
public static function debug(){
$args = func_get_args();
$message = array_shift($args);
if( $args ){
$message = vsprintf($message,$args);
}
WP_CLI::debug( $message,'loco' );
}
/**
* Parse boolean command line option. Absence is equal to false
* @param string[]
* @param string
* @return bool
*/
public static function bool( array $opts, $key ){
$value = isset($opts[$key]) ? $opts[$key] : false;
if( ! is_bool($value) ){
$value = $value && 'false' !== $value & 'no' !== $value;
}
return $value;
}
}

View File

@@ -0,0 +1,20 @@
<?php
/**
* Placeholder for missing PHP "ctype" extension.
*/
abstract class Loco_compat_CtypeExtension {
public static function digit( $value ){
return 1 === preg_match('/^[0-9]+$/',$value);
}
}
// @codeCoverageIgnoreStart
if( ! function_exists('ctype_digit') ){
function ctype_digit( $value ){
return Loco_compat_CtypeExtension::digit( $value );
}
}

View File

@@ -0,0 +1,23 @@
<?php
/**
* Class containing reasons for total incompatibility with current WordPress environment.
* It won't be loaded unless total failure occurs
*
* @codeCoverageIgnore
*/
abstract class Loco_compat_Failure {
/**
* "admin_notices" callback, renders failure notice if plugin failed to start up admin hooks.
* If this is hooked and not unhooked then auto-hooks using annotations have failed.
*/
public static function print_hook_failure(){
$texts = [ 'Loco Translate failed to start up' ];
/*/ Hooks currently not using annotations (would be if we enabled @priority tag)
if( ini_get('opcache.enable') && ( ! ini_get('opcache.save_comments') || ! ini_get('opcache.load_comments') ) ){
$texts[] = 'Try configuring opcache to preserve comments';
}*/
echo '<div class="notice error"><p><strong>Error:</strong> '.implode('. ',$texts).'</p></div>';
}
}

View File

@@ -0,0 +1,33 @@
<?php
/**
* Placeholder for missing PHP "json" extension.
* Just avoids fatal errors. Does not actually replace functionality.
*
* If this extension is missing no JavaScript will work in the plugin at all.
*/
abstract class Loco_compat_JsonExtension {
public static function json_encode( $value ){
return '{"error":{"code":-1,"message":"json extension is not installed"}}';
}
public static function json_decode( $json ){
return null;
}
}
// @codeCoverageIgnoreStart
if( ! function_exists('json_encode') ){
function json_encode( $value ){
return Loco_compat_JsonExtension::json_encode( $value );
}
}
if( ! function_exists('json_decode') ){
function json_decode( $json ){
return Loco_compat_JsonExtension::json_decode($json);
}
}

View File

@@ -0,0 +1,88 @@
<?php
/**
* Placeholder for missing PHP "mbstring" extension.
* Just avoids fatal errors. Does not actually replace functionality.
*
* If the mbstring library is missing any PO files that aren't UTF-8 encoded will result in parsing failures.
*/
abstract class Loco_compat_MbstringExtension {
public static function mb_detect_encoding( $str, ?array $encoding_list = null, bool $strict = false ):string {
return ! $str || preg_match('//u',$str)
? 'UTF-8'
: 'ISO-8859-1'
;
}
public static function mb_list_encodings():array {
return ['UTF-8','ISO-8859-1'];
}
public static function mb_strlen( $str, ?string $encoding = null ):int {
static $warned = false;
if( ! $warned && preg_match('/[\\x80-\\xFF]/',$str) ){
trigger_error('Character counts will be wrong without mbstring extension',E_USER_WARNING);
$warned = true;
}
return strlen($str);
}
public static function mb_convert_encoding( string $str, string $to_encoding, string $from_encoding ){
if( $to_encoding !== $from_encoding && '' !== $str ){
// loco_convert_utf8 no longer uses mb_convert_encoding for UTF8->latin1
if( '' === $from_encoding || 'ISO-8859-1' === $from_encoding || 'cp1252' === $from_encoding ){
if( '' === $to_encoding || 'UTF-8' === $to_encoding || 'US-ASCII' === $to_encoding ){
if( function_exists('loco_fix_utf8') ) {
return loco_fix_utf8( $str );
}
}
}
trigger_error('Unable to convert from '.$from_encoding.' to '.$to_encoding.' without mbstring', E_USER_NOTICE );
}
return $str;
}
public static function mb_strtolower( string $str ):string {
return strtolower($str);
}
}
// @codeCoverageIgnoreStart
if( ! function_exists('mb_detect_encoding') ){
function mb_detect_encoding( string $str = '', array $encoding_list = [], bool $strict = false ):string {
return Loco_compat_MbstringExtension::mb_detect_encoding( $str, $encoding_list, $strict );
}
}
if( ! function_exists('mb_list_encodings') ){
function mb_list_encodings():array {
return Loco_compat_MbstringExtension::mb_list_encodings();
}
}
if( ! function_exists('mb_strlen') ){
function mb_strlen( string $str, ?string $encoding = null ):int {
return Loco_compat_MbstringExtension::mb_strlen( $str, $encoding );
}
}
if( ! function_exists('mb_convert_encoding') ){
function mb_convert_encoding( string $str, string $to_encoding, ?string $from_encoding = null ){
return Loco_compat_MbstringExtension::mb_convert_encoding( $str, $to_encoding, (string) $from_encoding );
}
}
if( ! function_exists('mb_encoding_aliases') ){
function mb_encoding_aliases(){
return false;
}
}
if( ! function_exists('mb_strtolower') ){
function mb_strtolower( $str ){
return Loco_compat_MbstringExtension::mb_strtolower($str);
}
}

View File

@@ -0,0 +1,119 @@
<?php
/**
* Abstraction of PHP "posix" extension.
* Basic functionality substitution, but cannot get user/group names so falls back to numeric
*/
abstract class Loco_compat_PosixExtension {
/**
* @var int|null
*/
private static $uid = null;
/**
* @var int|null
*/
private static $gid = null;
/**
* @return int
*/
public static function getuid(){
if( is_null(self::$uid) ){
// use posix function if extension available
if( function_exists('posix_geteuid') ){
self::$uid = posix_geteuid();
}
// else use temp file system to establish owner
else {
self::$uid = self::getuidViaTempDir(); // @codeCoverageIgnore
}
}
return self::$uid;
}
/**
* @return int
*/
public static function getgid(){
if( is_null(self::$gid) ){
// use posix function if extension available
if( function_exists('posix_getegid') ){
self::$gid = posix_getegid();
}
// else use temp file system to establish group owner
else {
self::$gid = self::getgidViaTempDir(); // @codeCoverageIgnore
}
}
return self::$gid;
}
/**
* Attempt to get effective user ID by reading a temporary file
* @return int
*/
public static function getuidViaTempDir( $dir = '' ){
if( ! $dir ) {
$dir = get_temp_dir();
}
if( 04000 & fileperms($dir) ){
trigger_error( sprintf('%s directory has setuid bit, getuid may not be accurate',basename($dir) ) );
}
$path = wp_tempnam( 'loco-sniff-'.time(), $dir );
$uid = fileowner($path);
unlink( $path );
return $uid;
}
/**
* Attempt to get effective group ID by reading a temporary file
* @return int
*/
public static function getgidViaTempDir( $dir = '' ){
if( ! $dir ) {
$dir = get_temp_dir();
}
if( 02000 & fileperms($dir) ){
trigger_error( sprintf('%s directory has setgid bit, getgid may not be accurate',basename($dir) ) );
}
$path = wp_tempnam( 'loco-sniff-'.time(), $dir );
$gid = filegroup($path);
unlink( $path );
return $gid;
}
/**
* Get the name of the user that the web server runs under
* This is only for visual/info purposes.
* @return string
*/
public static function getHttpdUser(){
if( function_exists('posix_getpwuid') ){
$info = posix_getpwuid( self::getuid() );
if( isset($info['name']) ){
return $info['name'];
}
}
// @codeCoverageIgnoreStart
foreach( ['apache','nginx'] as $name ){
if( false !== stripos(PHP_SAPI,$name) ){
return $name;
}
if( isset($_SERVER['SERVER_SOFTWARE']) && false !== stripos($_SERVER['SERVER_SOFTWARE'],$name) ){
return $name;
}
}
// translators: used when username of web server process is unknown
return __('the web server','loco-translate');
// @codeCoverageIgnoreEnd
}
}

View File

@@ -0,0 +1,24 @@
<?php
/**
* Placeholder for missing PHP "tokenizer" extension.
* Just avoids fatal errors. Does not actually replace functionality.
*
* If this extension is missing PHP string extraction will not work at all.
*/
abstract class Loco_compat_TokenizerExtension {
public static function token_get_all( $value ){
return [];
}
}
// @codeCoverageIgnoreStart
if( ! function_exists('token_get_all') ){
function token_get_all( $value ){
return Loco_compat_TokenizerExtension::token_get_all($value);
}
}

View File

@@ -0,0 +1,439 @@
<?php
/**
* Holds a bundle definition in a structure serializable to a native array.
*/
class Loco_config_ArrayModel extends Loco_config_Model {
/**
* @var LocoConfigDocument
*/
private $dom;
/**
* {@inheritdoc}
*/
public function createDom(){
$this->dom = new LocoConfigDocument( ['#document', [], [] ] );
}
/**
* @return LocoConfigDocument
*/
public function getDom(){
return $this->dom;
}
/**
* Construct model from serialized JSON
* @return void
*/
public function loadJson( $json ){
$root = json_decode( $json, true );
if( ! $root || ! is_array($root) ){
throw new Loco_error_ParseException('Invalid JSON');
}
$this->loadArray( $root );
}
/**
* Construct model from exported array
* @return void
*/
public function loadArray( array $root ){
$dom = $this->getDom();
$dom->load( ['#document', [], [$root] ] );
}
/**
* {@inheritdoc}
* Emulates *very limited* XPath queries used by the XML DOM.
*/
public function query( $query, $context = null ){
$match = new LocoConfigNodeList;
$query = explode('/', $query );
// absolute path always starts in document
if( empty($query[0]) ){
$match->append( $this->getDom() );
}
// else start with base for relative path
else if( $context instanceof LocoConfigNode ){
$match->append( $context );
}
while( $query ){
$name = array_shift($query);
// self references do nothing
if( ! $name || '.' === $name ){
continue;
}
// match all current branches to produce new set of parents
$next = new LocoConfigNodeList;
foreach( $match as $parent ){
foreach( $parent->childNodes as $child ){
if( $name === $child->nodeName || ( '*' === $name && $child instanceof LocoConfigElement ) || ( 'text()' === $name && $child instanceof LocoConfigText) ){
$next->append( $child );
}
}
}
$match = $next;
}
return $match;
}
}
// The following classes are "private" to this file:
// They partially implement the same interfaces as the core DOM classes and are used for code hints.
// Interfaces are deliberately not used as the real DOM classes would not be able to implement them.
/**
* Node
* @property-read string $textContent
* @property-read string $nodeName
* @property-read LocoConfigNodeList $childNodes
*/
abstract class LocoConfigNode implements IteratorAggregate {
/**
* Raw data of internal format
* @var array|string
*/
protected $data;
/**
* Child nodes once cast to node objects
* @var LocoConfigNodeList
*/
protected $children;
/**
* @return mixed
*/
abstract public function export();
/**
* @param array|string $data
*/
final public function __construct( $data ){
$this->data = $data;
}
/**
* @internal
*/
protected function get_nodeName(){
return $this->data[0];
}
/**
* @internal
*/
protected function get_childNodes(){
return $this->getIterator();
}
public function __get( $prop ){
$method = [ $this, 'get_'.$prop ];
if( is_callable($method) ){
return call_user_func( $method );
}
return null;
}
/** @return LocoConfigNode */
public function appendChild( LocoConfigNode $child ){
$children = $this->getIterator();
$children->append( $child );
return $child;
}
/** @return bool */
public function hasChildNodes(){
return (bool) count( $this->getIterator() );
}
/**
* @return LocoConfigNodeList
*/
#[ReturnTypeWillChange]
public function getIterator(){
if( ! $this->children ){
//$raw = isset($this->data[2]) ? $this->data[2] : array();
$this->children = new LocoConfigNodeList( $this->data[2] );
}
return $this->children;
}
public function get_textContent(){
$s = '';
foreach( $this as $child ){
$s .= $child->get_textContent();
}
return $s;
}
}
/**
* NodeList
*/
class LocoConfigNodeList implements Iterator, Countable, ArrayAccess {
private $nodes;
private $i;
private $n;
public function __construct( array $nodes = [] ){
$this->nodes = $nodes;
$this->n = count( $nodes );
}
#[ReturnTypeWillChange]
public function count(){
return $this->n;
}
#[ReturnTypeWillChange]
public function rewind(){
$this->i = -1;
$this->next();
}
#[ReturnTypeWillChange]
public function key(){
return $this->i;
}
#[ReturnTypeWillChange]
public function current(){
return $this[ $this->i ];
}
#[ReturnTypeWillChange]
public function valid(){
return is_int($this->i);
}
#[ReturnTypeWillChange]
public function next(){
if( ++$this->i === $this->n ){
$this->i = null;
}
}
#[ReturnTypeWillChange]
public function offsetExists( $offset ){
return $offset >= 0 && $offset < $this->n;
}
#[ReturnTypeWillChange]
public function offsetGet( $offset ){
$node = $this->nodes[$offset];
if( ! $node instanceof LocoConfigNode ){
if( is_array($node) ){
$node = new LocoConfigElement( $node );
}
else {
$node = new LocoConfigText( $node );
}
$this->nodes[$offset] = $node;
}
return $node;
}
/**
* @codeCoverageIgnore
*/
#[ReturnTypeWillChange]
public function offsetSet( $offset, $value ){
throw new LogicException('Use append');
}
/**
* @codeCoverageIgnore
*/
#[ReturnTypeWillChange]
public function offsetUnset( $offset ){
throw new LogicException('Read only');
}
public function append( LocoConfigNode $node ){
$this->nodes[] = $node;
$this->n++;
}
/**
* Revert nodes back to raw array form and return for exporting
* @return array
*/
public function normalize(){
foreach( $this->nodes as $i => $node ){
if( $node instanceof LocoConfigNode ){
$this->nodes[$i] = $node->export();
}
}
return $this->nodes;
}
}
/**
* Document
* @property-read LocoConfigElement $documentElement
*/
class LocoConfigDocument extends LocoConfigNode {
/**
* Rapidly set new data for document
*/
public function load( $data ){
$this->data = $data;
$this->children = null;
}
/**
* @return LocoConfigElement
*/
public function createElement( $name ){
return new LocoConfigElement( [ $name, [], [] ] );
}
/**
* @return LocoConfigText
*/
public function createTextNode( $text ){
return new LocoConfigText( $text );
}
/**
* @return LocoConfigElement
*/
public function get_documentElement(){
$child = null;
foreach( $this as $child ){
break;
}
return $child;
}
/**
* {@inheritdoc}
* Override to keep single element root
*/
public function export(){
$root = $this->get_documentElement();
return $root ? $root->export() : null;
}
}
/**
* Element
*/
class LocoConfigElement extends LocoConfigNode {
public function setAttribute( $prop, $value ){
$this->data[1][$prop] = $value;
}
public function removeAttribute( $prop ){
unset( $this->data[1][$prop] );
}
public function getAttribute( $prop ){
if( isset($this->data[1][$prop]) ){
return $this->data[1][$prop];
}
return '';
}
public function hasAttribute( $prop ){
return isset($this->data[1][$prop]);
}
/**
* {@inheritdoc}
*/
public function export(){
$raw = $this->data;
// return any cast elements back to raw data
if( $this->children ){
$raw[2] = $this->children->normalize();
}
return $raw;
}
}
/**
* Text node
* @property-read string $nodeValue
* @property-read string $textContent
*/
class LocoConfigText extends LocoConfigNode {
protected function get_nodeName(){
return '#text';
}
public function hasChildNodes(){
return false;
}
public function getIterator(){
return new ArrayIterator;
}
public function export(){
return (string) $this->data;
}
/** @internal */
public function get_nodeValue(){
return (string) $this->data;
}
/** @internal */
public function get_textContent(){
return (string) $this->data;
}
}

View File

@@ -0,0 +1,171 @@
<?php
/**
* Loads Loco configuration file into a bundle definition
*/
class Loco_config_BundleReader {
/**
* @var Loco_package_Bundle
*/
private $bundle;
/**
* Constructor initializes empty dom
*/
public function __construct( Loco_package_Bundle $bundle ){
$this->bundle = $bundle;
}
/**
* @return Loco_package_Bundle
*/
public function loadXml( Loco_fs_File $file ){
$this->bundle->setDirectoryPath( $file->dirname() );
$model = new Loco_config_XMLModel;
$model->loadXml( $file->getContents() );
return $this->loadModel( $model );
}
/**
* @return Loco_package_Bundle
*/
public function loadJson( Loco_fs_File $file ){
$this->bundle->setDirectoryPath( $file->dirname() );
return $this->loadArray( json_decode( $file->getContents(), true ) );
}
/**
* @return Loco_package_Bundle
*/
public function loadArray( array $raw ){
$model = new Loco_config_ArrayModel;
$model->loadArray( $raw );
return $this->loadModel( $model );
}
/**
* Agnostic construction of Bundle from any configuration format
* @return Loco_package_Bundle
*/
public function loadModel( Loco_config_Model $model ){
// Base directory required to resolve relative paths
$bundle = $this->bundle;
$model->setDirectoryPath( $bundle->getDirectoryPath() );
$dom = $model->getDom();
$bundleElement = $dom->documentElement;
if( ! $bundleElement || 'bundle' !== $bundleElement->nodeName ){
throw new InvalidArgumentException('Expected root bundle element');
}
// Set bundle meta data if configured
// note that bundles have no inherent slug as it can change according to plugin/theme directory naming
if( $bundleElement->hasAttribute('name') ){
$bundle->setName( $bundleElement->getAttribute('name') );
}
// Bundle-level path exclusions
foreach( $model->query('exclude/*',$bundleElement) as $fileElement ){
$bundle->excludeLocation( $model->evaluateFileElement($fileElement) );
}
/* @var $domainElement LocoConfigElement */
foreach( $model->query('domain',$bundleElement) as $domainElement ){
$slug = $domainElement->getAttribute('name') or $slug = $bundle->getSlug();
// bundle may not have a handle set (most likely only in tests)
if( ! $bundle->getHandle() ){
$bundle->setHandle( $slug );
}
// Text Domain may also be declared by bundle author
$domain = new Loco_package_TextDomain( $slug );
$declared = $bundle->getHeaderInfo();
if( $declared && $declared->TextDomain === $slug ){
$domain->setCanonical( true );
}
/* @var $projectElement LocoConfigElement */
foreach( $model->query('project',$domainElement) as $projectElement ){
$name = $projectElement->getAttribute('name') or $name = $bundle->getName();
$project = new Loco_package_Project( $bundle, $domain, $name );
if( $projectElement->hasAttribute('slug') ){
$project->setSlug( $projectElement->getAttribute('slug') );
}
// <source>
foreach( $model->query('source',$projectElement) as $sourceElement ){
// sources may be <file>, <directory> or pass in special <path> if it could be either
foreach( $model->query('file',$sourceElement) as $fileElement ){
$project->addSourceFile( $model->evaluateFileElement($fileElement) );
}
foreach( $model->query('directory',$sourceElement) as $fileElement ){
$project->addSourceDirectory( $model->evaluateFileElement($fileElement) );
}
foreach( $model->query('path',$sourceElement) as $fileElement ){
$project->addSourceLocation( $model->evaluateFileElement($fileElement) );
}
foreach( $model->query('exclude/*', $sourceElement) as $fileElement ){
$project->excludeSourcePath( $model->evaluateFileElement($fileElement) );
}
}
// Avoid having no source locations
if( ! $project->hasSourceFiles() ){
if( $bundle->isSingleFile() ){
$project->addSourceFile( $bundle->getBootstrapPath() );
}
else {
$project->addSourceDirectory( $bundle->getDirectoryPath() );
}
}
// <target>
foreach( $model->query('target',$projectElement) as $targetElement ){
// targets support only directory paths:
foreach( $model->query('directory',$targetElement) as $fileElement ){
$project->addTargetDirectory( $model->evaluateFileElement($fileElement) );
}
foreach( $model->query('exclude/*', $targetElement) as $fileElement ){
$project->excludeTargetPath( $model->evaluateFileElement($fileElement) );
}
}
// Avoid having no target locations ..
if( 0 === count($project->getConfiguredTargets() ) ){
// .. unless the inherited root is a global location
if( $bundle->isTheme() || ( $bundle->isPlugin() && ! $bundle->isSingleFile() ) ){
$project->addTargetDirectory( $bundle->getDirectoryPath() );
}
}
// <template>
// configure POT file, should only be one
foreach( $model->query('template',$projectElement) as $templateElement ){
if( $model->evaluateBooleanAttribute( $templateElement, 'locked') ){
$project->setPotLock( true );
}
foreach( $model->query('file',$templateElement) as $fileElement ){
$project->setPot( $model->evaluateFileElement( $fileElement ) );
break 2;
}
}
// add project last for additional configs to be appended
$bundle->addProject( $project );
}
}
return $bundle;
}
}

View File

@@ -0,0 +1,163 @@
<?php
/**
*
*/
class Loco_config_BundleWriter implements JsonSerializable {
/**
* @var Loco_package_Bundle
*/
private $bundle;
/**
* Initialize config from the bundle it will describe
*/
public function __construct( Loco_package_Bundle $bundle ){
$this->bundle = $bundle;
}
/**
* @return string XML source
*/
public function toXml(){
$model = new Loco_config_XMLModel;
$dom = $this->compile($model);
return $dom->saveXML();
}
/**
* @return array
*/
public function toArray(){
$model = new Loco_config_ArrayModel;
$dom = $this->compile($model);
return $dom->export();
}
/**
* @return Loco_mvc_PostParams
*/
public function toForm(){
$model = new Loco_config_FormModel;
$this->compile($model);
return $model->getPost();
}
/**
* Alias of toArray implementing JsonSerializable
* @return array
*/
#[ReturnTypeWillChange]
public function jsonSerialize(){
return $this->toArray();
}
/**
* Agnostic compilation of any config data type
* @return LocoConfigDocument
*/
private function compile( Loco_config_Model $model ){
$bundle = $this->bundle;
$model->setDirectoryPath( $bundle->getDirectoryPath() );
$systemTargets = $bundle->getSystemTargets();
$dom = $model->getDom();
$root = $dom->createElement('bundle') ;
$dom->appendChild($root);
$root->setAttribute( 'name', $bundle->getName() );
/*/ additional headers for information only (not read back in)
if( $value = $bundle->getHeaderInfo()->getVendorHost() ){
$root->setAttribute( 'vendor', $value );
}*/
foreach( $bundle->exportGrouped() as $domainName => $projects ){
$domainElement = $dom->createElement('domain');
$root->appendChild( $domainElement );
$domainElement->setAttribute( 'name', $domainName );
/* @var $proj Loco_package_Project */
foreach( $projects as $proj ){
$projElement = $dom->createElement('project');
$domainElement->appendChild($projElement);
// add project name even if it's the same as the bundle name
// when loading however, missing name will default to bundle name
$value = $proj->getName() or $value = $bundle->getName();
$projElement->setAttribute( 'name', $value );
// add project slug even if it's the same as the domain name
$value = $proj->getSlug();
$projElement->setAttribute( 'slug', $value );
// <source>
// zero or more source file locations
$sourcesElement = $dom->createElement('source');
/* @var Loco_fs_File $file */
foreach( $proj->getConfiguredSources() as $file ){
$sourcesElement->appendChild( $model->createFileElement($file) );
}
// zero or more excluded source paths
$excludeElement = $dom->createElement('exclude');
foreach( $proj->getConfiguredSourcesExcluded() as $file ){
$excludeElement->appendChild( $model->createFileElement($file) );
}
if( $excludeElement->hasChildNodes() ){
$sourcesElement->appendChild($excludeElement);
}
if( $sourcesElement->hasChildNodes() ){
$projElement->appendChild( $sourcesElement );
}
// <target>
// add zero or more target locations
$targetsElement = $dom->createElement('target');
/* @var $file Loco_fs_Directory */
foreach( $proj->getConfiguredTargets() as $file ){
if( ! in_array( $file->getPath(), $systemTargets, true ) ){
$targetsElement->appendChild( $model->createFileElement($file) );
}
}
// zero or more excluded targets
$excludeElement = $dom->createElement('exclude');
foreach( $proj->getConfiguredTargetsExcluded() as $file ){
$excludeElement->appendChild( $model->createFileElement($file) );
}
if( $excludeElement->hasChildNodes() ){
$targetsElement->appendChild($excludeElement);
}
if( $targetsElement->hasChildNodes() ){
$projElement->appendChild( $targetsElement );
}
// <template>
// add single POT template location
if( $file = $proj->getPot() ){
$templateElement = $dom->createElement('template');
$projElement->appendChild($templateElement);
$templateElement->appendChild( $model->createFileElement($file) );
// template may be protected from end-user tampering
if( $proj->isPotLocked() ){
$templateElement->setAttribute('locked','true');
}
}
}
}
// Write bundle-level path exclusions
$excludeElement = $dom->createElement('exclude');
foreach( $bundle->getExcludedLocations() as $file ){
$excludeElement->appendChild( $model->createFileElement($file) );
}
if( $excludeElement->hasChildNodes() ){
$root->appendChild( $excludeElement );
}
return $dom;
}
}

View File

@@ -0,0 +1,51 @@
<?php
/**
* Bundle configuration saved as a WordPress site option.
*/
class Loco_config_CustomSaved extends Loco_data_Option {
/**
* @var Loco_package_Bundle
*/
private $bundle;
/**
* {@inheritdoc}
*/
public function getKey(){
return strtolower( $this->bundle->getType() ).'_config__'.$this->bundle->getHandle();
}
/**
* {@inheritdoc}
*/
public function persist(){
$writer = new Loco_config_BundleWriter( $this->bundle );
$this->exchangeArray( $writer->toArray() );
return parent::persist();
}
/**
* @return Loco_config_CustomSaved
*/
public function setBundle( Loco_package_Bundle $bundle ){
$this->bundle = $bundle;
return $this;
}
/**
* Modify currently set bundle according to saved config data
* @return Loco_package_Bundle
*/
public function configure(){
$this->bundle->clear();
$reader = new Loco_config_BundleReader( $this->bundle );
$reader->loadArray( $this->getArrayCopy() );
return $this->bundle;
}
}

View File

@@ -0,0 +1,214 @@
<?php
/**
* Provides a bridge between the full serializable array model and POSTDATA array.
*
* Key differences between form data and the DOM are:
* - form fields cannot express attributes
* - form fields uses line breaks to separate multiple nodes
*/
class Loco_config_FormModel extends Loco_config_ArrayModel {
/**
* Export array data that matches the format used in postdata
* @return Loco_mvc_PostParams
*/
public function getPost(){
$dom = $this->getDom();
$root = $dom->documentElement;
$post = new Loco_mvc_PostParams( [
'name' => $root->getAttribute('name'),
'exclude' => [
'path' => '',
],
'conf' => [],
] );
/* @var LocoConfigElement $domain */
foreach( $this->query('domain',$root) as $domain ){
$domainName = $domain->getAttribute('name');
/* @var LocoConfigElement $project */
foreach( $domain as $project ){
$tree = [
'name' => $project->getAttribute('name'),
'slug' => $project->getAttribute('slug'),
'domain' => $domainName,
'source' => [
'path' => '',
'exclude' => [ 'path' => '' ],
],
'target' => [
'path' => '',
'exclude' => [ 'path' => '' ],
],
'template' => [ 'path' => '', 'locked' => false ],
];
$post['conf'][] = $this->collectPaths( $project, $tree );
}
}
/* @var LocoConfigElement $paths */
foreach( $this->query('exclude',$root) as $paths ){
$post['exclude'] = $this->collectPaths( $paths, $post['exclude'] );
}
return $post;
}
private function collectPaths( LocoConfigElement $parent, array $branch ){
$texts = [];
foreach( $parent as $child ){
$name = $child->nodeName;
// all file types as "path" in form model
if( 'file' === $name || 'directory' === $name ){
$name = 'path';
}
if( isset($branch[$name]) ){
// collect text if child is a <path> node
if( 'path' === $name ){
$file = $this->evaluateFileElement($child);
$path = $file->getRelativePath( $this->getDirectoryPath() );
if( '' === $path ){
$path = '.';
}
$texts[] = $path;
}
// else could be simple key to next depth
else if( is_array($branch[$name]) ){
$branch[$name] = $this->collectPaths( $child, $branch[$name] );
}
}
// @codeCoverageIgnoreStart
else {
throw new Exception('Unexpected structure: '.$name.' not in '.json_encode($branch) );
}
// @codeCoverageIgnoreEnd
}
// parent may have attributes we can set in branch data
foreach( $branch as $name => $default ){
if( $parent->hasAttribute($name) ){
if( is_bool($default) ){
$branch[$name] = $this->evaluateBooleanAttribute($parent, $name);
}
else {
$branch[$name] = $parent->getAttribute($name);
}
}
}
// set compiled path values if any collected
if( $texts ){
$value = implode("\n", $texts );
// display single root path as empty, but not when additional paths defined
if( '.' === $value ){
$branch['path'] = '';
}
else {
$branch['path'] = $value;
}
}
return $branch;
}
/**
* Construct model from posted form data.
* @return void
*/
public function loadForm( Loco_mvc_PostParams $post ){
// basic validation unlikely to fail when posted from UI
$name = $post->name;
if( ! $name ){
throw new InvalidArgumentException('Bundle must have a name');
}
$confs = $post->conf;
if( ! $confs || ! is_array($confs) ){
throw new InvalidArgumentException('Bundle must have at least one definition');
}
// transform posted data into internal model:
// deliberately not configuring bundle object at this point. simply converting data for storage.
$dom = $this->getDom();
$root = $dom->createElement('bundle');
$root->setAttribute( 'name', $name );
$dom->appendChild($root);
// bundle level excluded paths
if( $nodes = array_intersect_key( $post->getArrayCopy(), [ 'exclude' => '' ] ) ) {
$this->loadStruct( $root, $nodes );
}
// collect all projects grouped by domain
$domains = [];
foreach( $confs as $conf ){
if( ! empty($conf['removed']) ){
continue;
}
if( empty($conf['domain']) ){
throw new InvalidArgumentException( __('Text Domain cannot be empty','loco-translate') );
}
$domains[ $conf['domain'] ][] = $project = $dom->createElement('project');
// project attributes
foreach( ['name','slug'] as $attr ){
if( isset($conf[$attr]) ){
$project->setAttribute( $attr, $conf[$attr] );
}
}
// project children
if( $nodes = array_intersect_key( $conf, [ 'source' => '', 'target' => '', 'template' => '' ] ) ) {
$this->loadStruct( $project, $nodes );
}
}
// add all domains and their projects
foreach( $domains as $name => $projects ){
$parent = $dom->createElement('domain');
$parent->setAttribute('name',$name);
$root->appendChild($parent);
/* @var $project LocoConfigElement */
foreach( $projects as $project ){
$parent->appendChild( $project );
}
}
}
/**
* Recursively add array structure into model.
* - Text nodes are split into one parent element per line.
* - Elements added here cannot have attributes, but are not expected to as they came from form fields
*/
private function loadStruct( LocoConfigElement $parent, array $nodes ){
$dom = $this->getDom();
foreach( $nodes as $name => $data ){
if( is_string($data) ){
// support common path containing elements
if( 'file' === $name || 'directory' === $name || 'path' === $name ){
// form model has multiline "path" nodes which we'll expand from non-empty lines
// resolving empty paths to "." must be done elsewhere. here empty means ignore.
foreach( preg_split('/[\\r\\n]+/', trim( $data,"\n\r"), -1, PREG_SPLIT_NO_EMPTY ) as $path ){
$ext = pathinfo( $path, PATHINFO_EXTENSION );
$child = $parent->appendChild( $dom->createElement( $ext ? 'file' : 'directory' ) );
$child->appendChild( $dom->createTextNode($path) );
}
}
// else assume value is an attribute
else {
$parent->setAttribute( $name, $data );
}
}
else if( is_bool($data) ){
$data ? $parent->setAttribute($name,'true') : $parent->removeAttribute($name);
}
else if( ! is_array($data) ){
throw new InvalidArgumentException('Invalid datatype');
}
else {
$child = $dom->createElement($name);
$parent->appendChild($child);
$this->loadStruct( $child, $data );
}
}
}
}

View File

@@ -0,0 +1,175 @@
<?php
/**
* Generic configuration model serializer for portable Loco configs
*/
abstract class Loco_config_Model {
/**
* Root directory for calculating relative file paths
*/
private string $base;
/**
* registry of location constants that may have been overridden
*/
private array $dirs;
/**
* @return Iterator
*/
abstract public function query( $query, $context = null );
/**
* @return void
*/
abstract public function createDom();
/**
* @return DOMDocument|LocoConfigDocument
*/
abstract public function getDom();
/**
* Super constructor for all model types
*/
final public function __construct(){
$this->dirs = [];
$this->createDom();
$this->setDirectoryPath( loco_constant('ABSPATH') );
}
/**
* @return void
*/
public function setDirectoryPath( $path, $key = null ){
$path = untrailingslashit($path);
if( is_null($key) ){
$this->base = $path;
}
else {
$this->dirs[$key] = $path;
}
}
/**
* Evaluate a name constant pointing to a file location
* @param string|null $key one of 'LOCO_LANG_DIR', 'WP_LANG_DIR', 'WP_PLUGIN_DIR', 'WPMU_PLUGIN_DIR', 'WP_CONTENT_DIR', or 'ABSPATH'
*/
public function getDirectoryPath( $key = null ){
if( is_null($key) ){
$value = $this->base;
}
else if( isset($this->dirs[$key]) ){
$value = $this->dirs[$key];
}
else {
$value = untrailingslashit( loco_constant($key) );
}
return $value;
}
/**
* @return LocoConfigElement
*/
public function createFileElement( Loco_fs_File $file ){
$path = $file->getPath();
// only test concrete file type if existence is testable
if( '' === $path || '/' !== $path[0] ){
$type = $file->extension() ? 'file' : 'directory';
}
else {
$type = $file->isDirectory() ? 'directory' : 'file';
}
$node = $this->getDom()->createElement($type);
// Calculate relative path to the config file itself
$relpath = $file->getRelativePath($this->base);
// Map to a configured base path if target is not under our root. This makes XML more portable
// matching order is the most specific first, resulting in the shortest path
if( $relpath && ( Loco_fs_File::abs($relpath) || '..' === substr($relpath,0,2) || $this->base === $this->getDirectoryPath('ABSPATH') ) ){
$bases = [ 'LOCO_LANG_DIR', 'WP_LANG_DIR', 'WP_PLUGIN_DIR', 'WPMU_PLUGIN_DIR', 'WP_CONTENT_DIR', 'ABSPATH' ];
foreach( $bases as $key ){
if( ( $base = $this->getDirectoryPath($key) ) && $base !== $this->base ){
$base .= '/';
$len = strlen($base);
if( substr($path,0,$len) === $base ){
$node->setAttribute('base',$key);
$relpath = substr( $path, $len );
break;
}
}
}
}
$this->setFileElementPath($node,$relpath);
return $node;
}
/**
* @param LocoConfigElement $node
* @param string $path
* @return LocoConfigText
*/
protected function setFileElementPath( $node, $path ){
$text = $this->getDom()->createTextNode($path);
$node->appendChild($text);
return $text;
}
/**
* @param LocoConfigElement $el
* @return Loco_fs_File
*/
public function evaluateFileElement( $el ){
$path = $el->textContent;
switch( $el->nodeName ){
case 'directory':
$file = new Loco_fs_Directory($path);
break;
case 'file':
$file = new Loco_fs_File($path);
break;
case 'path':
$file = new Loco_fs_File($path);
if( $file->isDirectory() ){
$file = new Loco_fs_Directory($path);
}
break;
default:
throw new InvalidArgumentException('Cannot evaluate file element from <'.$el->nodeName.'>');
}
if( $el->hasAttribute('base') ){
$key = $el->getAttribute('base');
$base = $this->getDirectoryPath($key);
$file->normalize( $base );
}
else {
$file->normalize( $this->base );
}
return $file;
}
/**
* @param LocoConfigElement $el
* @param string $attr
* @return bool
*/
public function evaluateBooleanAttribute( $el, $attr ){
if( ! $el->hasAttribute($attr) ){
return false;
}
$value = (string) $el->getAttribute($attr);
return 'false' !== $value && 'no' !== $value && '' !== $value;
}
}

View File

@@ -0,0 +1,216 @@
<?php
/**
* Holds a bundle definition in a DOM document
*/
class Loco_config_XMLModel extends Loco_config_Model {
/**
* @var DOMDocument
*/
private $dom;
/**
* @var DOMXpath
*/
private $xpath;
/**
* {@inheritdoc}
*/
public function createDom(){
$dom = new DOMDocument('1.0','utf-8');
$dom->formatOutput = true;
$dom->registerNodeClass('DOMElement','LocoConfig_DOMElement');
$this->xpath = new DOMXPath($dom);
$this->dom = $dom;
}
/**
* @return DOMDocument
*/
public function getDom(){
return $this->dom;
}
/**
* {@inheritdoc}
* @return LocoConfigNodeListIterator
*/
public function query( $query, $context = null ){
$list = $this->xpath->query( $query, $context );
return new LocoConfigNodeListIterator( $list );
}
/**
* @return void
*/
public function loadXml( $source ){
if( ! $source ){
throw new Loco_error_XmlParseException( __('XML supplied is empty','loco-translate') );
}
$dom = $this->getDom();
// parse with silent errors, clearing after
$used_errors = libxml_use_internal_errors(true);
$dom->loadXML( $source, LIBXML_NONET );
unset( $source );
// fetch errors and ensure clean for next run.
$errors = libxml_get_errors();
$used_errors || libxml_use_internal_errors(false);
libxml_clear_errors();
// Throw exception if error level exceeds current tolerance
if( $errors ){
foreach( $errors as $error ){
if( $error->level >= LIBXML_ERR_FATAL ){
throw new Loco_error_XmlParseException( trim($error->message) );
// ->setContext( $error->line, $error->column, $source );
}
}
}
// Not currently validating against a DTD, but will preempt generic model loading errors
$root = $dom->documentElement;
if( ! $root instanceof DOMNode ){
throw new Loco_error_XmlParseException('Expected <bundle> document element');
}
if( 'bundle' !== strtolower($root->nodeName) ){
throw new Loco_error_XmlParseException('Expected <bundle> document element, got <'.$root->nodeName.'>');
}
$this->xpath = new DOMXPath($dom);
}
/**
* {@inheritdoc}
* Overridden to avoid empty text nodes in XML files, preferring <file>.</file> to <file />
*/
protected function setFileElementPath( $node, $path ){
if( ! $path && '0' !== $path ){
$path = '.';
}
return parent::setFileElementPath( $node, $path );
}
}
/**
* @internal
*/
class LocoConfig_DOMElement extends DOMElement implements IteratorAggregate, Countable {
#[ReturnTypeWillChange]
public function getIterator(){
return new LocoConfigNodeListIterator( $this->childNodes );
}
#[ReturnTypeWillChange]
public function count(){
return $this->childNodes->length;
}
}
/**
* @internal
* Cos NodeList doesn't iterate
*/
class LocoConfigNodeListIterator implements Iterator, Countable, ArrayAccess {
/**
* @var DOMNodeList
*/
private $nodes;
/**
* @var int
*/
private $i;
/**
* @var int
*/
private $n;
public function __construct( DOMNodeList $nodes ){
$this->nodes = $nodes;
$this->n = $nodes->length;
}
#[ReturnTypeWillChange]
public function count(){
return $this->n;
}
#[ReturnTypeWillChange]
public function rewind(){
$this->i = -1;
$this->next();
}
#[ReturnTypeWillChange]
public function key(){
return $this->i;
}
#[ReturnTypeWillChange]
public function current(){
return $this->nodes->item( $this->i );
}
#[ReturnTypeWillChange]
public function valid(){
return is_int($this->i);
}
#[ReturnTypeWillChange]
public function next(){
while( true ){
$this->i++;
if( $child = $this->nodes->item($this->i) ){
break;
}
$this->i = null;
break;
}
}
#[ReturnTypeWillChange]
public function offsetExists( $i ){
return $i >= 0 && $i < $this->n;
}
#[ReturnTypeWillChange]
public function offsetGet( $i ){
return $this->nodes->item($i);
}
/**
* @codeCoverageIgnore
*/
#[ReturnTypeWillChange]
public function offsetSet( $i, $value ){
throw new Exception('Read only');
}
/**
* @codeCoverageIgnore
*/
#[ReturnTypeWillChange]
public function offsetUnset( $i ){
throw new Exception('Read only');
}
}

View File

@@ -0,0 +1,94 @@
<?php
/**
* Static, read-only caching of data held in serialized files.
* Used for pre-built arrays of information such as plural forms.
*/
class Loco_data_CompiledData implements ArrayAccess, Countable, IteratorAggregate {
/**
* @var array
*/
private static $reg = [];
/**
* @var string
*/
private $name;
/**
* @var array
*/
private $data;
/**
* @param string $name
* @return self
*/
public static function get( $name ){
if( ! isset(self::$reg[$name]) ){
self::$reg[$name] = new Loco_data_CompiledData($name);
}
return self::$reg[$name];
}
/**
* Remove all cached data from memory
* @return void
*/
public static function flush(){
self::$reg = [];
}
private function __construct( $name ){
$path = 'lib/data/'.$name.'.php';
$this->data = loco_include( $path, true );
$this->name = $name;
}
public function destroy(){
unset( self::$reg[$this->name], $this->data );
}
#[ReturnTypeWillChange]
public function offsetGet( $k ){
return isset($this->data[$k]) ? $this->data[$k] : null;
}
#[ReturnTypeWillChange]
public function offsetExists( $k ){
return isset($this->data[$k]);
}
#[ReturnTypeWillChange]
public function offsetUnset( $k ){
throw new RuntimeException('Read only');
}
#[ReturnTypeWillChange]
public function offsetSet( $k, $v ){
throw new RuntimeException('Read only');
}
#[ReturnTypeWillChange]
public function count(){
return count($this->data);
}
/**
* Implements IteratorAggregate::getIterator
* @return ArrayIterator
*/
#[ReturnTypeWillChange]
public function getIterator(){
return new ArrayIterator( $this->data );
}
}

View File

@@ -0,0 +1,83 @@
<?php
/**
* Basic abstraction of cookie setting.
* - Provides loco_setcookie filter for tests.
* - Provides multiple values as url-encoded pairs. Not using JSON, because stripslashes
*
* Not currently used anywhere - replaced with usermeta-based session
* @codeCoverageIgnore
*/
class Loco_data_Cookie extends ArrayObject {
private $name = 'loco';
private $expires = 0;
/**
* Get and deserialize cookie sent to server
* @return Loco_data_Cookie
*/
public static function get( $name ){
if( isset($_COOKIE[$name]) ){
parse_str( $_COOKIE[$name], $data );
if( $data ){
$cookie = new Loco_data_Cookie( $data );
return $cookie->setName( $name );
}
}
}
/**
* @internal
*/
public function __toString(){
$data = $this->getArrayCopy();
return http_build_query( $data, null, '&' );
}
/**
* @return Loco_data_Cookie
*/
public function setName( $name ){
$this->name = $name;
return $this;
}
/**
* @return string
*/
public function getName(){
return $this->name;
}
/**
* Send cookie to the browser, unless filtered out.
* @return bool|null
*/
public function send(){
if( false !== apply_filters( 'loco_setcookie', $this ) ){
$value = (string) $this;
// @codeCoverageIgnoreStart
return setcookie( $this->name, $value, $this->expires, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
}
}
/**
* Empty values such that sending cookie would remove it from browser
* @return Loco_data_Cookie
*/
public function kill(){
$this->exchangeArray( [] );
$this->expires = time() - 86400;
return $this;
}
}

View File

@@ -0,0 +1,53 @@
<?php
/**
* Data object persisted as a WordPress "option"
*/
abstract class Loco_data_Option extends Loco_data_Serializable {
/**
* Get short suffix for use as end of option_name field.
* DB allows 191 characters including "loco_" prefix, leaving 185 bytes
* @return string
*/
abstract public function getKey();
/**
* Persist object in WordPress options database
* @return bool
*/
public function persist(){
$key = 'loco_'.$this->getKey();
return update_option( $key, $this->getSerializable(), false );
}
/**
* Retrieve and unserialize this object from WordPress options table
* @return bool whether object existed in cache
*/
public function fetch(){
$key = 'loco_'.$this->getKey();
if( $data = get_option($key) ){
try {
$this->setUnserialized($data);
return true;
}
catch( InvalidArgumentException $e ){
// suppress validation error
// @codeCoverageIgnore
}
}
return false;
}
/**
* Delete option from WordPress
*/
public function remove(){
$key = 'loco_'.$this->getKey();
return delete_option( $key );
}
}

View File

@@ -0,0 +1,187 @@
<?php
/**
* Abstraction of WordPress roles and capabilities and how they apply to Loco.
*
* - Currently only one capability exists, proving full access "loco_admin"
* - Any user with super admin privileges automatically inherits this permission
* - A single custom role is added called "translator"
*/
class Loco_data_Permissions {
/**
* Loco capabilities applicable to roles
* @var array
*/
private static $caps = ['loco_admin'];
/**
* Polyfill for wp_roles which requires WP >= 4.3
* @return WP_Roles
*/
private static function wp_roles(){
global $wp_roles;
if( ! isset($wp_roles) ){
get_role('ping');
}
return $wp_roles;
}
/**
* Set up default roles and capabilities
* @return WP_Roles
*/
public static function init(){
$roles = self::wp_roles();
$apply = [];
// ensure translator role exists and is not locked out
$role = $roles->get_role('translator');
if( $role instanceof WP_Role ){
$role->has_cap('read') || $role->add_cap('read');
}
// else absence of translator role indicates first run
// by default we'll initially allow full access to anyone that can manage_options
else {
$apply['translator'] = $roles->add_role( 'translator', 'Translator', ['read'=>true] );
foreach( $roles->role_objects as $id => $role ){
if( $role->has_cap('manage_options') ){
$apply[$id] = $role;
}
}
}
// fix broken permissions whereby super admin cannot access Loco at all.
// this could happen if another plugin added the translator role beforehand.
if( ! isset($apply['administrator']) && ! is_multisite() ){
$apply['administrator'] = $roles->get_role('administrator');
}
foreach( $apply as $role ){
if( $role instanceof WP_Role ){
foreach( self::$caps as $cap ){
$role->has_cap($cap) || $role->add_cap($cap);
}
}
}
return $roles;
}
/**
* Construct instance, ensuring default roles and capabilities exist
*/
public function __construct(){
self::init();
}
/**
* @return WP_Role[]
*/
public function getRoles(){
$roles = self::wp_roles();
return $roles->role_objects;
}
/**
* Check if role is protected such that user cannot lock themselves out when modifying settings
* @param WP_Role WordPress role object to check
* @return bool
*/
public function isProtectedRole( WP_Role $role ){
// if current user has this role and is not the super user, prevent lock-out
$user = wp_get_current_user();
if( $user instanceof WP_User && ! is_super_admin($user->ID) && $user->has_cap('manage_options') ){
return in_array( $role->name, $user->roles, true );
}
// admin users of single site install must never be denied access
// note that there is no such thing as a network admin role, but network admins have all permissions
return is_multisite() ? false : $role->has_cap('delete_users');
}
/**
* Completely remove all Loco permissions, as if uninstalling
* @return Loco_data_Permissions
*/
public function remove(){
/* @var $role WP_Role */
foreach( $this->getRoles() as $role ){
foreach( self::$caps as $cap ){
$role->has_cap($cap) && $role->remove_cap($cap);
}
}
// we'll only remove our custom role if it has no capabilities other than admin access
// this avoids breaking other plugins that use it, or added it before Loco was installed.
if( $role = get_role('translator') ){
if( ! $role->capabilities || ['read'] === array_keys($role->capabilities) ){
remove_role('translator');
}
}
return $this;
}
/**
* Reset to default: roles include no Loco capabilities unless they have super admin privileges
* @return WP_Role[]
*/
public function reset(){
$roles = $this->getRoles();
/* @var $role WP_Role */
foreach( $roles as $role ){
// always provide access to site admins on first run
$grant = $this->isProtectedRole($role);
foreach( self::$caps as $cap ){
if( $grant ){
$role->has_cap($cap) || $role->add_cap($cap);
}
else {
$role->has_cap($cap) && $role->remove_cap($cap);
}
}
}
return $roles;
}
/**
* Get translated WordPress role name
* @param string
* @return string
*/
public function getRoleName( $id ){
if( 'translator' === $id ){
$label = _x( 'Translator', 'User role', 'loco-translate' );
}
else {
$names = self::wp_roles()->role_names;
$label = isset($names[$id]) ? translate_user_role( $names[$id] ) : $id;
}
return $label;
}
/**
* Populate permission settings from posted checkboxes
* @param string[]
* @return self
*/
public function populate( array $caps ){
// drop all permissions before adding (cos checkboxes)
$roles = $this->reset();
foreach( $caps as $id => $checked ){
if( isset($roles[$id]) ){
$role = $roles[$id];
/* @var $role WP_Role */
foreach( self::$caps as $cap ){
if( ! empty($checked[$cap]) ){
$role->has_cap($cap) || $role->add_cap($cap);
}
}
}
}
return $this;
}
}

View File

@@ -0,0 +1,181 @@
<?php
/**
* Data object persisted as a WordPress user meta entry under the loco_prefs key
*
* @property string $credit Last-Translator credit, defaults to current display name
* @property string[] $locales List of locales user wants to be restricted to seeing.
*/
class Loco_data_Preferences extends Loco_data_Serializable {
/**
* User preference singletons
* @var Loco_data_Preferences[]
*/
private static $current = [];
/**
* ID of the currently operational user
* @var int
*/
private $user_id = 0;
/**
* Available options and their defaults
* @var array
*/
private static $defaults = [
'credit' => '',
'locales' => [],
];
/**
* Get current user's preferences
* @return Loco_data_Preferences
*/
public static function get(){
$id = get_current_user_id();
if( ! $id ){
// allow null return only on command line. All web users must be logged in
if( 'cli' === PHP_SAPI || defined('LOCO_TEST') ){
return null;
}
throw new Exception( 'No current user' ); // @codeCoverageIgnore
}
if( isset(self::$current[$id]) ){
return self::$current[$id];
}
$prefs = self::create($id);
self::$current[$id] = $prefs;
$prefs->fetch();
return $prefs;
}
/**
* Create default settings instance
* @param int User ID
* @return Loco_data_Preferences
*/
public static function create( $id ){
$prefs = new Loco_data_Preferences( self::$defaults );
$prefs->user_id = $id;
return $prefs;
}
/**
* Destroy current user's preferences
* @return void
*/
public static function clear(){
get_current_user_id() && self::get()->remove();
}
/**
* Persist object in WordPress usermeta table
* @return bool
*/
public function persist(){
return update_user_meta( $this->user_id, 'loco_prefs', $this->getSerializable() ) ? true : false;
}
/**
* Retrieve and unserialize this object from WordPress usermeta table
* @return bool whether object existed in cache
*/
public function fetch(){
$data = get_user_meta( $this->user_id, 'loco_prefs', true );
// See comments in Loco_data_Settings
if( is_array($data) ){
$copy = new Loco_data_Preferences;
$copy->setUnserialized($data);
$data = $copy->getArrayCopy() + $this->getArrayCopy();
$this->exchangeArray($data);
$this->clean();
return true;
}
return false;
}
/**
* Delete usermeta entry from WordPress
* return bool
*/
public function remove(){
$id = $this->user_id;
self::$current[$id] = null;
return delete_user_meta( $id, 'loco_prefs' );
}
/**
* Populate all settings from raw postdata.
* @param array
* @return Loco_data_Preferences
*/
public function populate( array $data ){
// set all keys present in array
foreach( $data as $prop => $value ){
try {
$this->offsetSet( $prop, $value );
}
catch( InvalidArgumentException $e ){
// skipping invalid key
}
}
return $this;
}
/**
* {@inheritdoc}
*/
public function offsetSet( $prop, $value ){
$value = parent::cast($prop,$value,self::$defaults);
parent::offsetSet( $prop, $value );
}
/**
* Get default Last-Translator credit
* @return string
*/
public function default_credit(){
$user = wp_get_current_user();
$name = (string) $user->get('display_name');
if( $user->get('user_login') === $name ){
$name = '';
}
return $name;
}
/**
* Check if user wants to know about this locale
* @param Loco_Locale locale to match in whitelist
* @return bool
*/
public function has_locale( Loco_Locale $locale ){
$haystack = $this->locales;
if( $haystack ){
foreach( $haystack as $tag ){
$tag = strtolower($tag);
// allow language wildcard. en_GB allowed by "en"
if( $locale->lang === $tag ){
return true;
}
// else normalize whitelist before comparison
if( Loco_Locale::parse($tag)->__toString() === $locale->__toString() ){
return true;
}
}
return false;
}
return true;
}
}

View File

@@ -0,0 +1,128 @@
<?php
/**
* Recently items to display on home page
*/
class Loco_data_RecentItems extends Loco_data_Option {
/**
* Global instance of recent items
* @var Loco_data_RecentItems
*/
private static $current;
/**
* {@inheritdoc}
*/
public function getKey(){
return 'recent';
}
/**
* @return Loco_data_RecentItems
*/
public static function get(){
if( ! self::$current ){
self::$current = new Loco_data_RecentItems;
self::$current->fetch();
}
return self::$current;
}
/**
* Trash data and remove from memory
*/
public static function destroy(){
$tmp = new Loco_data_RecentItems;
$tmp->remove();
self::$current = null;
}
/**
* @internal
* @return Loco_data_RecentItems
*/
private function push( $object, array $indexes ){
foreach( $indexes as $key => $id ){
$stack = isset($this[$key]) ? $this[$key] : [];
// remove before add ensures latest item appended to hashmap
unset($stack[$id]);
$stack[$id] = time();
$this[$key] = $stack;
// TODO prune stack to maximum length
}
return $this;
}
/**
* @return array
*/
private function getItems( $key, $offset, $count ){
$stack = isset($this[$key]) ? $this[$key] : [];
// hash map should automatically be in "push" order, meaning most recent last
// sorting gives wrong order for same-second updates (only relevant in tests, but still..)
// asort( $stack, SORT_NUMERIC );
$stack = array_reverse( array_keys( $stack ) );
if( is_null($count) && 0 === $offset ){
return $stack;
}
return array_slice( $stack, $offset, $count, false );
}
/**
* @return int
*/
private function hasItem( $key, $id ){
if( isset($this[$key]) && ( $items = $this[$key] ) && isset($items[$id]) ){
return $items[$id];
}
return 0;
}
/**
* Push bundle to the front of recent bundles stack
* @return Loco_data_RecentItems
*/
public function pushBundle( Loco_package_Bundle $bundle ){
return $this->push( $bundle, [ 'bundle' => $bundle->getId() ] );
}
/**
* Get bundle IDs
* @return array
*/
public function getBundles( $offset = 0, $count = null ){
return $this->getItems('bundle', $offset, $count );
}
/**
* Check if a bundle has been recently used
* @return int timestamp item was added, 0 if absent
*/
public function hasBundle( $id ){
return $this->hasItem( 'bundle', $id );
}
/**
* TODO other types of item
* Push project to the front of recent bundles stack
* @return Loco_data_RecentItems
*
public function pushProject( Loco_package_Project $project ){
return $this;
}*/
}

View File

@@ -0,0 +1,237 @@
<?php
/**
* Generic array-like object that may be serialized as an array and committed into WordPress data stores.
*/
abstract class Loco_data_Serializable extends ArrayObject {
/**
* Object/schema version (not plugin version) can be used for validation and migrations
* @var string|int|float
*/
private $v = 0;
/**
* Time object was last persisted
* @var int
*/
private $t = 0;
/**
* @var bool
*/
private $dirty;
/**
* Whether persisting on object destruction
* @var bool
*/
private $lazy = false;
/**
* Commit serialized data to WordPress storage
* @return mixed
*/
abstract public function persist();
/**
* {@inheritdoc}
*/
public function __construct( array $data = [] ){
$this->setFlags( ArrayObject::ARRAY_AS_PROPS );
parent::__construct( $data );
$this->dirty = (bool) $data;
}
/**
* @internal
*/
final public function __destruct(){
if( $this->lazy ){
$this->persistIfDirty();
}
}
/**
* Check if object's properties have change since last clean
* @return bool
*/
public function isDirty(){
return $this->dirty;
}
/**
* Make not dirty
* @return self
*/
protected function clean(){
$this->dirty = false;
return $this;
}
/**
* Force dirtiness for next check
* @return static
*/
protected function touch(){
$this->dirty = true;
return $this;
}
/**
* Enable lazy persistence on object destruction, if dirty
* @return static
*/
public function persistLazily(){
$this->lazy = true;
return $this;
}
/**
* Call persist method only if has changed since last clean
* @return static
*/
public function persistIfDirty(){
if( $this->isDirty() ){
$this->persist();
}
return $this;
}
/**
* {@inheritdoc}
* override so we can set dirty flag
*/
#[ReturnTypeWillChange]
public function offsetSet( $key, $value ){
if( ! isset($this[$key]) || $value !== $this[$key] ){
parent::offsetSet( $key, $value );
$this->dirty = true;
}
}
/**
* {@inheritdoc}
* override so we can set dirty flag
*/
#[ReturnTypeWillChange]
public function offsetUnset( $key ){
if( isset($this[$key]) ){
parent::offsetUnset($key);
$this->dirty = true;
}
}
/**
* @param string|int|float $version
* @return self
*/
public function setVersion( $version ){
if( $version !== $this->v ){
$this->v = $version;
$this->dirty = true;
}
return $this;
}
/**
* @return string|int|float
*/
public function getVersion(){
return $this->v;
}
/**
* @return int
*/
public function getTimestamp(){
return $this->t;
}
/**
* Get serializable data for storage
* @return array
*/
protected function getSerializable(){
return [
'c' => get_class($this),
'v' => $this->getVersion(),
'd' => $this->getArrayCopy(),
't' => time(),
];
}
/**
* Restore object state from array as returned from getSerializable
* @param array $data
* @return self
*/
protected function setUnserialized( $data ){
if( ! is_array($data) || ! isset($data['d']) ) {
throw new InvalidArgumentException('Unexpected data');
}
if( get_class($this) !== $data['c'] ){
throw new InvalidArgumentException('Unexpected class name');
}
// ok to populate ArrayObject
$this->exchangeArray( $data['d'] );
// setting version as it was in database
$this->setVersion( $data['v'] );
// timestamp may not be present in old objects
$this->t = isset($data['t']) ? $data['t'] : 0;
// object is being restored, probably from disk so start with clean state
$this->dirty = false;
return $this;
}
/**
* @param string $prop
* @param mixed $value
* @param array $defaults
* @return mixed
*/
protected static function cast( $prop, $value, array $defaults ){
if( ! array_key_exists($prop,$defaults) ){
throw new InvalidArgumentException('Invalid option, '.$prop );
}
$default = $defaults[$prop];
// cast to same type as default
if( is_bool($default) ){
$value = (bool) $value;
}
else if( is_int($default) ){
$value = (int) $value;
}
else if( is_array($default) ){
if( ! is_array($value) ){
$value = preg_split( '/[\\s,]+/', trim($value), -1, PREG_SPLIT_NO_EMPTY );
}
}
else {
$value = (string) $value;
}
return $value;
}
}

View File

@@ -0,0 +1,196 @@
<?php
/**
* Abstracts session data access using WP_Session_Tokens
*/
class Loco_data_Session extends Loco_data_Serializable {
/**
* @var Loco_data_Session
*/
private static $current;
/**
* Value from wp_get_session_token
* @var string
*/
private $token;
/**
* @var WP_User_Meta_Session_Tokens
*/
private $manager;
/**
* Dirty flag: TODO abstract into array access setters
* @var bool
*/
private $dirty = false;
/**
* @return Loco_data_Session
*/
public static function get(){
if( ! self::$current ){
new Loco_data_Session;
}
return self::$current;
}
/**
* Trash data and remove from memory
*/
public static function destroy(){
if( self::$current ){
try {
self::$current->clear();
}
catch( Exception $e ){
// probably no session to destroy
}
self::$current = null;
}
}
/**
* Commit current session data to WordPress storage and remove from memory
*/
public static function close(){
if( self::$current && self::$current->dirty ){
self::$current->persist();
self::$current = null;
}
}
/**
* @internal
*/
final public function __construct( array $raw = [] ){
$this->token = wp_get_session_token();
if( ! $this->token ){
throw new Loco_error_Exception('Failed to get session token');
}
parent::__construct( [] );
$this->manager = WP_Session_Tokens::get_instance( get_current_user_id() );
// populate object from stored session data
$data = $this->getRaw();
if( isset($data['loco']) ){
$this->setUnserialized( $data['loco'] );
}
// any initial arbitrary data can be merged on top
foreach( $raw as $prop => $value ){
$this[$prop] = $value;
}
// enforce single instance
self::$current = $this;
// ensure against unclean shutdown
if( loco_debugging() ){
register_shutdown_function( [$this,'_on_shutdown'] );
}
}
/**
* @internal
* Ensure against unclean use of session storage
*/
public function _on_shutdown(){
if( $this->dirty ){
trigger_error('Unclean session shutdown: call either Loco_data_Session::destroy or Loco_data_Session::close');
}
}
/**
* Get raw session data held by WordPress
* @return array
*/
private function getRaw(){
$data = $this->manager->get( $this->token );
// session data will exist if WordPress login is valid
if( ! $data || ! is_array($data) ){
throw new Loco_error_Exception('Invalid session');
}
return $data;
}
/**
* Persist object in WordPress usermeta table
* @return Loco_data_Session
*/
public function persist(){
$data = $this->getRaw();
$data['loco'] = $this->getSerializable();
$this->manager->update( $this->token, $data );
$this->dirty = false;
return $this;
}
/**
* Clear object data and remove our key from WordPress usermeta record
* @return Loco_data_Session
*/
public function clear(){
$data = $this->getRaw();
if( isset($data['loco']) ){
unset( $data['loco'] );
$this->manager->update( $this->token, $data );
}
$this->exchangeArray( [] );
$this->dirty = false;
return $this;
}
/**
* @param string name of messages bag, e.g. "errors"
* @param string optionally put message in rather than getting data out
* @return mixed
*/
public function flash( $bag, $data = null ){
if( isset($data) ){
$this->dirty = true;
$this[$bag][] = $data;
}
// else get first object in bag and remove before returning
else if( isset($this[$bag]) ){
if( $data = array_shift($this[$bag]) ){
$this->dirty = true;
return $data;
}
}
return null;
}
/**
* {@inheritDoc}
*/
public function offsetSet( $index, $newval ){
if( ! isset($this[$index]) || $newval !== $this[$index] ){
$this->dirty = true;
parent::offsetSet( $index, $newval );
}
}
/**
* {@inheritDoc}
*/
public function offsetUnset( $index ){
if( isset($this[$index]) ){
$this->dirty = true;
parent::offsetUnset( $index );
}
}
}

View File

@@ -0,0 +1,244 @@
<?php
/**
* Global plugin settings stored in a single WordPress site option.
*
* @property string $version Current plugin version installed
* @property bool $gen_hash Whether to compile hash table into MO files
* @property bool $use_fuzzy Whether to include Fuzzy strings in MO files
* @property int $fuzziness Fuzzy matching tolerance level, 0-100
* @property int $num_backups Number of backups to keep of Gettext files
* @property array $pot_alias Alternative names for POT files in priority order
* @property array $php_alias Alternative file extensions for PHP files
* @property array $jsx_alias Registered extensions for scanning JavaScript/JSX files (disabled by default)
* @property bool $fs_persist Whether to remember file system credentials in session
* @property int $fs_protect Prevent modification of files in system folders (0:off, 1:warn, 2:block)
* @property int $pot_protect Prevent modification of POT files (0:off, 1:warn, 2:block)
* @property int $pot_expected Whether to allow missing templates and sync to source (0:off, 1:warn, 2:block)
* @property string $max_php_size Skip PHP source files this size or larger
* @property bool $po_utf8_bom Whether to prepend PO and POT files with UTF-8 byte order mark
* @property string $po_width PO/POT file maximum line width (wrapping) zero to disable
* @property bool $jed_pretty Whether to pretty print JSON JED files
* @property bool $jed_clean Whether to clean up redundant JSON files during compilation
* @property bool $ajax_files Whether to submit PO data as concrete files (requires Blob support in Ajax)
* @property int $code_view Access level for source code snippet viewer (0:disabled, 1:admins only, 2:all users)
*
* @property string $deepl_api_key API key for DeepL Translator
* @property string $google_api_key API key for Google Translate
* @property string $lecto_api_key API key for Lecto Translation API
* @property string $microsoft_api_key API key for Microsoft Translator text API
* @property string $microsoft_api_region API region for Microsoft Translator text API
* @property string $openai_api_key API key for OpenAI / ChatGPT translator
* @property string $openai_api_model Model for OpenAI / ChatGPT translator
* @property string $openai_api_prompt Custom prompt for OpenAI / ChatGPT translator
*
* TODO @property bool $php_pretty Whether to pretty print .l10n.php files
*/
class Loco_data_Settings extends Loco_data_Serializable {
/**
* Global instance of this plugin's settings
*/
private static ?Loco_data_Settings $current = null;
/**
* Available options and their defaults
*/
private static array $defaults = [
'version' => '',
'gen_hash' => false,
'use_fuzzy' => true,
'fuzziness' => 20,
'num_backups' => 5,
'pot_alias' => [ 'default.po', 'en_US.po', 'en.po' ],
'php_alias' => [ 'php', 'twig' ],
'jsx_alias' => [],
'fs_persist' => false,
'fs_protect' => 1,
'pot_protect' => 1,
'pot_expected' => 1,
'max_php_size' => '100K',
'po_utf8_bom' => false,
'po_width' => '79',
'jed_pretty' => false,
'jed_clean' => false,
'ajax_files' => true,
'code_view' => 1,
'deepl_api_key' => '',
'google_api_key' => '',
'microsoft_api_key' => '',
'microsoft_api_region' => 'global',
'lecto_api_key' => '',
'openai_api_key' => '',
'openai_api_model' => '',
'openai_api_prompt' => '',
];
/**
* Create default settings instance
* @return Loco_data_Settings
*/
public static function create(){
$args = self::$defaults;
$args['version'] = loco_plugin_version();
return new Loco_data_Settings( $args );
}
/**
* Get currently configured global settings
* @return Loco_data_Settings
*/
public static function get(){
$opts = self::$current;
if( ! $opts ){
$opts = self::create();
$opts->fetch();
self::$current = $opts;
// allow hooks to modify settings
do_action('loco_settings', $opts );
}
return $opts;
}
/**
* Destroy current settings
* @return void
*/
public static function clear(){
delete_option('loco_settings');
self::$current = null;
}
/**
* Destroy current settings and return a fresh one
* @return Loco_data_Settings
*/
public static function reset(){
self::clear();
return self::$current = self::create();
}
/**
* {@inheritdoc}
*/
public function offsetSet( $prop, $value ){
$value = parent::cast($prop,$value,self::$defaults);
parent::offsetSet( $prop, $value );
}
/**
* Commit current settings to WordPress DB
* @return bool
*/
public function persist(){
$this->version = loco_plugin_version();
$this->clean();
return update_option('loco_settings', $this->getSerializable() );
}
/**
* Pull current settings from WordPress DB and merge into this object
* @return bool whether settings where previously saved
*/
public function fetch(){
$data = get_option('loco_settings');
if( is_array($data) ){
$copy = new Loco_data_Settings;
$copy->setUnserialized($data);
// preserve any defaults not in previously saved data
// this will occur if we've added options since setting were saved
$data = $copy->getArrayCopy() + $this->getArrayCopy();
// could ensure redundant keys are removed, but no need currently
// $data = array_intersect_key( $data, self::$defaults );
$this->exchangeArray( $data );
$this->clean();
return true;
}
return false;
}
/**
* Run migration in case plugin has been upgraded since settings last saved
* @return bool whether upgrade has occurred
*/
public function migrate(){
$updated = false;
// Always update version number in settings after an upgrade
$old = $this->version;
$new = loco_plugin_version();
if( version_compare($old,$new,'<') ){
$this->persist();
$updated = true;
/*/ feature alerts:
if( '2.6.' === substr($new,0,4) && '2.6.' !== substr($old,0,4) ){
Loco_error_AdminNotices::info( __('Loco Translate 2.6 adds ......','loco-translate') )
->addLink( apply_filters('loco_external','https://localise.biz/wordpress/plugin/changelog'), __('Documentation','loco-translate') );
}*/
}
return $updated;
}
/**
* Populate ALL settings from raw postdata.
* @param array $data Posted setting values
* @param array|null $filter Optional filter to restrict modifiable values
* @return Loco_data_Settings
*/
public function populate( array $data, $filter = null ){
// set all keys present in posted data
foreach( $data as $prop => $value ){
try {
if( is_null($filter) || in_array($prop,$filter,true) ) {
$this->offsetSet( $prop, $value );
}
}
catch( InvalidArgumentException $e ){
// skipping invalid key
}
}
// set missing boolean keys as false, because unchecked checkboxes won't post anything
$defaults = self::$defaults;
if( is_array($filter) ){
$defaults = array_intersect_key( array_flip($filter) ,$defaults);
}
foreach( array_diff_key($defaults,$data) as $prop => $default ){
if( is_bool($default) ){
parent::offsetSet( $prop, false );
}
}
// enforce missing values that must have a default, but were passed empty
foreach( ['php_alias','max_php_size','po_width'] as $prop ){
if( isset($data[$prop]) && '' === $data[$prop] ){
parent::offsetSet( $prop, self::$defaults[$prop] );
}
}
return $this;
}
/**
* Map a file extension to registered types, defaults to "php"
* @param string $ext File extension
* @param string $default Optional default
* @return string php, js, json, twig or $default
*/
public function ext2type( string $ext, string $default = 'php' ):string {
$types = ['php'=>'php', 'js'=>'js', 'json'=>'json', 'twig'=>'twig'] // <- canonical
+ array_fill_keys( $this->php_alias, 'php')
+ array_fill_keys( $this->jsx_alias, 'js')
;
$ext = strtolower($ext);
return $types[ $ext ] ?? $default;
}
}

View File

@@ -0,0 +1,77 @@
<?php
/**
*
*/
abstract class Loco_data_Transient extends Loco_data_Serializable {
/**
* Lifespan to persist object in transient cache
* @var int seconds
*/
private $ttl = 0;
/**
* Get short suffix for use as end of cache key.
* DB allows 191 characters including "_transient_timeout_loco_" prefix, leaving 167 bytes
* @return string
*/
abstract public function getKey();
/**
* Persist object in WordPress cache
* @param int
* @param bool
* @return Loco_data_Transient
*/
public function persist(){
$key = 'loco_'.$this->getKey();
set_transient( $key, $this->getSerializable(), $this->ttl );
$this->clean();
return $this;
}
/**
* Retrieve and unserialize this object from WordPress transient cache
* @return bool whether object existed in cache
*/
public function fetch(){
$key = 'loco_'.$this->getKey();
$data = get_transient( $key );
try {
$this->setUnserialized($data);
return true;
}
catch( InvalidArgumentException $e ){
return false;
}
}
/**
* @param int
* @return self
*/
public function setLifespan( $ttl ){
$this->ttl = (int) $ttl;
return $this;
}
/**
* Set keep-alive interval
* @param int
* @return self
*/
public function keepAlive( $timeout ){
$time = $this->getTimestamp();
// legacy objects (with ttl=0) had no timestamp, so will always be touched.
// make dirty if this number of seconds has elapsed since last persisted.
if( time() > ( $time + $timeout ) ){
$this->touch()->persistLazily();
}
return $this;
}
}

View File

@@ -0,0 +1,86 @@
<?php
/**
* Simple wrapper for transient file uploads carrying PO data.
*/
class Loco_data_Upload extends Loco_fs_File {
/**
* @var array
*/
private $data;
/**
* Pass through temporary file data
* @param string $key in $_FILES known to exist
* @return string
* @throws Loco_error_UploadException
*/
public static function src($key){
$upload = new Loco_data_Upload($_FILES[$key]);
return $upload->getContents();
}
/**
* @param array $data member of $_FILE
* @throws Loco_error_UploadException
*/
public function __construct( array $data ){
// https://www.php.net/manual/en/features.file-upload.errors.php
$code = (int) $data['error'];
switch( $code ){
case UPLOAD_ERR_OK:
break;
case UPLOAD_ERR_INI_SIZE:
throw new Loco_error_UploadException('The uploaded file exceeds the upload_max_filesize directive in php.ini',$code);
case UPLOAD_ERR_FORM_SIZE:
throw new Loco_error_UploadException('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form',$code);
case UPLOAD_ERR_PARTIAL:
throw new Loco_error_UploadException('The uploaded file was only partially uploaded',$code);
case UPLOAD_ERR_NO_FILE:
throw new Loco_error_UploadException('No file was uploaded, or data is empty',$code);
case UPLOAD_ERR_NO_TMP_DIR:
throw new Loco_error_UploadException('Missing temporary folder for uploaded file',$code);
case UPLOAD_ERR_CANT_WRITE:
throw new Loco_error_UploadException('Failed to save uploaded file to disk',$code);
case UPLOAD_ERR_EXTENSION:
throw new Loco_error_UploadException('Your server blocked the file upload',$code);
default:
throw new Loco_error_UploadException('Unknown file upload error',$code);
}
// Upload is OK according to PHP, but may need moving (if upload_tmp_dir is not in open_basedir)
$path = $data['tmp_name'];
if( ! Loco_fs_File::is_readable($path) ){
$safe = get_temp_dir().basename($path);
if( $safe !== $path ){
Loco_error_AdminNotices::debug( sprintf('Moving uploaded file: %s -> %s',$path,$safe) );
if( move_uploaded_file( $path, $safe ) ){
register_shutdown_function('unlink',$safe);
$data['tmp_name'] = $safe;
$path = $safe;
}
}
}
if( ! is_file($path) ){
throw new Loco_error_UploadException('Uploaded file is not readable',UPLOAD_ERR_NO_FILE);
}
// upload ok, but check it's not empty
if( 0 === filesize($path) ){
throw new Loco_error_UploadException('Uploaded file contains no data',UPLOAD_ERR_NO_FILE);
}
$this->data = $data;
parent::__construct($path);
}
/**
* Get name of original file
* @return string
*/
public function getOriginalName(){
return $this->data['name'];
}
}

View File

@@ -0,0 +1,297 @@
<?php
class Loco_error_AdminNotices extends Loco_hooks_Hookable {
/**
* @var Loco_error_AdminNotices
*/
private static $singleton;
/**
* @var Loco_error_Exception[]
*/
private $errors = [];
/**
* Inline messages are handled by our own template views
* @var bool
*/
private $inline = false;
/**
* @return Loco_error_AdminNotices
*/
public static function get(){
self::$singleton or self::$singleton = new Loco_error_AdminNotices;
return self::$singleton;
}
/**
* Enable temporary buffering of PHP errors, reducing error reporting to debug level.
* Call restore_error_handler to stop capturing.
* @param int $level PHP error level bit mask, e.g. E_WARNING
* @return void
*/
public static function capture( $level ){
set_error_handler( [__CLASS__,'handle_error'], $level );
}
/**
* @internal
* @param int $errno
* @param string $errstr
*/
public static function handle_error( $errno, $errstr /*$errfile, $errline*/ ){
if( $errno & (E_ERROR|E_USER_ERROR) ){
return false;
}
$label = $errno & (E_WARNING|E_USER_WARNING) ? 'Warning' : 'Notice';
self::debug( '[PHP '.$label.'] '.$errstr );
return true;
}
/**
* @param Loco_error_Exception $error
* @return Loco_error_Exception
*/
public static function add( Loco_error_Exception $error ){
$notices = self::get();
// Skip repeated error messages in same stack
foreach( $notices->errors as $previous ){
if( $error->isIdentical($previous) ){
return $previous;
}
}
// if exception wasn't thrown we have to do some work to establish where it was invoked
if( __FILE__ === $error->getRealFile() ){
$error->setCallee(1);
}
// write error immediately under WP_CLI
if( 'cli' === PHP_SAPI && class_exists('WP_CLI',false) ){
$error->logCli();
return $error;
}
// else buffer notices for displaying when UI is ready
$notices->errors[] = $error;
// do late flush if we missed the boat
if( did_action('loco_admin_init') ){
$notices->on_loco_admin_init();
}
if( did_action('admin_notices') ){
$notices->on_admin_notices();
}
// Log message automatically if enabled
if( $error->loggable() ){
$error->log();
}
return $error;
}
/**
* Raise a success message
* @param string $message
* @return Loco_error_Exception
*/
public static function success( $message ){
$notice = new Loco_error_Success($message);
return self::add( $notice->setCallee(1) );
}
/**
* Raise a failure message
* @param string $message
* @return Loco_error_Exception
*/
public static function err( $message ){
$notice = new Loco_error_Exception($message);
return self::add( $notice->setCallee(1) );
}
/**
* Raise a warning message
* @param string $message
* @return Loco_error_Exception
*/
public static function warn( $message ){
$notice = new Loco_error_Warning($message);
return self::add( $notice->setCallee(1) );
}
/**
* Raise a generic info message
* @param string $message
* @return Loco_error_Exception
*/
public static function info( $message ){
$notice = new Loco_error_Notice($message);
return self::add( $notice->setCallee(1) );
}
/**
* Raise a debug notice, if debug is enabled
* @param string $message
* @return Loco_error_Debug
*/
public static function debug( $message ){
$notice = new Loco_error_Debug($message);
$notice->setCallee(1);
loco_debugging() and self::add( $notice );
return $notice;
}
/**
* Destroy and return buffer
* @return Loco_error_Exception[]
*/
public static function destroy(){
$notices = self::$singleton;
if( $notices instanceof Loco_error_AdminNotices ){
$buffer = $notices->errors;
$notices->errors = [];
self::$singleton = null;
return $buffer;
}
return [];
}
/**
* @codeCoverageIgnore
* @deprecated Since PHP 5.4 there is no need to cast array via calls to jsonSerialize
*/
public static function destroyAjax(){
$data = [];
foreach( self::destroy() as $notice ){
$data[] = $notice->jsonSerialize();
}
return $data;
}
/**
* @return void
*/
private function flushHtml(){
if( $this->errors ){
$htmls = [];
foreach( $this->errors as $error ){
$html = sprintf (
'<p><strong class="has-icon">%s:</strong> <span>%s</span></p>',
esc_html( $error->getTitle() ),
esc_html( $error->getMessage() )
);
$styles = [ 'notice', 'notice-'.$error->getType() ];
if( $this->inline ){
$styles[] = 'inline';
}
if( $links = $error->getLinks() ){
$styles[] = 'has-nav';
$html .= '<nav>'.implode( '<span> | </span>', $links ).'</nav>';
}
$htmls[] = '<div class="'.implode(' ',$styles).'">'.$html.'</div>';
}
$this->errors = [];
echo implode("\n", $htmls),"\n";
}
}
/**
* @return void
*/
private function flushCli(){
foreach( $this->errors as $e ){
$e->logCli();
}
$this->errors = [];
}
/**
* admin_notices action handler.
*/
public function on_admin_notices(){
if( ! $this->inline ){
$this->flushHtml();
}
}
/**
* loco_admin_notices callback.
* Unlike WordPress "admin_notices" this fires from within template layout at the point we want them, hence they are marked as "inline"
*/
public function on_loco_admin_notices(){
$this->inline = true;
$this->flushHtml();
}
/**
* loco_admin_init callback
* When we know a Loco admin controller will render the page we will control the point at which notices are printed
*/
public function on_loco_admin_init(){
$this->inline = true;
}
/**
* @internal
* Make sure we always see notices if hooks didn't fire
*/
public function __destruct(){
$this->inline = false;
$this->flush();
// handle situation where test case will have lost the buffer
if( $this->errors && 'cli' === PHP_SAPI ){
throw new RuntimeException('Notices not flushed before destruction');
}
}
/**
* @param int $level
* @return Loco_error_Exception[]
*/
public function filter( $level ){
$e = [];
foreach( $this->errors as $error ){
if( $error->getLevel() <= $level ){
$e[] = $error;
}
}
return $e;
}
/**
* @internal
*/
public function flush(){
if( class_exists('WP_CLI',false) ){
$this->flushCli();
}
else if( loco_doing_ajax() ){
$this->errors = [];
}
else if( 'cli' !== PHP_SAPI ){
$this->flushHtml();
}
// else probably in unit test and not properly handled, leave significant errors in buffer
else {
$this->errors = $this->filter( Loco_error_Exception::LEVEL_WARNING );
}
return $this;
}
}

View File

@@ -0,0 +1,53 @@
<?php
/**
* Developer notice
*/
class Loco_error_Debug extends Loco_error_Exception {
/**
* {@inheritdoc}
*/
public function getType(){
return 'debug';
}
/**
* {@inheritdoc}
*/
public function getTitle(){
return __('Debug','loco-translate');
}
/**
* {@inheritdoc}
*/
public function getLevel(){
return Loco_error_Exception::LEVEL_DEBUG;
}
/**
* {@inheritDoc}
*/
public function logCli(){
WP_CLI::debug( $this->getMessage(), 'loco' );
}
/**
* Log debugging message to file without raising admin notice
* @codeCoverageIgnore
*/
public static function trace( ...$args ){
$message = array_shift($args);
if( $args ){
$message = vsprintf($message,$args);
}
$debug = new Loco_error_Debug($message);
$debug->setCallee(1);
$debug->log();
}
}

View File

@@ -0,0 +1,270 @@
<?php
/**
* Generic exception that we know has come from the Loco plugin
*/
class Loco_error_Exception extends Exception implements JsonSerializable {
const LEVEL_ERROR = 0;
const LEVEL_WARNING = 1;
const LEVEL_DEBUG = 2;
const LEVEL_NOLOG = 3;
/**
* Links to help docs etc.. to show along side error message
* @var array
*/
private $links = [];
/**
* Override file in which exception was thrown
* @var string
*/
private $_file;
/**
* Override line number from where exception was thrown
* @var int
*/
private $_line;
/**
* Whether log file writing is enabled
* @var bool
*/
private $_log = true;
/**
* {@inheritdoc}
*/
public function __construct( $message = '', $code = 0, $previous = null ) {
parent::__construct( $message, $code, $previous );
}
/**
* @return Throwable
*/
private function getRootException(){
$current = $this;
// note that getPrevious is absent in PHP < 5.3
while( method_exists($current,'getPrevious') && ( $next = $current->getPrevious() ) ){
$current = $next;
}
return $current;
}
/**
* @return string
*/
public function getRealFile(){
if( $this->_file ){
return $this->_file;
}
return $this->getRootException()->getFile();
}
/**
* @return int
*/
public function getRealLine(){
if( $this->_line ){
return $this->_line;
}
return $this->getRootException()->getLine();
}
/**
* @return array
*/
public function getRealTrace(){
return $this->getRootException()->getTrace();
}
/**
* @param int $depth number of levels up from callee
*/
public function setCallee( int $depth = 0 ):self {
$stack = debug_backtrace(0);
$callee = $stack[$depth];
$this->_file = $callee['file'];
$this->_line = $callee['line'];
// TODO could also log the stack trace from $depth upwards, but not required unless being logged or thrown
return $this;
}
/**
* Write this error to file regardless of log level
* @return void
*/
public function log(){
$file = new Loco_fs_File( $this->getRealFile() );
$path = $file->getRelativePath( loco_plugin_root() );
$text = sprintf('[Loco.%s] "%s" in %s:%u', $this->getType(), $this->getMessage(), $path, $this->getRealLine() );
// separate error log for cli tests
if( 'cli' === PHP_SAPI && defined('LOCO_TEST_DATA_ROOT') ){
error_log( '['.date('c').'] '.$text."\n", 3, 'debug.log' );
//fwrite( STDERR, $this->getType().': '.$this->getMessage()."\n" );
}
// Else write to default PHP log, but note that WordPress may have set this to wp-content/debug.log.
// If no `error_log` is set this will send message to the SAPI, so check your httpd/fast-cgi errors too.
else {
error_log( $text, 0 );
}
}
/**
* Get view template for rendering error to HTML.
* @return string path relative to root tpl directory
*/
public function getTemplate(){
return 'admin/errors/generic';
}
/**
* Get notice level short code as a string
* @return string
*/
public function getType(){
return 'error';
}
/**
* Get verbosity level
* @return int
*/
public function getLevel(){
return self::LEVEL_ERROR;
}
/**
* Call wp cli logging function
* @return void
*/
public function logCli(){
WP_CLI::error( $this->getMessage(), false );
}
/**
* Get localized notice level name
* @return string
*/
public function getTitle(){
return __('Error','loco-translate');
}
/**
* @return array
*/
#[ReturnTypeWillChange]
public function jsonSerialize(){
$a = [
'code' => $this->getCode(),
'type' => $this->getType(),
'title' => $this->getTitle(),
'message' => $this->getMessage(),
];
/*if( loco_debugging() ){
$a['file'] = str_replace( ABSPATH, '', $this->getRealFile() );
$a['line'] = $this->getRealLine();
$a = self::recurseJsonSerialize($a,$this);
}*/
return $a;
}
/**
* @param string[] $a
* @return array modified from $a
* @codeCoverageIgnore
*/
private static function recurseJsonSerialize( array $a, Exception $child ){
$a['class'] = get_class($child);
$a['trace'] = $child->getTraceAsString();
$parent = $child->getPrevious();
if( $parent instanceof Exception ){
$a['previous'] = self::recurseJsonSerialize([],$parent);
}
return $a;
}
/**
* Push navigation links into error. Use for help pages etc..
* @param string $href
* @param string $text
* @return Loco_error_Exception
*/
public function addLink( $href, $text ){
$this->links[] = sprintf('<a href="%s">%s</a>', esc_url($href), esc_html($text) );
return $this;
}
/**
* @return array
*/
public function getLinks(){
return $this->links;
}
/**
* Convert generic exception to one of ours
* @param Exception $e original error
* @return Loco_error_Exception
*/
public static function convert( Exception $e ){
if( $e instanceof Loco_error_Exception ){
return $e;
}
return new Loco_error_Exception( $e->getMessage(), $e->getCode(), $e );
}
/**
* Test if this error should be automatically logged
* @return bool
*/
public function loggable(){
if( $this->_log ){
// Log messages of minimum priority and up, depending on debug mode
// note that non-debug level is in line with error_reporting set by WordPress (notices ignored)
$priority = loco_debugging() ? Loco_error_Exception::LEVEL_DEBUG : Loco_error_Exception::LEVEL_WARNING;
return $this->getLevel() <= $priority;
}
return false;
}
/**
* Suppress logging for this error. e.g if you want to warn in UI but don't want to pollute log files.
* @return self
*/
public function noLog(){
$this->_log = false;
return $this;
}
/**
* Check if passed exception is effectively the same as this one
* @return bool
*/
public function isIdentical( Exception $other ){
return $this->getCode() === $other->getCode()
&& $this->getMessage() === $other->getMessage()
&& $this->getType() === ( $other instanceof Loco_error_Exception ? $other->getType() : 0 );
}
}

View File

@@ -0,0 +1,7 @@
<?php
/**
*
*/
class Loco_error_LocaleException extends Loco_error_Exception {
}

View File

@@ -0,0 +1,39 @@
<?php
/**
* Generic, non-critical informational notice
* Not to be confused with an error notice. This is for onscreen messages, and won't be logged.
*/
class Loco_error_Notice extends Loco_error_Exception {
/**
* {@inheritdoc}
*/
public function getType(){
return 'info';
}
/**
* {@inheritdoc}
*/
public function getTitle(){
return __('Notice','loco-translate');
}
/**
* {@inheritdoc}
*/
public function getLevel(){
return Loco_error_Exception::LEVEL_NOLOG;
}
/**
* {@inheritDoc}
*/
public function logCli(){
WP_CLI::log( $this->getMessage() );
}
}

View File

@@ -0,0 +1,51 @@
<?php
/**
* Exception thrown when parsing fails
*/
class Loco_error_ParseException extends Loco_error_Exception {
/**
* @var string[]
*/
private $context;
/**
* @param int line number
* @param int column number
* @param string source in which to identify line and column
* @return self
*/
public function setContext( $line, $column, $source ){
$this->context = [];
$lines = preg_split( '/\\r?\\n/', $source, $line+1 );
$offset = $line - 1;
if( isset($lines[$offset]) ){
$this->context[] = $lines[$offset];
$this->context[] = str_repeat(' ', max(0,$column-1) ).'^';
}
$this->message = sprintf("Error at line %u, column %u: %s", $line, $column, $this->message );
return $this;
}
/**
* @param int zero-based offset to failure point
* @param string source in which to identify line and column
* @return self
*/
public function setOffsetContext( $offset, $source ){
$lines = preg_split( '/\\r?\\n/', substr($source,0,$offset) );
$line = count($lines);
$column = 1 + strlen( end($lines) );
return $this->setContext( $line, $column, $source );
}
/**
* @return string
*/
public function getContext(){
return is_array($this->context) ? implode("\n",$this->context) : '';
}
}

View File

@@ -0,0 +1,39 @@
<?php
/**
* Success message. Not really an exception obviously, but compatible with Loco_error_AdminNotices
*/
class Loco_error_Success extends Loco_error_Exception {
/**
* {@inheritdoc}
*/
public function getType(){
return 'success';
}
/**
* {@inheritdoc}
*/
public function getTitle(){
return __('OK','loco-translate');
}
/**
* {@inheritdoc}
*/
public function getLevel(){
return Loco_error_Exception::LEVEL_NOLOG;
}
/**
* {@inheritDoc}
*/
public function logCli(){
WP_CLI::success( $this->getMessage() );
}
}

View File

@@ -0,0 +1,8 @@
<?php
/**
* Exception type for failed uploads
*/
class Loco_error_UploadException extends Loco_error_Exception {
}

View File

@@ -0,0 +1,38 @@
<?php
/**
* Generic warning
*/
class Loco_error_Warning extends Loco_error_Exception {
/**
* {@inheritdoc}
*/
public function getType(){
return 'warning';
}
/**
* {@inheritdoc}
*/
public function getTitle(){
return __('Warning','loco-translate');
}
/**
* {@inheritdoc}
*/
public function getLevel(){
return Loco_error_Exception::LEVEL_WARNING;
}
/**
* {@inheritDoc}
*/
public function logCli(){
WP_CLI::warning( $this->getMessage() );
}
}

View File

@@ -0,0 +1,16 @@
<?php
/**
* File system write error.
* Generally thrown from Loco_fs_FileWriter
*/
class Loco_error_WriteException extends Loco_error_Exception {
/**
* {@inheritdoc}
*/
public function getTitle(){
return __('Permission denied','loco-translate');
}
}

View File

@@ -0,0 +1,11 @@
<?php
/**
*
*/
class Loco_error_XmlParseException extends Loco_error_Exception {
public function getTitle(){
return __('XML parse error','loco-translate');
}
}

View File

@@ -0,0 +1,49 @@
<?php
/**
*
*/
class Loco_fs_Directory extends Loco_fs_File {
/**
* Recursive flag for internal use
*/
private bool $r = false;
/**
* {@inheritDoc}
*/
public function isDirectory():bool {
return true;
}
/**
* Set recursive flag for use when traversing directory trees
*/
public function setRecursive( bool $bool ):self {
$this->r = $bool;
return $this;
}
/**
* @return bool
*/
public function isRecursive():bool {
return $this->r;
}
/**
* Create this directory for real.
*
* @throws Loco_error_WriteException
* @return Loco_fs_Directory
*/
public function mkdir():self {
$this->getWriteContext()->mkdir();
return $this;
}
}

View File

@@ -0,0 +1,218 @@
<?php
/**
* Dummy file that just holds content in memory.
* Use when you don't want to commit data to disk but you need to pass a typed file object
*/
class Loco_fs_DummyFile extends Loco_fs_File {
/**
* @var string
*/
private $contents = '';
/**
* @var int
*/
private $mtime = 0;
/**
* @var int
*/
private $fmode = 0644;
/**
* @var int
*/
private $uid = 0;
/**
* @var int
*/
private $gid = 0;
public function __construct($path){
parent::__construct($path);
$this->mtime = time();
}
/**
* {@inheritdoc}
*/
public function exists():bool {
return false;
}
/**
* {@inheritdoc}
*/
public function getContents():string {
return $this->contents;
}
/**
* {@inheritdoc}
*/
public function size(){
return strlen($this->contents);
}
/**
* {@inheritdoc}
*/
public function putContents( string $data ):int {
$this->contents = $data;
return strlen($data);
}
/**
* {@inheritdoc}
*/
public function modified(){
return $this->mtime;
}
/**
* Allow forcing of modified stamp for testing purposes
* @return Loco_fs_File
*/
public function touch( $modified ){
$this->mtime = (int) $modified;
return $this;
}
/**
* {@inheritdoc}
*/
public function mode(){
return $this->fmode;
}
/**
* {@inheritdoc}
*/
public function chmod( $mode, $recursive = false ){
$this->fmode = (int) $mode;
return $this;
}
/**
* TODO implement in parent
*/
public function chown( $uid = null, $gid = null ){
if( is_int($uid) ){
$this->uid = $uid;
}
if( is_int($gid) ){
$this->gid = $gid;
}
return $this;
}
/**
* {@inheritdoc}
*/
public function copy( $dest ){
$copy = new Loco_fs_DummyFile($dest);
foreach( get_object_vars($this) as $prop => $value ){
$copy->$prop = $value;
}
return $copy;
}
/**
* {@inheritdoc}
*/
public function uid(){
return $this->uid;
}
/**
* {@inheritdoc}
*/
public function gid(){
return $this->gid;
}
/*
* {@inheritdoc}
public function writable(){
throw new Exception('Who did this?');
$mode = $this->mode();
// world writable
if( $mode & 02 ){
return true;
}
// group writable
if( ( $mode & 020 ) && $this->gid() === Loco_compat_PosixExtension::getgid() ){
return true;
}
// owner writable
if( ( $mode & 0200 ) && $this->uid() === Loco_compat_PosixExtension::getuid() ){
return true;
}
// else locked:
return false;
}*/
/**
* {@inheritdoc}
*/
public function creatable():bool {
return false;
}
/**
* {@inheritDoc}
*/
public function md5():string {
return md5( $this->getContents() );
}
/**
* {@inheritDoc}
*/
public function getWriteContext():Loco_fs_FileWriter {
return new _LocoDummyFileWriter($this);
}
}
/**
* @internal
*/
class _LocoDummyFileWriter extends Loco_fs_FileWriter {
/**
* @inheritdoc
*/
public function writable(){
return true;
}
/**
* @inheritdoc
*/
public function authorize(){
return $this;
}
}

View File

@@ -0,0 +1,709 @@
<?php
/**
*
*/
class Loco_fs_File {
/**
* @var Loco_fs_FileWriter
*/
private $w;
/**
* Path to file
* @var string
*/
private $path;
/**
* Cached pathinfo() data
* @var array
*/
private $info;
/**
* Base path which path has been normalized against
* @var string
*/
private $base;
/**
* Flag set when current path is relative
* @var bool
*/
private $rel;
/**
* Check if a path is absolute and return fixed slashes for readability
* @param string $path
* @return string fixed path, or "" if not absolute
*/
public static function abs( $path ){
$path = (string) $path;
if( '' !== $path ){
$chr1 = substr($path,0,1);
// return unmodified path if starts "/"
if( '/' === $chr1 ){
return $path;
}
// Windows drive path if "X:" or network path if "\\"
$chr2 = (string) substr($path,1,1);
if( '' !== $chr2 ){
if( ':' === $chr2 || ( '\\' === $chr1 && '\\' === $chr2 ) ){
return strtoupper($chr1).$chr2.strtr( substr($path,2), '\\', '/' );
}
}
}
// else path is relative, so return falsy string
return '';
}
/**
* Test if a path looks absolute
*/
public static function is_abs( $path ){
return '' !== $path && ( '/' === $path[0] || preg_match('!^\\\\\\\\|.:\\\\!',$path) );
}
/**
* Call PHP is_readable() but suppress E_WARNING when path is outside open_basedir.
* @param string $path
* @return bool
*/
public static function is_readable( $path ){
if( '' === $path || '.' === $path[0] ){
throw new InvalidArgumentException('Relative paths disallowed');
}
// Reduce PHP errors from is_readable to debug messages
Loco_error_AdminNotices::capture(E_NOTICE|E_WARNING);
$bool = is_readable($path);
restore_error_handler();
return $bool;
}
/**
* Create file with initial, unvalidated path
* @param string $path
*/
public function __construct( $path ){
$this->setPath( $path );
}
/**
* Internally set path value and flag whether relative or absolute
* @param string $path
* @return void
*/
private function setPath( $path ){
$path = (string) $path;
if( $fixed = self::abs($path) ){
$path = $fixed;
$this->rel = false;
}
else {
$this->rel = true;
}
if( $path !== $this->path ){
$this->path = $path;
$this->info = null;
}
}
/**
* @return bool
*/
public function isAbsolute(){
return ! $this->rel;
}
/**
* @internal
*/
public function __clone(){
$this->cloneWriteContext( $this->w );
}
/**
* Copy write context with our file reference
*/
private function cloneWriteContext( ?Loco_fs_FileWriter $context = null ):void {
if( $context ){
$context = clone $context;
$this->w = $context->setFile($this);
}
}
/**
* Get file system context for operations that *modify* the file system.
* Read operations and operations that stat the file will always do so directly.
*/
public function getWriteContext():Loco_fs_FileWriter {
if( ! $this->w ){
$this->w = new Loco_fs_FileWriter( $this );
}
return $this->w;
}
/**
* @internal
*/
private function pathinfo(){
return is_array($this->info) ? $this->info : ( $this->info = pathinfo($this->path) );
}
/**
* Checks if a file exists, and is within open_basedir restrictions.
* This does NOT check if file permissions allow PHP to read it. Call $this->readable() or self::is_readable($path).
*/
public function exists():bool {
return file_exists($this->path);
}
/**
* Check if file is writable by the current write context
*/
public function writable():bool {
return $this->getWriteContext()->writable();
}
/**
* Check if the file exists and is readable by the current PHP process.
*/
public function readable():bool {
return self::is_readable($this->path);
}
/**
* Check if file is removable by the current write context
*/
public function deletable():bool {
$parent = $this->getParent();
if( $parent && $parent->writable() ){
// sticky directory requires that either the file its parent is owned by effective user
if( $parent->mode() & 01000 ){
$writer = $this->getWriteContext();
if( $writer->isDirect() && ( $uid = Loco_compat_PosixExtension::getuid() ) ){
return $uid === $this->uid() || $uid === $parent->uid();
}
// else delete operation won't be done directly, so can't preempt sticky problems
// TODO is it worth comparing FTP username etc.. for ownership?
}
// defaulting to "deletable" based on fact that parent is writable.
return true;
}
return false;
}
/**
* Get owner uid
* @return int|false
*/
public function uid(){
return fileowner($this->path);
}
/**
* Get group gid
* @return int|false
*/
public function gid(){
return filegroup($this->path);
}
/**
* Check if file can't be overwritten when existent, nor created when non-existent
* This does not check permissions recursively as directory trees are not built implicitly
*/
public function locked():bool {
if( $this->exists() ){
return ! $this->writable();
}
if( $dir = $this->getParent() ){
return ! $dir->writable();
}
return true;
}
/**
* Check if full path can be built to non-existent file.
*/
public function creatable():bool {
$file = $this;
while( $file = $file->getParent() ){
if( $file->exists() ){
return $file->writable();
}
}
return false;
}
/**
* @return string
*/
public function dirname(){
$info = $this->pathinfo();
return $info['dirname'];
}
/**
* @return string
*/
public function basename(){
$info = $this->pathinfo();
return $info['basename'];
}
/**
* @return string
*/
public function filename(){
$info = $this->pathinfo();
return $info['filename'];
}
/**
* Gets final file extension, e.g. "html" in "foo.php.html"
* @return string
*/
public function extension(){
$info = $this->pathinfo();
return $info['extension'] ?? '';
}
/**
* Gets full file extension after first dot ("."), e.g. "php.html" in "foo.php.html"
* @return string
*/
public function fullExtension(){
$bits = explode('.',$this->basename(),2);
return array_key_exists(1,$bits) ? $bits[1] : '';
}
/**
* @return string
*/
public function getPath(){
return $this->path;
}
/**
* Get file modification time as unix timestamp in seconds
* @return int
*/
public function modified(){
return filemtime( $this->path );
}
/**
* Get file size in bytes
* @return int
*/
public function size(){
return filesize( $this->path );
}
/**
* @return int
*/
public function mode(){
if( is_link($this->path) ){
$stat = lstat( $this->path );
$mode = $stat[2];
}
else {
$mode = fileperms($this->path);
}
return $mode;
}
/**
* Set file mode
* @param int $mode file mode integer e.g 0664
* @param bool $recursive whether to set recursively (directories)
* @return Loco_fs_File
*/
public function chmod( $mode, $recursive = false ){
$this->getWriteContext()->chmod( $mode, $recursive );
return $this->clearStat();
}
/**
* Clear stat cache if any file data has changed
* @return Loco_fs_File
*/
public function clearStat(){
$this->info = null;
// PHP 5.3.0 Added optional clear_realpath_cache and filename parameters.
if( version_compare( PHP_VERSION, '5.3.0', '>=' ) ){
clearstatcache( true, $this->path );
}
// else no choice but to drop entire stat cache
else {
clearstatcache();
}
return $this;
}
/**
* @return string
*/
public function __toString(){
return $this->getPath();
}
/**
* Check if passed path is equal to ours
* @param string|self $ref
* @return bool
*/
public function equal( $ref ){
return $this->path === (string) $ref;
}
/**
* Normalize path for string comparison, resolves redundant dots and slashes.
* @param string $base path to prefix
* @return string
*/
public function normalize( $base = '' ){
if( $path = self::abs($base) ){
$base = $path;
}
if( $base !== $this->base ){
$path = $this->path;
if( '' === $path ){
$this->setPath($base);
}
else {
if( ! $this->rel || ! $base ){
$b = [];
}
else {
$b = self::explode( $base, [] );
}
$b = self::explode( $path, $b );
$this->setPath( implode('/',$b) );
}
$this->base = $base;
}
return $this->path;
}
/**
* Get real path if file is real, but without altering internal path property.
* Also skips call to realpath() when likely to raise E_WARNING due to open_basedir
* @return string
*/
public function getRealPath(){
if( $this->readable() ){
$path = realpath( $this->getPath() );
if( is_string($path) ){
return $path;
}
}
return '';
}
/**
* @param string $path
* @param string[] $b
* @return array
*/
private static function explode( $path, array $b ){
$a = explode( '/', $path );
foreach( $a as $i => $s ){
if( '' === $s ){
if( 0 !== $i ){
continue;
}
}
if( '.' === $s ){
continue;
}
if( '..' === $s ){
if( array_pop($b) ){
continue;
}
}
$b[] = $s;
}
return $b;
}
/**
* Get path relative to given location, unless path is already relative
* @param string $base Base path
* @return string path relative to given base
*/
public function getRelativePath( $base ){
$path = $this->normalize();
if( self::abs($path) ){
// base may require normalizing
$file = new Loco_fs_File($base);
$base = $file->normalize();
$length = strlen($base)+1;
// if we are below given base path, return ./relative
if( substr($path,0,$length) === $base.'/' ){
if( strlen($path) > $length ){
return substr( $path, $length );
}
// else paths were identical
return '';
}
// else attempt to find nearest common root
$i = 0;
$source = explode('/',$base);
$target = explode('/',$path);
while( isset($source[$i]) && isset($target[$i]) && $source[$i] === $target[$i] ){
$i++;
}
if( $i > 1 ){
$depth = count($source) - $i;
$build = array_merge( array_fill( 0, $depth, '..' ), array_slice( $target, $i ) );
$path = implode( '/', $build );
}
}
// else return unmodified
return $path;
}
/**
* Test if file is a directory
*/
public function isDirectory():bool {
if( $this->readable() ){
return is_dir($this->path);
}
return '' === $this->extension();
}
/**
* Load contents of file into a string
*/
public function getContents():string {
return file_get_contents($this->path);
}
/**
* Check if path is under a theme directory
*/
public function underThemeDirectory():bool {
return Loco_fs_Locations::getThemes()->check( $this->path );
}
/**
* Check if path is under a plugin directory
*/
public function underPluginDirectory():bool {
return Loco_fs_Locations::getPlugins()->check( $this->path );
}
/**
* Check if path is under wp-content directory
*/
public function underContentDirectory():bool {
return Loco_fs_Locations::getContent()->check( $this->path );
}
/**
* Check if path is under WordPress root directory (ABSPATH)
*/
public function underWordPressDirectory():bool {
return Loco_fs_Locations::getRoot()->check( $this->path );
}
/**
* Check if path is under the global system directory
*/
public function underGlobalDirectory():bool {
return Loco_fs_Locations::getLangs()->check( $this->path );
}
/**
* @return Loco_fs_Directory|null
*/
public function getParent():?Loco_fs_Directory {
$dir = null;
$path = $this->dirname();
if( '.' !== $path && $this->path !== $path ){
$dir = new Loco_fs_Directory( $path );
$dir->cloneWriteContext( $this->w );
}
return $dir;
}
/**
* Copy this file for real
* @param string $dest new path
* @throws Loco_error_WriteException
* @return Loco_fs_File new file
*/
public function copy( $dest ){
$copy = clone $this;
$copy->path = $dest;
$copy->clearStat();
$this->getWriteContext()->copy($copy);
return $copy;
}
/**
* Move/rename this file for real
* @param Loco_fs_File $dest target file with new path
* @throws Loco_error_WriteException
* @return Loco_fs_File original file that should no longer exist
*/
public function move( Loco_fs_File $dest ){
$this->getWriteContext()->move($dest);
return $this->clearStat();
}
/**
* Delete this file for real
* @throws Loco_error_WriteException
* @return Loco_fs_File
*/
public function unlink(){
$recursive = $this->isDirectory();
$this->getWriteContext()->delete( $recursive );
return $this->clearStat();
}
/**
* Copy this object with an alternative file extension
* @param string $ext new extension
* @return self
*/
public function cloneExtension( $ext ){
return $this->cloneBasename( $this->filename().'.'.ltrim($ext,'.') );
}
/**
* Copy this object with an alternative name under the same directory
* @param string $name new name
* @return self
*/
public function cloneBasename( $name ){
$file = clone $this;
$file->path = rtrim($file->dirname(),'/').'/'.$name;
$file->info = null;
return $file;
}
/**
* Ensure full parent directory tree exists
* @return Loco_fs_Directory|null
*/
public function createParent(){
$dir = $this->getParent();
if( $dir instanceof Loco_fs_Directory && ! $dir->exists() ){
$dir->mkdir();
}
return $dir;
}
/**
* @param string $data file contents
* @return int number of bytes written to file
*/
public function putContents( string $data ):int {
$this->getWriteContext()->putContents($data);
$this->clearStat();
return $this->size();
}
/**
* Establish what part of the WordPress file system this is.
* Value is that used by WP_Automatic_Updater::should_update.
* @return string "core", "plugin", "theme" or "translation"
*/
public function getUpdateType(){
// global languages directory root, and canonical subdirectories
$dirpath = (string) ( $this->isDirectory() ? $this : $this->getParent() );
$sub = Loco_fs_Locations::getGlobal()->rel($dirpath);
if( is_string($sub) && '' !== $sub ){
list($root) = explode('/', $sub, 2 );
if( '.' === $root || 'themes' === $root || 'plugins' === $root ){
return 'translation';
}
}
// theme and plugin locations can be at any depth
else if( $this->underThemeDirectory() ){
return 'theme';
}
else if( $this->underPluginDirectory() ){
return 'plugin';
}
// core locations are under WordPress root, but not under wp-content
else if( $this->underWordPressDirectory() && ! $this->underContentDirectory() ){
return 'core';
}
// else not an update type
return '';
}
/**
* Get MD5 hash of file contents
*/
public function md5():string {
if( $this->exists() ) {
return md5_file( $this->path );
}
else {
return 'd41d8cd98f00b204e9800998ecf8427e';
}
}
}

View File

@@ -0,0 +1,520 @@
<?php
/**
* Lazy file iterator. Pulls directory listings when required.
*/
class Loco_fs_FileFinder implements Iterator, Countable, Loco_fs_FileListInterface {
/**
* Top-level search directories
* @var Loco_fs_FileList
*/
private $roots;
/**
* Directories to search, including those descended into
* @var Loco_fs_FileList|null
*/
private $subdir;
/**
* Whether directories all read into memory
* @var bool
*/
private $cached = false;
/**
* File listing already matched
* @var Loco_fs_FileList|null
*/
private $cache;
/**
* Internal array pointer for whole list of paths
* @var int
*/
private $i;
/**
* Internal pointer for directory being read
* @var int|null
*/
private $d;
/**
* Current directory being read
* @var resource|null
*/
private $dir;
/**
* Path of current directory being read
* @var string
*/
private $cwd;
/**
* Whether directories added to search will be recursive by default
* @var bool
*/
private $recursive = false;
/**
* Whether currently recursing into subdirectories
* This is switched on and off as each directory is opened
* @var bool|null
*/
private $recursing;
/**
* Whether to follow symlinks when recursing into subdirectories
* Root-level symlinks are always resolved when possible
* @var bool
*/
private $symlinks = true;
/**
* Registry of followed links by their original path
* @var Loco_fs_FileList
*/
private $linked;
/**
* List of file extensions to filter on and group by
* @var null|Loco_fs_FileList[]
*/
private $exts = null;
/**
* List of directory names to exclude from recursion
* @var array
*/
private $excluded = [];
/**
* Create initial list of directories to search
* @param string $root default root to start
*/
public function __construct( string $root = '' ){
$this->roots = new Loco_fs_FileList;
$this->linked = new Loco_fs_FileList;
if( $root ){
$this->addRoot( $root );
}
}
/**
* Set recursive state of all defined roots
*/
public function setRecursive( bool $bool ):self {
$this->invalidate();
$this->recursive = $bool;
/* @var $dir Loco_fs_Directory */
foreach( $this->roots as $dir ){
$dir->setRecursive( $bool );
}
return $this;
}
/**
* Whether to follow symlinks
*/
public function followLinks( bool $bool ):self {
$this->invalidate();
$this->symlinks = $bool;
return $this;
}
/**
* Get the original symlink that was followed for a given file
*/
public function getFollowed( string $path ):?Loco_fs_Link {
/* @var Loco_fs_Link $link */
foreach( $this->linked as $link ){
$file = $link->resolve();
$orig = $file->getPath();
// exact match on followed path
if( $orig === $path ){
return $link;
}
// match further up the directory tree
if( $file instanceof Loco_fs_Directory ){
$orig = trailingslashit($orig);
$snip = strlen($orig);
if( $orig === substr($path,0,$snip) ){
return new Loco_fs_Link( $link->getPath().'/'.substr($path,$snip) );
}
}
}
return null;
}
private function invalidate():void {
$this->cached = false;
$this->cache = null;
$this->subdir = null;
}
/**
* Get all files found as a list object
* @return Loco_fs_FileList <int,Loco_fs_File>
*/
public function export():Loco_fs_FileList{
if( ! $this->cached ){
$this->rewind();
while( $this->valid() ){
$this->next();
}
}
return $this->cache;
}
/**
* @return Loco_fs_FileList[]
*/
public function exportGroups():array {
$this->cached || $this->export();
return $this->exts;
}
/**
* Add a directory root to search.
*/
public function addRoot( string $root, ?bool $recursive = null ):self {
$this->invalidate();
$dir = new Loco_fs_Directory($root);
$this->roots->add( $dir );
// new directory inherits current global setting unless set explicitly
$dir->setRecursive( is_bool($recursive) ? $recursive : $this->recursive );
return $this;
}
/**
* Get all root directories to be searched
*/
public function getRootDirectories():Loco_fs_FileList {
return $this->roots;
}
/**
* Filter results by given file extensions
*/
public function group( ...$exts ):self {
return $this->filterExtensions($exts);
}
/**
* Filter results by file extensions given in array
* @param string[] $exts File extension strings
*/
public function filterExtensions( array $exts ):self {
$this->invalidate();
$this->exts = [];
foreach( $exts as $ext ){
$this->exts[ ltrim($ext,'*.') ] = new Loco_fs_FileList;
}
return $this;
}
/**
* Add one or more paths to exclude from listing
*/
public function exclude( ...$paths ):self {
$this->invalidate();
foreach( $paths as $path ){
$file = new Loco_fs_File($path);
// if path is absolute, add straight onto list
if( $file->isAbsolute() ){
$file->normalize();
$this->excluded[] = $file;
}
// else append to all defined roots
else {
foreach( $this->roots as $dir ) {
$file = new Loco_fs_File( $dir.'/'.$path );
$file->normalize();
$this->excluded[] = $file;
}
}
}
return $this;
}
/**
* Export excluded paths as file objects
* @return Loco_fs_File[]
*/
public function getExcluded():array {
return $this->excluded;
}
/**
* @param Loco_fs_Directory $dir
* @return void
*/
private function open( Loco_fs_Directory $dir ){
$path = $dir->getPath();
$recursive = $dir->isRecursive();
if( is_link($path) ){
$link = new Loco_fs_Link($path);
if( $link->isDirectory() ){
$path = $link->resolve()->getPath();
$this->linked->add($link);
}
}
$this->cwd = $path;
$this->recursing = $recursive;
$this->dir = opendir($path);
}
/**
* @return void
*/
private function close(){
closedir( $this->dir );
$this->dir = null;
$this->recursing = null;
}
/**
* Test if given path is matched by one of our exclude rules
*/
public function isExcluded( string $path ):bool {
/* @var Loco_fs_File $excl */
foreach( $this->excluded as $excl ){
if( $excl->equal($path) ){
return true;
}
}
return false;
}
/**
* Read next valid file path from root directories
*/
private function read():?Loco_fs_File {
while( is_resource($this->dir) ){
while( $f = readdir($this->dir) ){
// dot-files always excluded
if( '.' === substr($f,0,1) ){
continue;
}
$path = $this->cwd.'/'.$f;
// early path exclusion check
if( $this->isExcluded($path) ){
continue;
}
// early filter on file extension when grouping
if( is_array($this->exts) ){
$ext = pathinfo($f,PATHINFO_EXTENSION);
// missing file extension only relevant for directories
if( '' === $ext ){
if( ! $this->recursing || ! is_dir($path) ){
continue;
}
}
// any other extension can be skipped
else if( ! array_key_exists($ext,$this->exts) ){
continue;
}
}
// follow symlinks (subdir hash ensures against loops)
if( is_link($path) ){
if( ! $this->symlinks ){
continue;
}
$link = new Loco_fs_Link($path);
if( $file = $link->resolve() ){
$path = $file->getPath();
if( $this->isExcluded($path) ){
continue;
}
$this->linked->add($link);
}
else {
continue;
}
}
// add subdirectory to recursion list, or skip
if( is_dir($path) ){
if( $this->recursing ){
$subdir = new Loco_fs_Directory($path);
$subdir->setRecursive(true);
$this->subdir->add( $subdir );
}
continue;
}
// file represented as object containing original path
$file = new Loco_fs_File($path);
$this->add($file);
return $file;
}
$this->close();
// Advance directory and continue outer loop
$d = $this->d + 1;
if( $this->subdir->offsetExists($d) ){
$this->d = $d;
$this->open( $this->subdir->offsetGet($d) );
}
// else no directories left to search
else {
break;
}
}
// at end of all available files
$this->cached = true;
return null;
}
/**
* {@inheritDoc}
*/
public function add( Loco_fs_File $file ):bool {
if( is_array($this->exts) ){
$ext = $file->extension();
if( array_key_exists($ext,$this->exts) ){
$this->exts[$ext]->add($file);
}
// edge case where symlink has correct extension, but resolved path does not.
else if( $this->symlinks && $this->linked->has($file) ){
Loco_error_Debug::trace('Symbolic link resolves to a file excluded by filter (%s)',$file->basename());
}
else {
$glob = implode(',',array_keys($this->exts));
Loco_error_Debug::trace('Should have filtered out %s when grouping by *.{%s}', $file->basename(), $glob );
}
}
if( $this->cache->add($file) ){
$this->i++;
return true;
}
return false;
}
/**
* @return int
*/
#[ReturnTypeWillChange]
public function count():int {
return count( $this->export() );
}
/**
* @return Loco_fs_File|null
*/
#[ReturnTypeWillChange]
public function current(){
$i = $this->i;
if( is_int($i) && isset($this->cache[$i]) ){
return $this->cache[$i];
}
return null;
}
/**
* @return Loco_fs_File|null
*/
#[ReturnTypeWillChange]
public function next(){
if( $this->cached ){
$i = $this->i + 1;
if( isset($this->cache[$i]) ){
$this->i = $i;
return $this->cache[$i];
}
}
else {
$file = $this->read();
if( $file instanceof Loco_fs_File ) {
return $file;
}
}
// else at end of all directory listings
$this->i = null;
return null;
}
/**
* @return int
*/
#[ReturnTypeWillChange]
public function key(){
return $this->i;
}
/**
* @return bool
*/
#[ReturnTypeWillChange]
public function valid(){
// may be in lazy state after rewind
// must do initial read now in case list is empty
return is_int($this->i);
}
/**
* @return void
*/
#[ReturnTypeWillChange]
public function rewind(){
if( $this->cached ){
$this->cache->rewind();
$this->i = $this->cache->key();
}
else {
$this->d = 0;
$this->dir = null;
$this->cache = new Loco_fs_FileList;
// add only root directories that exist
$this->subdir = new Loco_fs_FileList;
/* @var Loco_fs_Directory $root */
foreach( $this->roots as $root ){
if( $root instanceof Loco_fs_Directory && $root->readable() && ! $this->isExcluded( $root->getPath() ) ){
$this->subdir->add($root);
}
}
if( $this->subdir->offsetExists(0) ){
$this->i = -1;
$this->open( $this->subdir->offsetGet(0) );
$this->next();
}
else {
$this->i = null;
$this->subdir = null;
$this->cached = true;
}
}
}
/**
* Test whether internal list has been fully cached in memory
*/
public function isCached():bool {
return $this->cached;
}
}

View File

@@ -0,0 +1,119 @@
<?php
/**
* Collection of unique file references
*
* @method Loco_fs_File[] getArrayCopy
*/
class Loco_fs_FileList extends ArrayIterator implements Loco_fs_FileListInterface {
/**
* Hash map for ensuring files only added once
* @var array
*/
private $unique = [];
/**
* Construct with initial list if files
* @param Loco_fs_File[] $a
*/
public function __construct( $a = [] ){
parent::__construct();
foreach( $a as $file ){
$this->add( $file );
}
}
/**
* Use instead of clone because that does weird things to ArrayIterator instances.
* Note that this does NOT clone individual file members.
*/
public function copy():self {
return new Loco_fs_FileList( $this->getArrayCopy() );
}
/**
* Like getArrayCopy, but exports string paths
* @return string[]
*/
public function export():array {
$a = [];
foreach( $this as $file ){
$a[] = (string) $file;
}
return $a;
}
/**
* @internal
*/
public function __toString():string {
return implode( "\n", $this->getArrayCopy() );
}
/**
* Generate a unique key for file
*/
private function hash( Loco_fs_File $file ):string {
return $file->getRealPath() ?: $file->normalize();
}
/**
* {@inheritDoc}
*/
#[ReturnTypeWillChange]
public function offsetSet( $key, $value ){
throw new Exception('Use Loco_fs_FileList::add');
}
/**
* {@inheritDoc}
*/
public function add( Loco_fs_File $file ):bool {
$hash = $this->hash( $file );
if( isset($this->unique[$hash]) ){
return false;
}
$this->unique[$hash] = true;
parent::offsetSet( null, $file );
return true;
}
/**
* Check if given file is already in list
*/
public function has( Loco_fs_File $file ):bool {
$hash = $this->hash( $file );
return isset($this->unique[$hash]);
}
/**
* Get a copy of list with only files not contained in passed list
*/
public function diff( Loco_fs_FileList $not_in ):self {
$list = new Loco_fs_FileList;
foreach( $this as $file ){
$not_in->has($file) || $list->add( $file );
}
return $list;
}
/**
* Merge another list of the SAME TYPE uniquely on top of current one
*/
public function augment( loco_fs_FileList $list ):self {
foreach( $list as $file ){
$this->add( $file );
}
return $this;
}
}

View File

@@ -0,0 +1,11 @@
<?php
interface Loco_fs_FileListInterface extends Countable, Iterator {
/**
* Add a file uniquely
* @return bool whether file was added (and didn't already exist)
*/
public function add( Loco_fs_File $file ):bool;
}

View File

@@ -0,0 +1,80 @@
<?php
/**
* Object representing a file's permission bits
*/
class Loco_fs_FileMode {
/**
* inode protection mode
* @var int
*/
private $i;
/**
* Instantiate from integer file mode
* @param int
*/
public function __construct( $mode ){
$this->i = (int) $mode;
}
/**
* @return string
*/
public function __toString(){
return sprintf('%03o', $this->i & 07777 );
}
/**
* rwx style friendly formatting
* @return string
*/
public function format(){
$mode = $this->i;
$setuid = $mode & 04000;
$setgid = $mode & 02000;
$sticky = $mode & 01000;
return
$this->type().
( $mode & 0400 ? 'r' : '-' ).
( $mode & 0200 ? 'w' : '-' ).
( $mode & 0100 ? ($setuid?'s':'x') : ($setuid?'S':'-') ).
( $mode & 0040 ? 'r' : '-' ).
( $mode & 0020 ? 'w' : '-' ).
( $mode & 0010 ? ($setgid?'s':'x') : ($setgid?'S':'-') ).
( $mode & 0004 ? 'r' : '-' ).
( $mode & 0002 ? 'w' : '-' ).
( $mode & 0001 ? ($sticky?'t':'x') : ($sticky?'T':'-') );
}
/**
* File type bit field:
* http://man7.org/linux/man-pages/man2/stat.2.html
*/
public function type(){
$mode = $this->i & 0170000;
switch( $mode ){
case 0010000:
return '-';
case 0040000:
return 'd';
case 0120000:
return 'l';
case 0140000:
return 's';
case 0060000:
return 'c';
default:
return '-';
}
}
}

View File

@@ -0,0 +1,356 @@
<?php
/**
* Provides write operation context via the WordPress file system API
*/
class Loco_fs_FileWriter {
/**
* @var Loco_fs_File
*/
private $file;
/**
* @var WP_Filesystem_Base
*/
private $fs;
/**
* @param Loco_fs_File $file
*/
public function __construct( Loco_fs_File $file ){
$this->setFile($file);
$this->disconnect();
}
/**
* @param Loco_fs_File $file
* @return Loco_fs_FileWriter
*/
public function setFile( Loco_fs_File $file ){
$this->file = $file;
return $this;
}
/**
* Connect to alternative file system context
*
* @param WP_Filesystem_Base $fs
* @param bool $disconnected whether reconnect required
* @return Loco_fs_FileWriter
* @throws Loco_error_WriteException
*/
public function connect( WP_Filesystem_Base $fs, $disconnected = true ){
if( $disconnected && ! $fs->connect() ){
$errors = $fs->errors;
if( is_wp_error($errors) ){
foreach( $errors->get_error_messages() as $reason ){
Loco_error_AdminNotices::warn($reason);
}
}
throw new Loco_error_WriteException( __('Failed to connect to remote server','loco-translate') );
}
$this->fs = $fs;
return $this;
}
/**
* Revert to direct file system connection
* @return self
*/
public function disconnect(){
$this->fs = Loco_api_WordPressFileSystem::direct();
return $this;
}
/**
* Get mapped path for use in indirect file system manipulation
* @return string
*/
public function getPath(){
return $this->mapPath( $this->file->getPath() );
}
/**
* Map virtual path for remote file system
* @param string $path
* @return string
*/
private function mapPath( $path ){
if( ! $this->isDirect() ){
$base = untrailingslashit( Loco_fs_File::abs(loco_constant('WP_CONTENT_DIR')) );
$snip = strlen($base);
if( substr( $path, 0, $snip ) !== $base ){
// fall back to default path in case of symlinks
$base = trailingslashit(ABSPATH).'wp-content';
$snip = strlen($base);
if( substr( $path, 0, $snip ) !== $base ){
throw new Loco_error_WriteException('Remote path must be under WP_CONTENT_DIR');
}
}
$virt = $this->fs->wp_content_dir();
if( false === $virt ){
throw new Loco_error_WriteException('Failed to find WP_CONTENT_DIR via remote connection');
}
$virt = untrailingslashit( $virt );
$path = substr_replace( $path, $virt, 0, $snip );
}
return $path;
}
/**
* Test if a direct (not remote) file system
* @return bool
*/
public function isDirect(){
return $this->fs instanceof WP_Filesystem_Direct;
}
/**
* @return bool
*/
public function writable(){
return ! $this->disabled() && $this->fs->is_writable( $this->getPath() );
}
/**
* @param int $mode file mode integer e.g 0664
* @param bool $recursive whether to set recursively (directories)
* @return Loco_fs_FileWriter
* @throws Loco_error_WriteException
*/
public function chmod( $mode, $recursive = false ){
$this->authorize();
if( ! $this->fs->chmod( $this->getPath(), $mode, $recursive ) ){
// translators: %s refers to a file name, for which the chmod operation failed.
throw new Loco_error_WriteException( sprintf( __('Failed to chmod %s','loco-translate'), $this->file->basename() ) );
}
return $this;
}
/**
* @param Loco_fs_File $copy target for copy
* @return Loco_fs_FileWriter
* @throws Loco_error_WriteException
*/
public function copy( Loco_fs_File $copy ){
$this->authorize();
$source = $this->getPath();
$target = $this->mapPath( $copy->getPath() );
// bugs in WP file system "exists" methods means we must force $overwrite=true; so checking file existence first
if( $copy->exists() ){
Loco_error_AdminNotices::debug(sprintf('Cannot copy %s to %s (target already exists)',$source,$target));
throw new Loco_error_WriteException( __('Refusing to copy over an existing file','loco-translate') );
}
// ensure target directory exists, although in most cases copy will be in situ
$parent = $copy->getParent();
if( $parent && ! $parent->exists() ){
$this->mkdir($parent);
}
// perform WP file system copy method
if( ! $this->fs->copy($source,$target,true) ){
Loco_error_AdminNotices::debug(sprintf('Failed to copy %s to %s via "%s" method',$source,$target,$this->fs->method));
// translators: (1) Source file name (2) Target file name
throw new Loco_error_WriteException( sprintf( __('Failed to copy %1$s to %2$s','loco-translate'), basename($source), basename($target) ) );
}
return $this;
}
/**
* @param Loco_fs_File $dest target file with new path
* @return Loco_fs_FileWriter
* @throws Loco_error_WriteException
*/
public function move( Loco_fs_File $dest ){
$orig = $this->file;
try {
// target should have been authorized to create the new file
$context = clone $dest->getWriteContext();
$context->setFile($orig);
$context->copy($dest);
// source should have been authorized to delete the original file
$this->delete(false);
return $this;
}
catch( Loco_error_WriteException $e ){
Loco_error_AdminNotices::debug('copy/delete failure: '.$e->getMessage() );
throw new Loco_error_WriteException( sprintf( 'Failed to move %s', $orig->basename() ) );
}
}
/**
* @param bool $recursive
* @return self
* @throws Loco_error_WriteException
*/
public function delete( $recursive = false ){
$this->authorize();
if( ! $this->fs->delete( $this->getPath(), $recursive ) ){
// translators: %s refers to a file name, for which a delete operation failed.
throw new Loco_error_WriteException( sprintf( __('Failed to delete %s','loco-translate'), $this->file->basename() ) );
}
return $this;
}
/**
* @param string $data
* @return Loco_fs_FileWriter
* @throws Loco_error_WriteException
*/
public function putContents( $data ){
$this->authorize();
$file = $this->file;
if( $file->isDirectory() ){
// translators: %s refers to a directory name which was expected to be an ordinary file
throw new Loco_error_WriteException( sprintf( __('"%s" is a directory, not a file','loco-translate'), $file->basename() ) );
}
// file having no parent directory is likely an error, like a relative path.
$dir = $file->getParent();
if( ! $dir ){
throw new Loco_error_WriteException( sprintf('Bad file path "%s"',$file) );
}
// avoid chmod of existing file
if( $file->exists() ){
$mode = $file->mode();
}
// may have bypassed definition of FS_CHMOD_FILE
else {
$mode = defined('FS_CHMOD_FILE') ? FS_CHMOD_FILE : 0644;
// new file may also require directory path building
if( ! $dir->exists() ){
$this->mkdir($dir);
}
}
$fs = $this->fs;
$path = $this->getPath();
if( ! $fs->put_contents($path,$data,$mode) ){
// provide useful reason for failure if possible
if( $file->exists() && ! $file->writable() ){
Loco_error_AdminNotices::debug( sprintf('File not writable via "%s" method, check permissions on %s',$fs->method,$path) );
throw new Loco_error_WriteException( __("Permission denied to update file",'loco-translate') );
}
// directory path should exist or have thrown error earlier.
// directory path may not be writable by same fs context
if( ! $dir->writable() ){
Loco_error_AdminNotices::debug( sprintf('Directory not writable via "%s" method; check permissions for %s',$fs->method,$dir) );
throw new Loco_error_WriteException( __("Parent directory isn't writable",'loco-translate') );
}
// else reason for failure is not established
Loco_error_AdminNotices::debug( sprintf('Unknown write failure via "%s" method; check %s',$fs->method,$path) );
throw new Loco_error_WriteException( __('Failed to save file','loco-translate').': '.$file->basename() );
}
// trigger hook every time a file is written. This allows caches to be invalidated
try {
do_action( 'loco_file_written', $path );
}
catch( Exception $e ){
Loco_error_AdminNotices::add( Loco_error_Exception::convert($e) );
}
return $this;
}
/**
* Create current directory context
* @param Loco_fs_File|null $here optional working directory
* @throws Loco_error_WriteException
*/
public function mkdir( ?Loco_fs_File $here = null ):bool {
if( is_null($here) ){
$here = $this->file;
}
$this->authorize();
$fs = $this->fs;
// may have bypassed definition of FS_CHMOD_DIR
$mode = defined('FS_CHMOD_DIR') ? FS_CHMOD_DIR : 0755;
// find first ancestor that exists while building tree
$stack = [];
/* @var $parent Loco_fs_Directory */
while( $parent = $here->getParent() ){
array_unshift( $stack, $this->mapPath( $here->getPath() ) );
if( '/' === $parent->getPath() || $parent->readable() ){
// have existent directory, now build full path
foreach( $stack as $path ){
if( ! $fs->mkdir($path,$mode) ){
Loco_error_AdminNotices::debug( sprintf('mkdir(%s,%03o) failed via "%s" method;',var_export($path,1),$mode,$fs->method) );
throw new Loco_error_WriteException( __('Failed to create directory','loco-translate') );
}
}
return true;
}
$here = $parent;
}
// refusing to create directory when the entire path is missing. e.g. "/bad"
throw new Loco_error_WriteException( __('Failed to build directory path','loco-translate') );
}
/**
* Check whether write operations are permitted, or throw
* @throws Loco_error_WriteException
* @return self
*/
public function authorize(){
if( $this->disabled() ){
throw new Loco_error_WriteException( __('File modification is disallowed by your WordPress config','loco-translate') );
}
$opts = Loco_data_Settings::get();
// deny system file changes (fs_protect = 2)
if( 1 < $opts->fs_protect && $this->file->getUpdateType() ){
throw new Loco_error_WriteException( __('Modification of installed files is disallowed by the plugin settings','loco-translate') );
}
// we may need to examine multiple extensions, or there may be none for directories
$exts = array_slice( explode('.',strtolower($this->file->basename())), 1 );
if( ! $exts ){
return $this;
}
$ext = array_pop($exts);
// deny POT modification (pot_protect = 2)
// this assumes that templates all have .pot extension, which isn't guaranteed. UI should prevent saving of wrongly files like "default.po"
if( 'pot' === $ext && 1 < $opts->pot_protect ){
throw new Loco_error_WriteException( __( 'Modification of POT (template) files is disallowed by the plugin settings', 'loco-translate' ) );
}
// Full list of file extensions this plugin can modify; note that specific actions may limit this further.
$allow = [ 'po'=>1, 'pot'=>1, 'mo'=>1, 'json'=>1, 'po~'=>1, 'pot~'=>1, 'txt'=>1, 'xml'=>1, 'zip'=>1 ];
if( array_key_exists($ext,$allow) ){
return $this;
}
// Writing to PHP files is generally disallowed, but we need to write l10n.php cache files
if( preg_match('/php\\d*/i',$ext) ){
$prev = array_pop($exts);
if( 'mo' === $prev || 'l10n' === $prev ){
return $this;
}
}
throw new Loco_error_WriteException('File extension disallowed .'.$ext );
}
/**
* Check if file system modification is banned at WordPress level
* @return bool
*/
public function disabled(){
// WordPress >= 4.8
if( function_exists('wp_is_file_mod_allowed') ){
$context = apply_filters( 'loco_file_mod_allowed_context', 'download_language_pack', $this->file );
return ! wp_is_file_mod_allowed( $context );
}
// fall back to direct constant check
return (bool) loco_constant('DISALLOW_FILE_MODS');
}
}

Some files were not shown because too many files have changed in this diff Show More