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