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,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;
}
}