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,332 @@
<?php
/**
* Utility for compiling PO data to MO AND JSON files
*/
class Loco_gettext_Compiler {
/**
* @var Loco_api_WordPressFileSystem|null
*/
private $fs;
/**
* Target file group, where we're compiling to
* @var Loco_fs_Siblings
*/
private $files;
/**
* Result when files written
* @var Loco_fs_FileList
*/
private $done;
/**
* @var Loco_mvc_ViewParams
*/
private $progress;
/**
* Construct with primary file (PO) being saved
* @param Loco_fs_File $pofile Localised PO file which may or may not exist yet
*/
public function __construct( Loco_fs_File $pofile ){
$this->files = new Loco_fs_Siblings($pofile);
$this->progress = new Loco_mvc_ViewParams( [
'pobytes' => 0,
'mobytes' => 0,
'numjson' => 0,
'phbytes' => 0,
] );
// Connect compiler to the file system, if writing to disk for real
if( ! $pofile instanceof Loco_fs_DummyFile ) {
$this->fs = new Loco_api_WordPressFileSystem;
}
$this->done = new Loco_fs_FileList;
}
/**
* Write PO, MO and JSON siblings
*/
public function writeAll( Loco_gettext_Data $po, ?Loco_package_Project $project = null ):Loco_fs_FileList {
$this->writePo($po);
$this->writeMo($po);
if( $project ){
$this->writeJson($project,$po);
}
return $this->done;
}
/**
* @return int bytes written to PO file
* @throws Loco_error_WriteException
*/
public function writePo( Loco_gettext_Data $po ):int {
$file = $this->files->getSource();
// Perform PO file backup before overwriting an existing PO
if( $file->exists() && $this->fs ){
$backups = new Loco_fs_Revisions($file);
$backup = $backups->rotate($this->fs);
// debug backup creation only under cli. too noisy otherwise
if( $backup && 'cli' === PHP_SAPI && $backup->exists() ){
Loco_error_AdminNotices::debug( sprintf('Wrote backup: %s -> %s',$file->basename(),$backup->basename() ) );
}
}
$bytes = $this->writeFile( $file, $po->msgcat() );
$this->progress['pobytes'] = $bytes;
return $bytes;
}
/**
* @return int bytes written to MO file
*/
public function writeMo( Loco_gettext_Data $po ):int {
try {
$mofile = $this->files->getBinary();
$bytes = $this->writeFile( $mofile, $po->msgfmt() );
}
catch( Exception $e ){
Loco_error_AdminNotices::debug( $e->getMessage() );
Loco_error_AdminNotices::warn( __('PO file saved, but MO file compilation failed','loco-translate') );
$bytes = 0;
}
$this->progress['mobytes'] = $bytes;
// write PHP cache, if WordPress >= 6.5
if( 0 !== $bytes ){
try {
$this->progress['phbytes'] = $this->writePhp($po);
}
catch( Exception $e ){
Loco_error_AdminNotices::debug( $e->getMessage() );
}
}
return $bytes;
}
/**
* @return int bytes written to .l10n.php file
*/
private function writePhp( Loco_gettext_Data $po ):int {
$phfile = $this->files->getCache();
if( $phfile && class_exists('WP_Translation_File_PHP',false) ){
return $this->writeFile( $phfile, Loco_gettext_PhpCache::render($po) );
}
return 0;
}
/**
* @param Loco_package_Project $project Translation set, required to resolve script paths
* @param Loco_gettext_Data $po PO data to export
*/
public function writeJson( Loco_package_Project $project, Loco_gettext_Data $po ):Loco_fs_FileList {
$domain = $project->getDomain()->getName();
$pofile = $this->files->getSource();
$jsons = new Loco_fs_FileList;
// Allow plugins to dictate a single JSON file to hold all script translations for a text domain
// authors will additionally have to filter at runtime on load_script_translation_file
$path = apply_filters('loco_compile_single_json', '', $pofile->getPath(), $domain );
if( is_string($path) && '' !== $path ){
$refs = $po->splitRefs( $this->getJsExtMap() );
if( array_key_exists('js',$refs) && $refs['js'] instanceof Loco_gettext_Data ){
$jsonfile = new Loco_fs_File($path);
$json = $refs['js']->msgjed($domain,'*.js');
try {
if( '' !== $json ){
$this->writeFile($jsonfile,$json);
$jsons->add($jsonfile);
}
}
catch( Loco_error_WriteException $e ){
Loco_error_AdminNotices::debug( $e->getMessage() );
// translators: %s refers to a JSON file which could not be compiled due to an error
Loco_error_AdminNotices::warn( sprintf(__('JSON compilation failed for %s','loco-translate'),$jsonfile->basename()) );
}
}
}
// continue as per default, generating multiple per-script JSON
else {
$buffer = [];
$base_dir = $project->getBundle()->getDirectoryPath();
$extensions = array_keys( $this->getJsExtMap() );
$refsGrep = '\\.(?:'.implode('|',$extensions).')';
/* @var Loco_gettext_Data $fragment */
foreach( $po->exportRefs($refsGrep) as $ref => $fragment ){
$use = null;
// Reference could be a js source file, or a minified version. We'll try .min.js first, then .js
// Build systems may differ, but WordPress only supports these suffixes. See WP-CLI MakeJsonCommand.
if( substr($ref,-7) === '.min.js' ) {
$paths = [ $ref, substr($ref,-7).'.js' ];
}
else {
$paths = [ substr($ref,0,-3).'.min.js', $ref ];
}
// Try .js and .min.js paths to check whether deployed script actually exists
foreach( $paths as $path ){
// Hook into load_script_textdomain_relative_path like load_script_textdomain() does.
$url = $project->getBundle()->getDirectoryUrl().$path;
$path = apply_filters( 'load_script_textdomain_relative_path', $path, $url );
if( ! is_string($path) || '' === $path ){
continue;
}
// by default ignore js file that is not in deployed code
$file = new Loco_fs_File($path);
$file->normalize($base_dir);
if( apply_filters('loco_compile_script_reference',$file->exists(),$path,$domain) ){
$use = $path;
break;
}
}
// if neither exists in the bundle, this is a source path that will never be resolved at runtime
if( is_null($use) ){
Loco_error_AdminNotices::debug( sprintf('Skipping JSON for %s; script not found in bundle',$ref) );
}
// add .js strings to buffer for this json and merge if already present
else if( array_key_exists($use,$buffer) ){
$buffer[$use]->concat($fragment);
}
else {
$buffer[$use] = $fragment;
}
}
if( $buffer ){
// write all buffered fragments to their computed JSON paths
foreach( $buffer as $ref => $fragment ) {
$json = $fragment->msgjed($domain,$ref);
if( '' === $json ){
Loco_error_AdminNotices::debug( sprintf('Skipping JSON for %s; no translations',$ref) );
continue;
}
try {
$jsonfile = self::cloneJson($pofile,$ref,$domain);
$this->writeFile( $jsonfile, $json );
$jsons->add($jsonfile);
}
catch( Loco_error_WriteException $e ){
Loco_error_AdminNotices::debug( $e->getMessage() );
// phpcs:ignore -- comment already applied to this string elsewhere
Loco_error_AdminNotices::warn( sprintf(__('JSON compilation failed for %s','loco-translate'),$ref));
}
}
$buffer = null;
}
}
// clean up redundant JSONs including if no JSONs were compiled
if( Loco_data_Settings::get()->jed_clean ){
foreach( $this->files->getJsons($domain) as $path ){
$jsonfile = new Loco_fs_File($path);
if( ! $jsons->has($jsonfile) ){
try {
$jsonfile->unlink();
}
catch( Loco_error_WriteException $e ){
Loco_error_AdminNotices::debug('Unable to remove redundant JSON: '.$e->getMessage() );
}
}
}
}
$this->progress['numjson'] = $jsons->count();
return $jsons;
}
/**
* Clone localised file as a WordPress script translation file
*/
private static function cloneJson( Loco_fs_File $pofile, string $ref, string $domain ):Loco_fs_File {
$name = $pofile->filename();
// Theme author PO files have no text domain, but JSON files must always be prefixed
if( $domain && 'default' !== $domain && preg_match('/^[a-z]{2,3}(?:_[a-z\\d_]+)?$/i',$name) ){
$name = $domain.'-'.$name;
}
// Hashable reference is always finally unminified, as per load_script_textdomain()
if( '' !== $ref ){
$name .= '-'.self::hashRef($ref);
}
return $pofile->cloneBasename( $name.'.json' );
}
/**
* Hashable reference is always finally unminified, as per load_script_textdomain()
* @param string $ref script path relative to plugin base
*/
private static function hashRef( string $ref ):string {
if( substr($ref,-7) === '.min.js' ) {
$ref = substr($ref,0,-7).'.js';
}
return md5($ref);
}
/**
* Fetch compilation summary and raise most relevant success message
*/
public function getSummary():Loco_mvc_ViewParams {
$pofile = $this->files->getSource();
// Avoid calling this unless the initial PO save was successful
if( ! $this->progress['pobytes'] ){
throw new LogicException('PO not saved');
}
// Summary for localised file includes MO+JSONs
$mobytes = $this->progress['mobytes'];
$numjson = $this->progress['numjson'];
if( $mobytes && $numjson ){
Loco_error_AdminNotices::success( __('PO file saved and MO/JSON files compiled','loco-translate') );
}
else if( $mobytes ){
Loco_error_AdminNotices::success( __('PO file saved and MO file compiled','loco-translate') );
}
else {
// translators: Success notice where %s is a file extension, e.g. "PO"
Loco_error_AdminNotices::success( sprintf(__('%s file saved','loco-translate'),strtoupper($pofile->extension())) );
}
return $this->progress;
}
/**
* Obtain non-standard JavaScript file extensions.
* @return string[] where keys are PCRE safe extensions, all mapped to "js"
*/
private function getJsExtMap():array {
$map = ['js'=>'js','jsx'=>'js'];
$exts = Loco_data_Settings::get()->jsx_alias;
if( is_array($exts) && $exts ){
$exts = array_map( [__CLASS__,'pregQuote'], $exts);
$map = array_fill_keys($exts,'js') + $map;
}
return $map;
}
/**
* @internal
*/
private static function pregQuote( string $value ):string {
return preg_quote($value,'/');
}
/**
* @param Loco_fs_File $file
* @param string $data to write to given file
* @return int bytes written
*/
public function writeFile( Loco_fs_File $file, string $data ):int {
if( $this->fs ) {
$this->fs->authorizeSave( $file );
}
$bytes = $file->putContents($data);
if( 0 !== $bytes ){
$this->done->add($file );
}
return $bytes;
}
}

View File

@@ -0,0 +1,411 @@
<?php
loco_require_lib('compiled/gettext.php');
/**
* Wrapper for array forms of parsed PO data
*/
class Loco_gettext_Data extends LocoPoIterator implements JsonSerializable {
/**
* Normalize file extension to internal type.
* @return string Normalized file extension "po", "pot", "mo", "json" or "php"
* @throws Loco_error_Exception
*/
public static function ext( Loco_fs_File $file ):string {
$ext = rtrim( strtolower( $file->extension() ), '~' );
if( 'po' === $ext || 'pot' === $ext || 'mo' === $ext || 'json' === $ext ){
return $ext;
}
// only observing the full `.l10n.php` extension as a translation format.
if( 'php' === $ext && '.l10n.php' === substr($file->getPath(),-9) ){
return 'php';
}
// translators: Error thrown when attempting to parse a file that is not a supported translation file format
throw new Loco_error_Exception( sprintf( __('%s is not a Gettext file','loco-translate'), $file->basename() ) );
}
public static function load( Loco_fs_File $file, ?string $type = null ):self {
if( is_null($type) ) {
$type = self::ext($file);
}
$type = strtolower($type);
// catch parse errors, so we can inform user of which file is bad
try {
if( 'po' === $type || 'pot' === $type ){
return self::fromSource( $file->getContents() );
}
if( 'mo' === $type ){
return self::fromBinary( $file->getContents() );
}
if( 'json' === $type ){
return self::fromJson( $file->getContents() );
}
if( 'php' === $type ){
return self::fromPhp( $file->getPath() );
}
throw new InvalidArgumentException('No parser for '.$type.' files');
}
catch( Loco_error_ParseException $e ){
$path = $file->getRelativePath( loco_constant('WP_CONTENT_DIR') );
Loco_error_AdminNotices::debug( sprintf('Failed to parse %s as a %s file; %s',$path,strtoupper($type),$e->getMessage()) );
throw new Loco_error_ParseException( sprintf('Invalid %s file: %s',$type,basename($path)) );
}
}
/**
* Like load but just pulls header, saving a full parse
* @throws InvalidArgumentException
*/
public static function head( Loco_fs_File $file ):?LocoPoHeaders {
$p = new LocoPoParser( $file->getContents() );
$p->parse(0);
return $p->getHeader();
}
/**
* @param string $src PO source
*/
public static function fromSource( string $src ):self {
$p = new LocoPoParser($src);
return new Loco_gettext_Data( $p->parse() );
}
/**
* @param string $bin MO bytes
*/
public static function fromBinary( string $bin ):self {
$p = new LocoMoParser($bin);
return new Loco_gettext_Data( $p->parse() );
}
/**
* @param string $json Jed source
*/
public static function fromJson( string $json ):self {
$blob = json_decode( $json, true );
$p = new LocoJedParser( $blob['locale_data'] );
// note that headers outside of locale_data are won't be parsed out. we don't currently need them.
return new Loco_gettext_Data( $p->parse() );
}
/**
* @param string $path PHP file path
*/
public static function fromPhp( string $path ):self {
$blob = include $path;
if( ! is_array($blob) || ! array_key_exists('messages',$blob) ){
throw new Loco_error_ParseException('Invalid PHP translation file');
}
// refactor PHP structure into JED format
$p = new LocoMoPhpParser($blob);
return new Loco_gettext_Data( $p->parse() );
}
/**
* Create a dummy/empty instance with minimum content to be a valid PO file.
*/
public static function dummy():self {
return new Loco_gettext_Data( [ ['source'=>'','target'=>'Language:'] ] );
}
/**
* Ensure PO source is UTF-8.
* Required if we want PO code when we're not parsing it. e.g. source view
*/
public static function ensureUtf8( string $src ):string {
$src = loco_remove_bom($src,$cs);
if( ! $cs ){
// read PO header, requiring partial parse
try {
$cs = LocoPoHeaders::fromSource($src)->getCharset();
}
catch( Loco_error_ParseException $e ){
Loco_error_AdminNotices::debug( $e->getMessage() );
}
}
return loco_convert_utf8($src,$cs,false);
}
/**
* Compile messages to binary MO format
* @return string MO file source
* @throws Loco_error_Exception
*/
public function msgfmt():string {
if( 2 !== strlen("\xC2\xA3") ){
throw new Loco_error_Exception('Refusing to compile MO file. Please disable mbstring.func_overload'); // @codeCoverageIgnore
}
$mo = new LocoMo( $this, $this->getHeaders() );
$opts = Loco_data_Settings::get();
if( $opts->gen_hash ){
$mo->enableHash();
}
if( $opts->use_fuzzy ){
$mo->useFuzzy();
}
/*/ TODO optionally exclude .js strings
if( $opts->purge_js ){
$mo->filter....
}*/
return $mo->compile();
}
/**
* Get final UTF-8 string for writing to file
* @param bool $sort Whether to sort output, generally only for extracting strings
*/
public function msgcat( bool $sort = false ):string {
// set maximum line width, zero or >= 15
$this->wrap( Loco_data_Settings::get()->po_width );
// concat with default text sorting if specified
$po = $this->render( $sort ? [ 'LocoPoIterator', 'compare' ] : null );
// Prepend byte order mark only if configured
if( Loco_data_Settings::get()->po_utf8_bom ){
$po = "\xEF\xBB\xBF".$po;
}
return $po;
}
/**
* Compile JED flavour JSON
* @param string $domain text domain for JED metadata
* @param string $source reference to file that uses included strings
* @return string JSON source, or empty if JED file has no entries
*/
public function msgjed( string $domain = 'messages', string $source = '' ):string {
// note that JED is sparse, like MO. We won't write empty files.
$data = $this->exportJed();
if( 1 >= count($data) ){
return '';
}
$head = $this->getHeaders();
$head['domain'] = $domain;
// Pretty formatting for debugging. Doing as per WordPress and always escaping Unicode.
$json_options = 0;
if( Loco_data_Settings::get()->jed_pretty ){
$json_options |= loco_constant('JSON_PRETTY_PRINT') | loco_constant('JSON_UNESCAPED_SLASHES'); // | loco_constant('JSON_UNESCAPED_UNICODE');
}
// PO should have a date if localised properly
return json_encode( [
'translation-revision-date' => $head['PO-Revision-Date'],
'generator' => $head['X-Generator'],
'source' => $source,
'domain' => $domain,
'locale_data' => [
$domain => $data,
],
], $json_options );
}
/**
* @return array
*/
#[ReturnTypeWillChange]
public function jsonSerialize(){
$po = $this->getArrayCopy();
// exporting headers non-scalar so js doesn't have to parse them
try {
$headers = $this->getHeaders();
if( count($headers) && '' === $po[0]['source'] ){
$po[0]['target'] = $headers->getArrayCopy();
}
}
// suppress header errors when serializing
// @codeCoverageIgnoreStart
catch( Exception $e ){ }
// @codeCoverageIgnoreEnd
return $po;
}
/**
* Create a signature for use in comparing source strings between documents
*/
public function getSourceDigest():string {
$data = $this->getHashes();
return md5( implode("\1",$data) );
}
/**
* @param string[] $custom custom headers
*/
public function localize( Loco_Locale $locale, array $custom = [] ):self {
$date = gmdate('Y-m-d H:i').'+0000';
// headers that must always be set if absent
$defaults = [
'Project-Id-Version' => '',
'Report-Msgid-Bugs-To' => '',
'POT-Creation-Date' => $date,
];
// headers that must always override when localizing
$required = [
'PO-Revision-Date' => $date,
'Last-Translator' => '',
'Language-Team' => $locale->getName(),
'Language' => (string) $locale,
'Plural-Forms' => $locale->getPluralFormsHeader(),
'MIME-Version' => '1.0',
'Content-Type' => 'text/plain; charset=UTF-8',
'Content-Transfer-Encoding' => '8bit',
'X-Generator' => 'Loco https://localise.biz/',
'X-Loco-Version' => sprintf('%s; wp-%s; php-%s', loco_plugin_version(), $GLOBALS['wp_version'], PHP_VERSION ),
];
// Allow some existing headers to remain if PO was previously localized to the same language
$headers = $this->getHeaders();
$previous = Loco_Locale::parse( $headers->trimmed('Language') );
if( $previous->lang === $locale->lang ){
$header = $headers->trimmed('Plural-Forms');
if( preg_match('/^\\s*nplurals\\s*=\\s*\\d+\\s*;\\s*plural\\s*=/', $header) ) {
$required['Plural-Forms'] = $header;
}
if( $previous->region === $locale->region && $previous->variant === $locale->variant ){
unset( $required['Language-Team'] );
}
}
// set user's preferred Last-Translator credit if configured
if( function_exists('get_current_user_id') && get_current_user_id() ){
$prefs = Loco_data_Preferences::get();
$credit = (string) $prefs->credit;
if( '' === $credit ){
$credit = $prefs->default_credit();
}
// filter credit with current username and email
$user = wp_get_current_user();
$credit = apply_filters( 'loco_current_translator', $credit, $user->get('display_name'), $user->get('email') );
if( '' !== $credit ){
$required['Last-Translator'] = $credit;
}
}
$headers = $this->applyHeaders($required,$defaults,$custom);
// avoid non-empty POT placeholders that won't have been set from $defaults
if( 'PACKAGE VERSION' === $headers['Project-Id-Version'] ){
$headers['Project-Id-Version'] = '';
}
// finally allow headers to be modified via filter
$replaced = apply_filters( 'loco_po_headers', $headers );
if( $replaced instanceof LocoPoHeaders && $replaced !== $headers ){
$this->setHeaders($replaced);
}
return $this->initPo();
}
public function templatize( string $domain = '' ):self {
$date = gmdate('Y-m-d H:i').'+0000'; // <- forcing UCT
$defaults = [
'Project-Id-Version' => 'PACKAGE VERSION',
'Report-Msgid-Bugs-To' => '',
];
$required = [
'POT-Creation-Date' => $date,
'PO-Revision-Date' => 'YEAR-MO-DA HO:MI+ZONE',
'Last-Translator' => 'FULL NAME <EMAIL@ADDRESS>',
'Language-Team' => '',
'Language' => '',
'Plural-Forms' => 'nplurals=INTEGER; plural=EXPRESSION;',
'MIME-Version' => '1.0',
'Content-Type' => 'text/plain; charset=UTF-8',
'Content-Transfer-Encoding' => '8bit',
'X-Generator' => 'Loco https://localise.biz/',
'X-Loco-Version' => sprintf('%s; wp-%s; php-%s', loco_plugin_version(), $GLOBALS['wp_version'], PHP_VERSION ),
'X-Domain' => $domain,
];
$headers = $this->applyHeaders($required,$defaults);
// finally allow headers to be modified via filter
$replaced = apply_filters( 'loco_pot_headers', $headers );
if( $replaced instanceof LocoPoHeaders && $replaced !== $headers ){
$this->setHeaders($replaced);
}
return $this->initPot();
}
private function applyHeaders( array $required = [], array $defaults = [], array $custom = [] ):LocoPoHeaders {
$headers = $this->getHeaders();
// only set absent or empty headers from default list
foreach( $defaults as $key => $value ){
if( ! $headers[$key] ){
$headers[$key] = $value;
}
}
// add required headers with custom ones overriding
if( $custom ){
$required = array_merge( $required, $custom );
}
// TODO fix ordering weirdness here. required headers seem to get appended wrongly
foreach( $required as $key => $value ){
$headers[$key] = $value;
}
return $headers;
}
/**
* Remap proprietary base path when PO file is moving to another location.
*
* @param Loco_fs_File $origin the file that was originally extracted to (POT)
* @param Loco_fs_File $target the file that must now target references relative to itself
* @param string $vendor name used in header keys
* @return bool whether base header was altered
*/
public function rebaseHeader( Loco_fs_File $origin, Loco_fs_File $target, string $vendor ):bool {
$base = $target->getParent();
$head = $this->getHeaders();
$key = $head->normalize('X-'.$vendor.'-Basepath');
if( $key ){
$oldRelBase = $head[$key];
$oldAbsBase = new Loco_fs_Directory($oldRelBase);
$oldAbsBase->normalize( $origin->getParent() );
$newRelBase = $oldAbsBase->getRelativePath($base);
// new base path is relative to $target location
$head[$key] = $newRelBase;
return true;
}
return false;
}
/**
* Inherit meta values from header given, but leave standard headers intact.
*/
public function inheritHeader( LocoPoHeaders $source ):void {
$target = $this->getHeaders();
foreach( $source as $key => $value ){
if( 'X-' === substr($key,0,2) ) {
$target[$key] = $value;
}
}
}
/**
* @param string $podate Gettext data formatted "YEAR-MO-DA HO:MI+ZONE"
*/
public static function parseDate( string $podate ):int {
if( method_exists('DateTime','createFromFormat') ){
$objdate = DateTime::createFromFormat('Y-m-d H:iO', $podate);
if( $objdate instanceof DateTime ){
return $objdate->getTimestamp();
}
}
return strtotime($podate);
}
}

View File

@@ -0,0 +1,211 @@
<?php
loco_require_lib('compiled/gettext.php');
/**
* String extraction from source code.
*/
class Loco_gettext_Extraction {
/**
* @var Loco_package_Bundle
*/
private $bundle;
/**
* @var LocoExtracted
*/
private $extracted;
/**
* Extra strings to be pushed into domains
*/
private array $extras = [];
/**
* List of files skipped due to memory limit
* @var Loco_fs_FileList|null
*/
private $skipped;
/**
* Size in bytes of largest file encountered
*/
private int $maxbytes = 0;
/**
* Initialize extractor for a given bundle
*/
public function __construct( Loco_package_Bundle $bundle ){
loco_check_extension('ctype');
if( ! loco_check_extension('tokenizer') ){
throw new Loco_error_Exception('String extraction not available without required extension');
}
$this->bundle = $bundle;
$this->extracted = new LocoExtracted;
$this->extracted->setDomain('default');
$default = $bundle->getDefaultProject();
if( $default instanceof Loco_package_Project ){
$domain = $default->getDomain()->getName();
// wildcard stands in for empty text domain, meaning unspecified or dynamic domains will be included.
// note that strings intended to be in "default" domain must specify explicitly, or be included here too.
if( '*' === $domain ){
$domain = '';
$this->extracted->setDomain('');
}
// pull bundle's default metadata. these are translations that may not be encountered in files
$extras = [];
$header = $bundle->getHeaderInfo();
foreach( $bundle->getMetaTranslatable() as $prop => $notes ){
$text = $header->__get($prop);
if( is_string($text) && '' !== $text ){
$extras[] = ['source'=>$text, 'notes'=>$notes ];
}
}
if( $extras ){
$this->extras[$domain] = $extras;
}
}
}
/**
* @return self
*/
public function addProject( Loco_package_Project $project ){
$base = $this->bundle->getDirectoryPath();
$domain = (string) $project->getDomain();
// skip files larger than configured maximum
$opts = Loco_data_Settings::get();
$max = wp_convert_hr_to_bytes( $opts->max_php_size );
// *attempt* to raise memory limit to WP_MAX_MEMORY_LIMIT
if( function_exists('wp_raise_memory_limit') ){
wp_raise_memory_limit('loco');
}
/* @var Loco_fs_File $file */
foreach( $project->findSourceFiles() as $file ){
$type = $opts->ext2type( $file->extension() );
$fileref = $file->getRelativePath($base);
try {
$extr = loco_wp_extractor( $type, $file->fullExtension() );
if( 'php' === $type || 'twig' === $type) {
// skip large files for PHP, because token_get_all is hungry
if( 0 !== $max ){
$size = $file->size();
$this->maxbytes = max( $this->maxbytes, $size );
if( $size > $max ){
$list = $this->skipped or $list = ( $this->skipped = new Loco_fs_FileList() );
$list->add( $file );
continue;
}
}
// extract headers from theme files (templates and patterns)
if( $project->getBundle()->isTheme() ){
$extr->headerize( [
'Template Name' => ['notes'=>'Name of the template'],
], $domain );
if( preg_match('!^patterns/!', $fileref) ){
$extr->headerize([
'Title' => ['context'=>'Pattern title'],
'Description' => ['context'=>'Pattern description'],
], $domain );
}
}
}
// normally missing domains are treated as "default", but we'll make an exception for theme.json.
else if( 'json' === $type && $project->getBundle()->isTheme() ){
$extr->setDomain($domain);
}
$this->extracted->extractSource( $extr, $file->getContents(), $fileref );
}
catch( Exception $e ){
Loco_error_AdminNotices::debug('Error extracting '.$fileref.': '.$e->getMessage() );
}
}
return $this;
}
/**
* Add metadata strings deferred from construction. Note this will alter domain counts
* @return self
*/
public function includeMeta(){
foreach( $this->extras as $domain => $extras ){
foreach( $extras as $entry ){
$this->extracted->pushEntry($entry,$domain);
}
}
$this->extras = [];
return $this;
}
/**
* Add a custom source string constructed from `new Loco_gettext_String(msgid,[msgctxt])`
* @param Loco_gettext_String $string
* @param string $domain Optional text domain, if not current bundle's default
* @return void
*/
public function addString( Loco_gettext_String $string, $domain = '' ){
if( ! $domain ) {
$default = $this->bundle->getDefaultProject();
$domain = (string) ( $default ? $default->getDomain() : $this->extracted->getDomain() );
}
$index = $this->extracted->pushEntry( $string->exportSingular(), $domain );
if( $string->hasPlural() ){
$this->extracted->pushPlural( $string->exportPlural(), $index );
}
}
/**
* Get number of unique strings across all domains extracted (excluding additional metadata)
* @return array { default: x, myDomain: y }
*/
public function getDomainCounts(){
return $this->extracted->getDomainCounts();
}
/**
* Pull extracted data into POT, filtering out any unwanted domains
* @param string $domain
* @return Loco_gettext_Data
*/
public function getTemplate( $domain ){
do_action('loco_extracted_template', $this, $domain );
$data = new Loco_gettext_Data( $this->extracted->filter($domain) );
return $data->templatize( $domain );
}
/**
* Get total number of strings extracted from all domains, excluding additional metadata
* @return int
*/
public function getTotal(){
return $this->extracted->count();
}
/**
* Get list of files skipped, or null if none were skipped
* @return Loco_fs_FileList|null
*/
public function getSkipped(){
return $this->skipped;
}
/**
* Get size in bytes of largest file encountered, even if skipped.
* This is the value required of the max_php_size plugin setting to extract all files
* @return int
*/
public function getMaxPhpSize(){
return $this->maxbytes;
}
}

View File

@@ -0,0 +1,303 @@
<?php
/**
* Sync/Merge utility akin to msgmerge
*/
class Loco_gettext_Matcher extends LocoFuzzyMatcher {
/**
* Whether copying translation from source references (normally for a POT we won't)
* @var bool
*/
private $translate;
/**
* Number of translations pulled from source (when source is PO)
* @var int
*/
private $translated;
/**
* @var Loco_package_Project
*/
private $project;
/**
* @var array[]|null
*/
private $hashes;
public function __construct( Loco_package_Project $project ){
$this->project = $project;
}
/**
* Initialize matcher with current valid source strings (ref.pot)
* @param Loco_gettext_Data $pot POT reference
* @param bool $translate Whether copying translations from reference data
* @return int
*/
public function loadRefs( Loco_gettext_Data $pot, $translate = false ){
$ntotal = 0;
$this->translate = (bool) $translate;
$this->translated = 0;
/* @var LocoPoMessage $new */
foreach( $pot as $new ){
$ntotal++;
$this->add($new);
}
return $ntotal;
}
/**
* Perform a reverse lookup for a file reference from its pre-computed hash
*/
private function findScript( $hash ){
$map = $this->hashes;
// build full index of all script hashes under configured source locations.
if( is_null($map) ){
$map = [];
$scripts = clone $this->project->getSourceFinder();
$scripts->filterExtensions(['js']);
$basepath = $this->project->getBundle()->getDirectoryPath();
/* @var Loco_fs_File $jsfile */
foreach( $scripts->export() as $jsfile ){
$ref = $jsfile->getRelativePath($basepath);
if( substr($ref,-7) === '.min.js' ) {
$ref = substr($ref,0,-7).'.js';
}
$map[ md5($ref) ] = $ref;
}
$this->hashes = $map;
}
return array_key_exists($hash,$map) ? $map[$hash] : '';
}
/**
* Add further source strings from JSON/JED file
*/
private function loadJson( Loco_fs_File $file ):int {
$unique = 0;
$jed = json_decode( $file->getContents(), true );
if( ! is_array($jed) || ! array_key_exists('locale_data',$jed) || ! is_array($jed['locale_data']) ){
throw new Loco_error_Debug( $file->basename().' is not JED formatted');
}
// without a file reference, strings will never be compiled back to the correct JSON.
// if missing from JED, we'll attempt reverse match from scripts found on disk.
$ref = array_key_exists('source',$jed) ? $jed['source'] : '';
if( '' === $ref || ! is_string($ref) ){
$name = $file->basename();
$ref = preg_match('/-([0-9a-f]{32})\\.json$/',$name,$r) ? $this->findScript($r[1]) : '';
if( '' === $ref ){
throw new Loco_error_Debug($name.' has no "source" key; script is unknown');
}
// The hash is pre-computed and .js file is known to exist, so we'll skip filters here.
// The compiler will still filter this reference, so it could potentially yield a different hash.
// Loco_error_AdminNotices::debug($name.' has no "source" key; reverse matched '.$ref);
}
// We won't search the original script to know the line number, but this must be a valid reference
// TODO We could extract the JS here, and search for each string in the JSON, but may not be 100% reliable.
$ref .= ':1';
// not checking domain key. Should be valid if passed here and should only be one.
foreach( $jed['locale_data'] as $keys ){
foreach( $keys as $msgid => $arr ){
if( '' === $msgid || ! is_array($arr) || ! isset($arr[0]) ){
continue;
}
$msgctxt = '';
// Unglue "msgctxt\4msgid" unique key
$parts = explode("\4",$msgid,2);
if( array_key_exists(1,$parts) ){
list($msgctxt,$msgid) = $parts;
// TODO handle empty msgid case that uses weird "msgctxt\4(msgctxt)" format?
}
// string may exist in original template, and also in multiple JSONs.
$new = ['source'=>$msgid,'context'=>$msgctxt,'refs'=>$ref ];
$old = $this->getArrayRef($new);
if( $old ){
$refs = array_key_exists('refs',$old) ? (string) $old['refs'] : '';
if( '' === $refs ){
$old['refs'] = $ref;
}
else if( 0 === preg_match('/\\b'.preg_quote($ref,'/').'\\b/',$refs) ){
$old['refs'].= ' '.$ref;
}
$new = $old;
}
else {
$unique++;
}
// Add translation from JSON only if not present in merged PO already
if( $this->translate && ( ! array_key_exists('target',$new) || '' === $new['target'] ) ){
$new['target'] = $arr[0];
}
$message = new LocoPoMessage($new);
$this->add($message);
// handle plurals, noting that msgid_plural is not stored in JED structure
if( 1 < count($arr) ){
$index = 0;
$plurals = $old && array_key_exists('plurals',$old) ? $old['plurals'] : [];
while( array_key_exists(++$index,$arr) ){
if( array_key_exists($index,$plurals) ){
$raw = $plurals[$index];
if( $raw instanceof ArrayObject ){
$raw = $raw->getArrayCopy();
}
}
else {
$raw = ['source'=>'','target'=>''];
}
if( $this->translate && ( ! array_key_exists('target',$raw) || '' === $raw['target'] ) ){
$raw['target'] = $arr[$index];
}
// use translation as missing msgid_plural only if msgid matches msgstr (English file)
if( 1 === $index && '' === $raw['source'] ){
if( $arr[0] === $msgid ){
$raw['source'] = $arr[1];
}
/*else {
Loco_error_AdminNotices::debug('msgid_plural missing for msgid '.json_encode($msgid) );
}*/
}
$plurals[$index] = new LocoPoMessage($raw);
}
$message['plurals'] = $plurals;
}
}
}
return $unique;
}
/**
* Shortcut for loading multiple jsons with error tolerance
* @param Loco_fs_File[] $jsons
* @return int
*/
public function loadJsons( array $jsons ){
$n = 0;
foreach( $jsons as $jsonfile ){
try {
$n += $this->loadJson($jsonfile);
}
catch( Loco_error_Exception $e ){
Loco_error_AdminNotices::add($e);
}
}
return $n;
}
/**
* Update still-valid sources, deferring unmatched (new strings) for deferred fuzzy match
* @param LocoPoIterator $original Existing definitions
* @param LocoPoIterator $merged Resultant definitions
* @return string[] keys matched exactly
*/
public function mergeValid( LocoPoIterator $original, LocoPoIterator $merged ){
$valid = [];
$translate = $this->translate;
/* @var LocoPoMessage $old */
foreach( $original as $old ){
$new = $this->match($old);
// if existing source is still valid, merge any changes
if( $new instanceof LocoPoMessage ){
$p = clone $old;
$p->merge($new,$translate);
$merged->push($p);
$valid[] = $p->getKey();
// increment counter if translation was merged
if( $translate && ! $old->translated() ){
$this->translated += $new->translated();
}
}
}
return $valid;
}
/**
* Perform fuzzy matching after all exact matches have been attempted
* @param LocoPoIterator $merged Resultant definitions
* @return string[] strings fuzzy-matched
*/
public function mergeFuzzy( LocoPoIterator $merged ){
$fuzzy = [];
foreach( $this->getFuzzyMatches() as $pair ){
list($old,$new) = $pair;
$p = clone $old;
$p->merge($new);
$merged->push($p);
$fuzzy[] = $p->getKey();
}
return $fuzzy;
}
/**
* Add unmatched strings remaining as NEW source strings
* @param LocoPoIterator $merged Resultant definitions to accept new strings
* @return string[] strings added
*/
public function mergeAdded( LocoPoIterator $merged ){
$added = [];
$translate = $this->translate;
/* @var LocoPoMessage $new */
foreach( $this->unmatched() as $new ){
$p = clone $new;
// remove translations unless configured to keep
if( $p->translated() && ! $translate ){
$p->strip();
}
$merged->push($p);
$added[] = $p->getKey();
}
return $added;
}
/**
* Perform full merge and return result suitable from front end.
* @param LocoPoIterator $original Existing definitions
* @param LocoPoIterator $merged Resultant definitions
* @return array result
*/
public function merge( LocoPoIterator $original, LocoPoIterator $merged ){
$this->mergeValid($original,$merged);
$fuzzy = $this->mergeFuzzy($merged);
$added = $this->mergeAdded($merged);
/* @var LocoPoMessage $old */
$dropped = [];
foreach( $this->redundant() as $old ){
$dropped[] = $old->getKey();
}
// return to JavaScript with stats in the same form as old front end merge
return [
'add' => $added,
'fuz' => $fuzzy,
'del' => $dropped,
'trn' => $this->translated,
];
}
/**
* @param array $a
* @return array
*/
private function getArrayRef( array $a ){
$r = $this->getRef($a);
if( is_null($r) ){
return [];
}
if( $r instanceof ArrayObject ){
return $r->getArrayCopy();
}
throw new Exception( (is_object($r)?get_class($r):gettype($r) ).' returned from '.get_class($this).'::getRef');
}
}

View File

@@ -0,0 +1,230 @@
<?php
loco_require_lib('compiled/gettext.php');
/**
* Holds metadata about a PO file, cached as Transient
* TODO Non-PO files (MO/PHP) are sparse. We need to obtain the 100% mark from the PO sibling, and adjust completion.
*/
class Loco_gettext_Metadata extends Loco_data_Transient {
/**
* Generate abbreviated stats from parsed array data
* @param array $po in form returned from parser, including header message
* @return array in form ['t' => total, 'p' => progress, 'f' => fuzzy ];
*/
public static function stats( array $po ){
$t = $p = $f = 0;
/* @var $r array */
foreach( $po as $i => $r ){
// skip header
if( 0 === $i && empty($r['source']) && empty($r['context']) ){
continue;
}
// plural form
// TODO how should plural forms affect stats? should all forms be complete before 100% can be achieved? should offsets add to total??
if( isset($r['parent']) && is_int($r['parent']) ){
continue;
}
// singular form
$t++;
if( '' !== $r['target'] ){
$p++;
if( isset($r['flag']) /*&& LOCO_FLAG_FUZZY === $r['flag']*/ ){
$f++;
}
}
}
return compact('t','p','f');
}
/**
* {@inheritdoc}
*/
public function getKey(){
return 'po_'.md5( $this['rpath'] );
}
/**
* Load metadata from file, using cache if enabled.
* Note that this does not throw exception, check "valid" key
* @return Loco_gettext_Metadata
*/
public static function load( Loco_fs_File $po, $nocache = false ){
$bytes = $po->size();
$mtime = $po->modified();
// quick construct of a new metadata object. enough to query and validate cache
$meta = new Loco_gettext_Metadata( [
'rpath' => $po->getRelativePath( loco_constant('WP_CONTENT_DIR') ),
] );
// pull from cache if exists and has not been modified
if( $nocache || ! $meta->fetch() || $bytes !== $meta['bytes'] || $mtime !== $meta['mtime'] ){
// not available from cache, or cache is invalidated
$meta['bytes'] = $bytes;
$meta['mtime'] = $mtime;
// parse what is hopefully a PO file to get stats
try {
$data = Loco_gettext_Data::load($po)->getArrayCopy();
$meta['valid'] = true;
$meta['stats'] = self::stats($data);
}
catch( Exception $e ){
$meta['valid'] = false;
$meta['error'] = $e->getMessage();
}
}
// show cached debug notice as if file was being parsed
else if( $meta->offsetExists('error') ){
Loco_error_AdminNotices::debug($meta['error'].': '.$meta['rpath']);
}
// persist on shutdown with a useful TTL and keepalive
// Maximum lifespan: 10 days. Refreshed if accessed a day after being cached.
$meta->setLifespan(864000)->keepAlive(86400)->persistLazily();
return $meta;
}
/**
* Construct metadata from previously parsed PO data
* @return Loco_gettext_Metadata
*/
public static function create( Loco_fs_File $file, Loco_gettext_Data $data ){
return new Loco_gettext_Metadata( [
'valid' => true,
'bytes' => $file->size(),
'mtime' => $file->modified(),
'stats' => self::stats( $data->getArrayCopy() ),
] );
}
/**
* Get progress stats as simple array with keys, t=total, p=progress, f:flagged.
* Note that untranslated strings are never flagged, hence "f" includes all in "p"
* @return array in form ['t' => total, 'p' => progress, 'f' => fuzzy ];
*/
public function getStats(){
if( isset($this['stats']) ){
return $this['stats'];
}
// fallback to empty stats
return [ 't' => 0, 'p' => 0, 'f' => 0 ];
}
/**
* Get total number of messages, not including header and excluding plural forms
* @return int
*/
public function getTotal(){
$stats = $this->getStats();
return $stats['t'];
}
/**
* Get number of fuzzy messages, not including header
* @return int
*/
public function countFuzzy(){
$stats = $this->getStats();
return $stats['f'];
}
/**
* Get progress as a string percentage (minus % symbol)
* @return string
*/
public function getPercent(){
$stats = $this->getStats();
$n = max( 0, $stats['p'] - $stats['f'] );
$t = max( $n, $stats['t'] );
return loco_string_percent( $n, $t );
}
/**
* Get number of strings either untranslated or fuzzy.
* @return int
*/
public function countIncomplete(){
$stats = $this->getStats();
return max( 0, $stats['t'] - ( $stats['p'] - $stats['f'] ) );
}
/**
* Get number of strings completely untranslated (excludes fuzzy).
* @return int
*/
public function countUntranslated(){
$stats = $this->getStats();
return max( 0, $stats['t'] - $stats['p'] );
}
/**
* Echo progress bar using compiled function
* @return void
*/
public function printProgress(){
$stats = $this->getStats();
$flagged = $stats['f'];
$translated = $stats['p'];
$untranslated = $stats['t'] - $translated;
loco_print_progress( $translated, $untranslated, $flagged );
}
/**
* Get wordy summary of total strings
* @return string
*/
public function getTotalSummary(){
$total = $this->getTotal();
// translators: Where %s is any number of strings
return sprintf( _n('%s string','%s strings',$total,'loco-translate'), number_format_i18n($total) );
}
/**
* Get wordy summary including translation stats
* @return string
*/
public function getProgressSummary(){
$extra = [];
// translators: Shows percentage translated at top of editor
$stext = sprintf( __('%s%% translated','loco-translate'), $this->getPercent() ).', '.$this->getTotalSummary();
if( $num = $this->countFuzzy() ){
// translators: Shows number of fuzzy strings at top of editor
$extra[] = sprintf( __('%s fuzzy','loco-translate'), number_format($num) );
}
if( $num = $this->countUntranslated() ){
// translators: Shows number of untranslated strings at top of editor
$extra[] = sprintf( __('%s untranslated','loco-translate'), number_format($num) );
}
if( $extra ){
$stext .= ' ('.implode(', ', $extra).')';
}
return $stext;
}
/**
* @param bool $absolute
* @return string
*/
public function getPath( $absolute ){
$path = $this['rpath'];
if( $absolute && ! Loco_fs_File::abs($path) ){
$path = trailingslashit( loco_constant('WP_CONTENT_DIR') ).$path;
}
return $path;
}
}

View File

@@ -0,0 +1,54 @@
<?php
/**
* This saves the overhead of parsing a MO file when we already have it in memory,
* but lets us use WordPress's compact var_export utility so its files are identical to ours.
*/
class Loco_gettext_PhpCache extends WP_Translation_File_PHP {
/**
* @return string
*/
public static function render( Loco_gettext_Data $po ){
$me = new Loco_gettext_PhpCache('');
$me->headers = self::exportHeaders($po);
$me->entries = self::exportEntries($po);
// TODO support Loco_data_Settings::get()->php_pretty
return $me->export();
}
private static function exportHeaders( Loco_gettext_Data $po ){
$a = [];
foreach( $po->getHeaders() as $key => $value ){
$a[ strtolower($key) ] = (string) $value;
}
return $a;
}
private static function exportEntries( Loco_gettext_Data $po ){
$a = [];
$skip_fuzzy = ! Loco_data_Settings::get()->use_fuzzy;
// $max = preg_match('/^nplurals=(\\d)/',$po->getHeaders()->offsetGet('plural-forms'),$r) ? $r[1] : 0;
/* @var LocoPoMessage $message */
foreach( $po as $message ){
if( $skip_fuzzy && 4 === $message->__get('flag') ){
continue;
}
// Like JED, we must follow MO sparseness. Else empty strings will be merged on top of translations.
// TODO what should we do about partial completion of pluralized messages?
if( $message->translated() ) {
$a[ $message->getKey() ] = implode( "\0", $message->exportSerial() );
}
}
return $a;
}
/*private function prettyExport() {
return '<?php' . PHP_EOL . 'return ' . var_export($this->headers + ['messages'=>$this->entries],true) . ';' . PHP_EOL;
}*/
}

View File

@@ -0,0 +1,91 @@
<?php
/**
* A file finder built from search path references in a PO/POT file
*/
class Loco_gettext_SearchPaths extends Loco_fs_FileFinder {
/**
* Look up a relative file reference against search paths
* @param string $ref relative file path reference
* @return Loco_fs_File|null
*/
public function match( $ref ){
$excluded = new Loco_fs_Locations( $this->getExcluded() );
/* @var Loco_fs_Directory $base */
foreach( $this->getRootDirectories() as $base ){
$file = new Loco_fs_File($ref);
$path = $file->normalize( (string) $base );
if( $file->exists() && ! $excluded->check($path) ){
return $file;
}
}
return null;
}
/**
* Build search paths from a given PO/POT file that references other files
* @return Loco_gettext_SearchPaths
*/
public function init( Loco_fs_File $pofile, ?LocoHeaders $head = null ){
if( is_null($head) ){
loco_require_lib('compiled/gettext.php');
$head = LocoPoHeaders::fromSource( $pofile->getContents() );
}
$ninc = 0;
foreach( ['Poedit'] as $vendor ){
$key = 'X-'.$vendor.'-Basepath';
if( ! $head->has($key) ){
continue;
}
$dir = new Loco_fs_Directory( $head[$key] );
$base = $dir->normalize( $pofile->dirname() );
// base should be absolute, with the following search paths relative to it
$i = 0;
while( true ){
$key = sprintf('X-%s-SearchPath-%u', $vendor, $i++);
if( ! $head->has($key) ){
break;
}
// map search path to given base
$include = new Loco_fs_File( $head[$key] );
$include->normalize( $base );
if( $include->exists() ){
if( $include->isDirectory() ){
$this->addRoot( (string) $include );
$ninc++;
}
/*else {
TODO force specific file in Loco_fs_FileFinder
}*/
}
}
// exclude from search paths
$i = 0;
while( true ){
$key = sprintf('X-%s-SearchPathExcluded-%u', $vendor, $i++);
if( ! $head->has($key) ){
break;
}
// map excluded path to given base
$exclude = new Loco_fs_File( $head[$key] );
$exclude->normalize($base);
if( $exclude->exists() ){
$this->exclude( (string) $exclude );
}
// TODO implement wildcard exclusion
}
}
// Add po file location if no proprietary headers used
if( ! $ninc ){
$this->addRoot( $pofile->dirname() );
}
return $this;
}
}

View File

@@ -0,0 +1,116 @@
<?php
/**
* Gettext source string. Does not contain translations.
*/
class Loco_gettext_String {
/**
* @var array
*/
private $raw;
/**
* @var string
*/
private $plural;
/**
* Loco_gettext_String constructor.
*
* @param string $msgid Mandatory source
* @param string $msgctxt Optional context
*/
public function __construct( $msgid, $msgctxt = '' ){
$this->raw = [
'source' => (string) $msgid,
'context' => (string) $msgctxt,
];
}
/**
* Get singular form as raw array data
* @internal
* @return string[]
*/
public function exportSingular(){
return $this->raw;
}
/**
* Get plural form as raw array data
* @internal
* @return string[]
*/
public function exportPlural(){
return [
'source' => $this->plural,
];
}
/**
* @param string $prop
* @param string|array $value
* @param string $glue
* @return void
*/
private function merge( $prop, $value, $glue ){
if( is_string($value) ){
$value = [$value];
}
else if( ! is_array($value) ){
throw new InvalidArgumentException('Expected Array or String');
}
if( array_key_exists($prop,$this->raw) ){
$value = array_merge( explode($glue,$this->raw[$prop]), $value );
}
$this->raw[$prop] = implode($glue,$value);
}
/**
* @param array|string $refs
* @return self
*/
public function addFileReferences( $refs ){
$this->merge('refs',$refs,' ');
return $this;
}
/**
* @param array|string $notes
* @return self
*/
public function addExtractedComment( $notes ){
$this->merge('notes',$notes,' ');
return $this;
}
/**
* @param string $msgid_plural
* @return self
*/
public function pluralize( $msgid_plural ){
$this->plural = (string) $msgid_plural;
return $this;
}
/**
* @return bool
*/
public function hasPlural(){
return is_string($this->plural) && '' !== $this->plural;
}
/*public function __toString(){
return json_encode( $this->raw );
}*/
}

View File

@@ -0,0 +1,99 @@
<?php
/**
* Abstracts PO sync options held in custom headers
*/
class Loco_gettext_SyncOptions {
/**
* @var LocoPoHeaders
*/
private $head;
public function __construct( LocoPoHeaders $head ){
$this->head = $head;
}
/**
* Test if PO file has alternative template path
* @return bool
*/
public function hasTemplate(){
return '' !== $this->head->trimmed('X-Loco-Template');
}
/**
* Get *relative* path to alternative template path.
* @return Loco_fs_LocaleFile
*/
public function getTemplate(){
return new Loco_fs_LocaleFile( $this->head['X-Loco-Template'] );
}
/**
* Set *relative* path to alternative template path.
* @param string $path
*/
public function setTemplate( $path ){
$this->head['X-Loco-Template'] = (string) $path;
}
/**
* Test if translations (msgstr fields) are to be merged.
* @return bool true if NOT in pot mode
*/
public function mergeMsgstr(){
return 0 === preg_match( '/\\bpot\\b/', $this->getSyncMode() );
}
/**
* Test if JSON files are to be merged.
* @return bool
*/
public function mergeJson(){
return 1 === preg_match( '/\\bjson\\b/', $this->getSyncMode() );
}
/**
* @return string
*/
public function getSyncMode(){
$mode = strtolower( $this->head->trimmed('X-Loco-Template-Mode') );
// Default sync mode when undefined is to honour the type of source.
// i.e. for legacy compatibility, copy msgstr fields if source is a PO file.
if( '' === $mode ){
$mode = $this->hasTemplate() ? strtolower( $this->getTemplate()->extension() ) : 'pot';
}
return $mode;
}
/**
* @param string $mode
*/
public function setSyncMode( $mode ){
$this->head['X-Loco-Template-Mode'] = (string) $mode;
}
/**
* Remove redundant headers
* @return LocoPoHeaders
*/
public function getHeaders(){
if( ! $this->hasTemplate() ){
$this->head->offsetUnset('X-Loco-Template');
if( 'pot' === $this->getSyncMode() ){
$this->head->offsetUnset('X-Loco-Template-Mode');
}
}
return $this->head;
}
}

View File

@@ -0,0 +1,100 @@
<?php
/**
* Experimental PO/POT file word counter.
* Word counts are approximate, including numbers and sprintf tokens.
* Currently only used for source words in latin script, presumed to be in English.
*/
class Loco_gettext_WordCount implements Countable {
/**
* @var LocoPoIterator
*/
private $po;
/**
* Source Words: Cached count of "msgid" fields, presumed en_US
* @var int
*/
private $sw;
/**
* Create counter for a pre-parsed PO/POT file.
* @param Loco_gettext_Data
*/
public function __construct( Loco_gettext_Data $po ){
$this->po = $po;
}
/**
* @internal
*/
private function countField( $f ){
$n = 0;
foreach( $this->po as $r ){
$n += self::simpleCount( $r[$f] );
}
return $n;
}
/**
* Default count function returns source words (msgid) in current file.
* @return int
*/
#[ReturnTypeWillChange]
public function count(){
$n = $this->sw;
if( is_null($n) ){
$n = $this->countField('source');
$this->sw = $n;
}
return $n;
}
/**
* Very simple word count, only suitable for latin characters, and biased toward English.
* @param string
* @return int
*/
public static function simpleCount( $str ){
$n = 0;
if( is_string($str) && '' !== $str ){
// TODO should we strip PHP string formatting?
// e.g. "Hello %s" currently counts as 2 words.
// $str = preg_replace('/%(?:\\d+\\$)?(?:\'.|[-+0 ])*\\d*(?:\\.\\d+)?[suxXbcdeEfFgGo%]/', '', $str );
// Strip HTML (but only if open and close tags detected, else "< foo" would be stripped to nothing
if( false !== strpos($str,'<') && false !== strpos($str,'>') ){
$str = strip_tags($str);
}
// always html-decode, else escaped punctuation will be counted as words
$str = html_entity_decode( $str, ENT_QUOTES, 'UTF-8');
// Collapsing apostrophe'd words into single units:
// Simplest way to handle ambiguity of "It's Tim's" (technically three words in English)
$str = preg_replace('/(\\w+)\'(\\w)(\\W|$)/u', '\\1\\2\\3', $str );
// Combining floating numbers into single units
// e.g. "£1.50" and "€1,50" should be one word each
$str = preg_replace('/\\d[\\d,\\.]+/', '0', $str );
// count words by standard Unicode word boundaries
$words = preg_split( '/\\W+/u', $str, -1, PREG_SPLIT_NO_EMPTY );
$n += count($words);
/*/ TODO should we exclude some words (like numbers)?
foreach( $words as $word ){
if( ! ctype_digit($word) ){
$n++;
}
}*/
}
return $n;
}
}