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