415 lines
12 KiB
PHP
415 lines
12 KiB
PHP
|
<?php
|
||
|
|
||
|
namespace WP_CLI\I18n;
|
||
|
|
||
|
use Gettext\Extractors\Po as PoExtractor;
|
||
|
use Gettext\Generators\Po as PoGenerator;
|
||
|
use Gettext\Translation;
|
||
|
use Gettext\Translations;
|
||
|
use WP_CLI;
|
||
|
use WP_CLI_Command;
|
||
|
use WP_CLI\Utils;
|
||
|
use DirectoryIterator;
|
||
|
use IteratorIterator;
|
||
|
use SplFileInfo;
|
||
|
|
||
|
class MakeJsonCommand extends WP_CLI_Command {
|
||
|
/**
|
||
|
* Options passed to json_encode().
|
||
|
*
|
||
|
* @var int JSON options.
|
||
|
*/
|
||
|
protected $json_options = 0;
|
||
|
|
||
|
/**
|
||
|
* Extract JavaScript strings from PO files and add them to individual JSON files.
|
||
|
*
|
||
|
* For JavaScript internationalization purposes, WordPress requires translations to be split up into
|
||
|
* one Jed-formatted JSON file per JavaScript source file.
|
||
|
*
|
||
|
* See https://make.wordpress.org/core/2018/11/09/new-javascript-i18n-support-in-wordpress/ to learn more
|
||
|
* about WordPress JavaScript internationalization.
|
||
|
*
|
||
|
* ## OPTIONS
|
||
|
*
|
||
|
* <source>
|
||
|
* : Path to an existing PO file or a directory containing multiple PO files.
|
||
|
*
|
||
|
* [<destination>]
|
||
|
* : Path to the destination directory for the resulting JSON files. Defaults to the source directory.
|
||
|
*
|
||
|
* [--purge]
|
||
|
* : Whether to purge the strings that were extracted from the original source file. Defaults to true, use `--no-purge` to skip the removal.
|
||
|
*
|
||
|
* [--update-mo-files]
|
||
|
* : Whether MO files should be updated as well after updating PO files.
|
||
|
* Only has an effect when used in combination with `--purge`.
|
||
|
*
|
||
|
* [--pretty-print]
|
||
|
* : Pretty-print resulting JSON files.
|
||
|
*
|
||
|
* [--use-map=<paths_or_maps>]
|
||
|
* : Whether to use a mapping file for the strings, as a JSON value, array to specify multiple.
|
||
|
* Each element can either be a string (file path) or object (map).
|
||
|
*
|
||
|
* ## EXAMPLES
|
||
|
*
|
||
|
* # Create JSON files for all PO files in the languages directory
|
||
|
* $ wp i18n make-json languages
|
||
|
*
|
||
|
* # Create JSON files for my-plugin-de_DE.po and leave the PO file untouched.
|
||
|
* $ wp i18n make-json my-plugin-de_DE.po /tmp --no-purge
|
||
|
*
|
||
|
* # Create JSON files with mapping
|
||
|
* $ wp i18n make-json languages --use-map=build/map.json
|
||
|
*
|
||
|
* # Create JSON files with multiple mappings
|
||
|
* $ wp i18n make-json languages '--use-map=["build/map.json","build/map2.json"]'
|
||
|
*
|
||
|
* # Create JSON files with object mapping
|
||
|
* $ wp i18n make-json languages '--use-map={"source/index.js":"build/index.js"}'
|
||
|
*
|
||
|
* @when before_wp_load
|
||
|
*
|
||
|
* @throws WP_CLI\ExitException
|
||
|
*/
|
||
|
public function __invoke( $args, $assoc_args ) {
|
||
|
$assoc_args = Utils\parse_shell_arrays( $assoc_args, array( 'use-map' ) );
|
||
|
$purge = Utils\get_flag_value( $assoc_args, 'purge', true );
|
||
|
$update_mo_files = Utils\get_flag_value( $assoc_args, 'update-mo-files', true );
|
||
|
$map_paths = Utils\get_flag_value( $assoc_args, 'use-map', false );
|
||
|
|
||
|
if ( Utils\get_flag_value( $assoc_args, 'pretty-print', false ) ) {
|
||
|
$this->json_options |= JSON_PRETTY_PRINT;
|
||
|
}
|
||
|
|
||
|
$source = realpath( $args[0] );
|
||
|
|
||
|
if ( ! $source || ( ! is_file( $source ) && ! is_dir( $source ) ) ) {
|
||
|
WP_CLI::error( 'Source file or directory does not exist!' );
|
||
|
}
|
||
|
|
||
|
$destination = is_file( $source ) ? dirname( $source ) : $source;
|
||
|
|
||
|
if ( isset( $args[1] ) ) {
|
||
|
$destination = $args[1];
|
||
|
}
|
||
|
|
||
|
$map = $this->build_map( $map_paths );
|
||
|
if ( is_array( $map ) && empty( $map ) ) {
|
||
|
WP_CLI::error( 'No valid keys found. No file was created.' );
|
||
|
}
|
||
|
|
||
|
// Two is_dir() checks in case of a race condition.
|
||
|
if ( ! is_dir( $destination )
|
||
|
&& ! mkdir( $destination, 0777, true )
|
||
|
&& ! is_dir( $destination )
|
||
|
) {
|
||
|
WP_CLI::error( 'Could not create destination directory!' );
|
||
|
}
|
||
|
|
||
|
$result_count = 0;
|
||
|
|
||
|
if ( is_file( $source ) ) {
|
||
|
$files = [ new SplFileInfo( $source ) ];
|
||
|
} else {
|
||
|
$files = new IteratorIterator( new DirectoryIterator( $source ) );
|
||
|
}
|
||
|
|
||
|
/** @var DirectoryIterator $file */
|
||
|
foreach ( $files as $file ) {
|
||
|
if ( $file->isFile() && $file->isReadable() && 'po' === $file->getExtension() ) {
|
||
|
$result = $this->make_json( $file->getRealPath(), $destination, $map );
|
||
|
$result_count += count( $result );
|
||
|
|
||
|
if ( $purge ) {
|
||
|
$removed = $this->remove_js_strings_from_po_file( $file->getRealPath() );
|
||
|
|
||
|
if ( ! $removed ) {
|
||
|
WP_CLI::warning( sprintf( 'Could not update file %s', basename( $source ) ) );
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if ( $update_mo_files ) {
|
||
|
$file_basename = basename( $file->getFilename(), '.po' );
|
||
|
$destination_file = "{$destination}/{$file_basename}.mo";
|
||
|
|
||
|
$translations = Translations::fromPoFile( $file->getPathname() );
|
||
|
if ( ! $translations->toMoFile( $destination_file ) ) {
|
||
|
WP_CLI::warning( "Could not create file {$destination_file}" );
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
WP_CLI::success( sprintf( 'Created %d %s.', $result_count, Utils\pluralize( 'file', $result_count ) ) );
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Collect maps from paths, normalize and merge
|
||
|
*
|
||
|
* @param string|array|bool $paths_or_maps argument. False to do nothing.
|
||
|
* @return array|null Mapping array. Null if no maps specified.
|
||
|
*/
|
||
|
protected function build_map( $paths_or_maps ) {
|
||
|
if ( false === $paths_or_maps ) {
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
$map = [];
|
||
|
|
||
|
// not an array: single value could also be object (associative array)
|
||
|
if ( ! is_array( $paths_or_maps ) || empty( array_filter( array_keys( $paths_or_maps ), 'is_int' ) ) ) {
|
||
|
$paths_or_maps = [ $paths_or_maps ];
|
||
|
}
|
||
|
$paths = array_filter( $paths_or_maps, 'is_string' );
|
||
|
WP_CLI::debug( sprintf( 'Using %d map files: %s', count( $paths ), implode( ', ', $paths ) ), 'make-json' );
|
||
|
$maps = array_filter( $paths_or_maps, 'is_array' );
|
||
|
WP_CLI::debug( sprintf( 'Using %d inline map objects', count( $maps ) ), 'make-json' );
|
||
|
WP_CLI::debug( sprintf( 'Dropping %d invalid values from map argument', count( $paths_or_maps ) - count( $paths ) - count( $maps ) ), 'make-json' );
|
||
|
|
||
|
$to_transform = array_map(
|
||
|
static function ( $value, $index ) {
|
||
|
return [ $value, sprintf( 'inline object %d', $index ) ];
|
||
|
},
|
||
|
$maps,
|
||
|
array_keys( $maps )
|
||
|
);
|
||
|
|
||
|
foreach ( $paths as $path ) {
|
||
|
if ( ! file_exists( $path ) || is_dir( $path ) ) {
|
||
|
WP_CLI::warning( sprintf( 'Map file %s does not exist', $path ) );
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
$json = json_decode( file_get_contents( $path ), true );
|
||
|
if ( ! is_array( $json ) ) {
|
||
|
WP_CLI::warning( sprintf( 'Map file %s invalid', $path ) );
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
$to_transform[] = [ $json, $path ];
|
||
|
}
|
||
|
|
||
|
foreach ( $to_transform as $transform ) {
|
||
|
list( $json, $file ) = $transform;
|
||
|
$key_num = count( $json );
|
||
|
// normalize contents to string[]
|
||
|
$json = array_map(
|
||
|
static function ( $value ) {
|
||
|
if ( is_array( $value ) ) {
|
||
|
$value = array_values( array_filter( $value, 'is_string' ) );
|
||
|
if ( ! empty( $value ) ) {
|
||
|
return $value;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if ( is_string( $value ) ) {
|
||
|
return [ $value ];
|
||
|
}
|
||
|
|
||
|
return null;
|
||
|
},
|
||
|
$json
|
||
|
);
|
||
|
WP_CLI::debug( sprintf( 'Dropped %d keys from %s', count( $json ) - $key_num, $file ), 'make-json' );
|
||
|
|
||
|
$map = array_merge_recursive( $map, $json );
|
||
|
}
|
||
|
|
||
|
return $map;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Splits a single PO file into multiple JSON files.
|
||
|
*
|
||
|
* @param string $source_file Path to the source file.
|
||
|
* @param string $destination Path to the destination directory.
|
||
|
* @param array|null $map Source to build file mapping.
|
||
|
* @return array List of created JSON files.
|
||
|
*/
|
||
|
protected function make_json( $source_file, $destination, $map ) {
|
||
|
/** @var Translations[] $mapping */
|
||
|
$mapping = [];
|
||
|
$translations = new Translations();
|
||
|
$result = [];
|
||
|
|
||
|
PoExtractor::fromFile( $source_file, $translations );
|
||
|
|
||
|
$base_file_name = basename( $source_file, '.po' );
|
||
|
|
||
|
$domain = $translations->getDomain();
|
||
|
|
||
|
if ( $domain && 0 !== strpos( $base_file_name, $domain ) ) {
|
||
|
$base_file_name = "{$domain}-{$base_file_name}";
|
||
|
}
|
||
|
|
||
|
foreach ( $translations as $translation ) {
|
||
|
/** @var Translation $translation */
|
||
|
|
||
|
// Find all unique sources this translation originates from.
|
||
|
$sources = array_map(
|
||
|
static function ( $reference ) {
|
||
|
$file = $reference[0];
|
||
|
|
||
|
if ( substr( $file, - 7 ) === '.min.js' ) {
|
||
|
return substr( $file, 0, - 7 ) . '.js';
|
||
|
}
|
||
|
|
||
|
if ( substr( $file, - 3 ) === '.js' ) {
|
||
|
return $file;
|
||
|
}
|
||
|
|
||
|
return null;
|
||
|
},
|
||
|
$this->reference_map( $translation->getReferences(), $map )
|
||
|
);
|
||
|
|
||
|
$sources = array_unique( array_filter( $sources ) );
|
||
|
|
||
|
foreach ( $sources as $source ) {
|
||
|
if ( ! isset( $mapping[ $source ] ) ) {
|
||
|
$mapping[ $source ] = new Translations();
|
||
|
|
||
|
// phpcs:ignore Squiz.PHP.CommentedOutCode.Found -- Provide code that is meant to be used once the bug is fixed.
|
||
|
// See https://core.trac.wordpress.org/ticket/45441
|
||
|
// $mapping[ $source ]->setDomain( $translations->getDomain() );
|
||
|
|
||
|
$mapping[ $source ]->setHeader( 'Language', $translations->getLanguage() );
|
||
|
$mapping[ $source ]->setHeader( 'PO-Revision-Date', $translations->getHeader( 'PO-Revision-Date' ) );
|
||
|
|
||
|
$plural_forms = $translations->getPluralForms();
|
||
|
|
||
|
if ( $plural_forms ) {
|
||
|
list( $count, $rule ) = $plural_forms;
|
||
|
$mapping[ $source ]->setPluralForms( $count, $rule );
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$mapping[ $source ][] = $translation;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$result += $this->build_json_files( $mapping, $base_file_name, $destination );
|
||
|
|
||
|
return $result;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Takes the references and applies map, if given
|
||
|
*
|
||
|
* @param array $references translation references
|
||
|
* @param array|null $map file mapping
|
||
|
* @return array mapped references
|
||
|
*/
|
||
|
protected function reference_map( $references, $map ) {
|
||
|
if ( is_null( $map ) ) {
|
||
|
return $references;
|
||
|
}
|
||
|
|
||
|
// translate using map
|
||
|
$temp = array_map(
|
||
|
static function ( $reference ) use ( &$map ) {
|
||
|
$file = $reference[0];
|
||
|
|
||
|
if ( array_key_exists( $file, $map ) ) {
|
||
|
return $map[ $file ];
|
||
|
}
|
||
|
|
||
|
return null;
|
||
|
},
|
||
|
$references
|
||
|
);
|
||
|
// this is now an array of arrays of sources, translate to array of sources
|
||
|
$references = [];
|
||
|
foreach ( $temp as $sources ) {
|
||
|
if ( is_null( $sources ) ) {
|
||
|
continue;
|
||
|
}
|
||
|
array_push( $references, ...$sources );
|
||
|
}
|
||
|
// and wrap to array
|
||
|
return array_map(
|
||
|
static function ( $value ) {
|
||
|
return [ $value ];
|
||
|
},
|
||
|
$references
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Builds a mapping of JS file names to translation entries.
|
||
|
*
|
||
|
* Exports translations for each JS file to a separate translation file.
|
||
|
*
|
||
|
* @param array $mapping A mapping of files to translation entries.
|
||
|
* @param string $base_file_name Base file name for JSON files.
|
||
|
* @param string $destination Path to the destination directory.
|
||
|
*
|
||
|
* @return array List of created JSON files.
|
||
|
*/
|
||
|
protected function build_json_files( $mapping, $base_file_name, $destination ) {
|
||
|
$result = [];
|
||
|
|
||
|
foreach ( $mapping as $file => $translations ) {
|
||
|
/** @var Translations $translations */
|
||
|
|
||
|
$hash = md5( $file );
|
||
|
$destination_file = "${destination}/{$base_file_name}-{$hash}.json";
|
||
|
|
||
|
$success = JedGenerator::toFile(
|
||
|
$translations,
|
||
|
$destination_file,
|
||
|
[
|
||
|
'json' => $this->json_options,
|
||
|
'source' => $file,
|
||
|
]
|
||
|
);
|
||
|
|
||
|
if ( ! $success ) {
|
||
|
WP_CLI::warning( sprintf( 'Could not create file %s', basename( $destination_file, '.json' ) ) );
|
||
|
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
$result[] = $destination_file;
|
||
|
}
|
||
|
|
||
|
return $result;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Removes strings from PO file that only occur in JavaScript file.
|
||
|
*
|
||
|
* @param string $source_file Path to the PO file.
|
||
|
* @return bool True on success, false otherwise.
|
||
|
*/
|
||
|
protected function remove_js_strings_from_po_file( $source_file ) {
|
||
|
/** @var Translations[] $mapping */
|
||
|
$translations = new Translations();
|
||
|
|
||
|
PoExtractor::fromFile( $source_file, $translations );
|
||
|
|
||
|
foreach ( $translations->getArrayCopy() as $translation ) {
|
||
|
/** @var Translation $translation */
|
||
|
|
||
|
if ( ! $translation->hasReferences() ) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
foreach ( $translation->getReferences() as $reference ) {
|
||
|
$file = $reference[0];
|
||
|
|
||
|
if ( substr( $file, - 3 ) !== '.js' ) {
|
||
|
continue 2;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
unset( $translations[ $translation->getId() ] );
|
||
|
}
|
||
|
|
||
|
return PoGenerator::toFile( $translations, $source_file );
|
||
|
}
|
||
|
}
|