wenpai.org/wp-content/plugins/plat-gp-customizations/inc/cli/class-language-pack.php
2024-11-14 02:29:46 +08:00

838 lines
24 KiB
PHP

<?php
namespace WordPressdotorg\GlotPress\Customizations\CLI;
use GP;
use GP_Locales;
use stdClass;
use WP_CLI;
use WP_CLI\Utils;
use WP_CLI_Command;
use WP_Error;
class Language_Pack extends WP_CLI_Command {
const TMP_DIR = '/tmp/language-packs';
const BUILD_DIR = '/nfs/rosetta/builds';
const SVN_URL = 'https://i18n.svn.wordpress.org';
const PACKAGE_THRESHOLD = 90;
/**
* Whether a language pack should be enforced.
*
* @var bool
*/
private $force = false;
/**
* Generates a language pack.
*
* ## OPTIONS
*
* <type>
* : Type of the language pack. 'plugin' or 'theme'.
*
* <slug>
* : Slug of the theme or plugin.
*
* [--locale]
* : Locale the language pack is for.
*
* [--locale-slug]
* : Slug of the locale. Default: 'default'.
*
* [--version]
* : Current version of the theme or plugin.
*
* [--force]
* : Generate language pack even when threshold is not reached or no updates exist.
*/
public function generate( $args, $assoc_args ) {
$type = $args[0];
$slug = $args[1];
$this->force = Utils\get_flag_value( $assoc_args, 'force' );
$args = wp_parse_args( $assoc_args, [
'locale' => false,
'locale-slug' => 'default',
'version' => false,
] );
switch ( $type ) {
case 'plugin' :
$this->generate_plugin( $slug, $args );
break;
case 'theme' :
$this->generate_theme( $slug, $args );
break;
default :
WP_CLI::error( 'Invalid type.' );
}
}
/**
* Generates a language pack for a plugin.
*
* Examples:
* wp @translate wporg-translate language-pack generate plugin nothing-much
* wp @translate wporg-translate language-pack generate plugin nothing-much --locale=de
*
* @param string $slug Slug of the plugin.
* @param array $args Extra arguments.
*/
private function generate_plugin( $slug, $args ) {
$gp_project = GP::$project->by_path( "wp-plugins/$slug" );
if ( ! $gp_project ) {
WP_CLI::error( 'Invalid plugin slug.' );
}
$stable_tag = $this->get_plugin_stable_tag( $slug );
$branch = ( 'trunk' !== $stable_tag ) ? 'stable' : 'dev';
$gp_project = GP::$project->by_path( "wp-plugins/$slug/$branch" );
if ( ! $gp_project ) {
WP_CLI::error( 'Invalid plugin branch.' );
}
$translation_sets = GP::$translation_set->by_project_id( $gp_project->id );
if ( ! $translation_sets ) {
WP_CLI::error( 'No translation sets available.' );
}
/**
* Filters the arguments passed to the WP-CLI command.
*
* @param array $args CLI arguments.
* @param string $slug Slug of the theme.
*/
$args = apply_filters( 'wporg_translate_language_pack_plugin_args', $args, $slug );
if ( $args['locale'] ) {
$translation_sets = wp_list_filter( $translation_sets, [
'locale' => $args['locale'],
'slug' => $args['locale-slug'],
] );
}
if ( ! $translation_sets ) {
WP_CLI::error( 'No translation sets available.' );
}
$version = $args['version'];
if ( ! $version ) {
$version = $this->get_latest_plugin_version( $slug );
}
if ( ! $version ) {
WP_CLI::error( 'No version available.' );
}
$svn_command = $this->get_svn_command();
$svn_checkout = self::get_temp_directory( $slug );
$result = $this->execute_command( sprintf(
'%s checkout --quiet --depth=empty %s %s 2>&1',
$svn_command,
escapeshellarg( self::SVN_URL . '/plugins' ),
escapeshellarg( $svn_checkout )
) );
if ( is_wp_error( $result ) ) {
WP_CLI::error_multi_line( $result->get_error_data() );
WP_CLI::error( 'SVN export failed.' );
}
$data = new stdClass();
$data->type = 'plugin';
$data->domain = $slug;
$data->version = $version;
$data->translation_sets = $translation_sets;
$data->gp_project = $gp_project;
$data->svn_checkout = $svn_checkout;
$this->build_language_packs( $data );
}
/**
* Generates a language pack for a theme.
*
* Examples:
* wp @translate wporg-translate language-pack generate theme twentyeleven
* wp @translate wporg-translate language-pack generate theme twentyeleven --locale=de
*
* @param string $slug Slug of the theme.
* @param array $args Extra arguments.
*/
private function generate_theme( $slug, $args ) {
$gp_project = GP::$project->by_path( "wp-themes/$slug" );
if ( ! $gp_project ) {
WP_CLI::error( 'Invalid theme slug.' );
}
$translation_sets = GP::$translation_set->by_project_id( $gp_project->id );
if ( ! $translation_sets ) {
WP_CLI::error( 'No translation sets available.' );
}
/**
* Filters the arguments passed to the WP-CLI command.
*
* @param array $args CLI arguments.
* @param string $slug Slug of the theme.
*/
$args = apply_filters( 'wporg_translate_language_pack_theme_args', $args, $slug );
if ( $args['locale'] ) {
$translation_sets = wp_list_filter( $translation_sets, [
'locale' => $args['locale'],
'slug' => $args['locale-slug'],
] );
}
if ( ! $translation_sets ) {
WP_CLI::error( 'No translation sets available.' );
}
$version = $args['version'];
if ( ! $version ) {
$version = $this->get_latest_theme_version( $slug );
}
if ( ! $version ) {
WP_CLI::error( 'No version available.' );
}
$svn_command = $this->get_svn_command();
$svn_checkout = self::get_temp_directory( $slug );
$result = $this->execute_command( sprintf(
'%s checkout --quiet --depth=empty %s %s 2>&1',
$svn_command,
escapeshellarg( self::SVN_URL . '/themes' ),
escapeshellarg( $svn_checkout )
) );
if ( is_wp_error( $result ) ) {
WP_CLI::error_multi_line( $result->get_error_data() );
WP_CLI::error( 'SVN export failed.' );
}
$data = new stdClass();
$data->type = 'theme';
$data->domain = $slug;
$data->version = $version;
$data->translation_sets = $translation_sets;
$data->gp_project = $gp_project;
$data->svn_checkout = $svn_checkout;
$this->build_language_packs( $data );
}
/**
* Creates a unique temporary directory.
*
* The temporary directory returned will be removed upon script termination.
*
* @param string $prefix Optional. The prefix for the directory, 'hello-dolly' for example.
* @return string The temporary directory.
*/
public static function get_temp_directory( $prefix = '' ) {
if ( ! is_dir( self::TMP_DIR ) ) {
mkdir( self::TMP_DIR, 0777 );
}
// Generate a unique filename
$tmp_dir = tempnam( self::TMP_DIR, $prefix );
// Replace that filename with a directory
unlink( $tmp_dir );
mkdir( $tmp_dir, 0777 );
// Automatically remove temporary directories on shutdown.
register_shutdown_function( [ __CLASS__, 'remove_temp_directory' ], $tmp_dir );
return $tmp_dir;
}
/**
* Removes a directory.
*
* @param string $dir The directory which should be removed.
* @return bool False if directory is removed, false otherwise.
*/
public static function remove_temp_directory( $dir ) {
if ( trim( $dir, '/' ) ) {
exec( 'rm -rf ' . escapeshellarg( $dir ) );
}
return is_dir( $dir );
}
/**
* Retrieves the stable tag of a plugin.
*
* @param string $plugin_slug Slug of a plugin.
* @return false|string False on failure, stable tag on success.
*/
private function get_plugin_stable_tag( $plugin_slug ) {
$plugin = wp_remote_retrieve_body(
wp_safe_remote_get(
"https://api.wordpress.org/plugins/info/1.0/{$plugin_slug}.json?fields=stable_tag"
)
);
if ( ! $plugin ) {
return false;
}
$plugin = json_decode( $plugin );
return $plugin->stable_tag;
}
/**
* Retrieves the current stable version of a theme.
*
* @param string $theme_slug Slug of a theme.
* @return false|string False on failure, version on success.
*/
private function get_latest_theme_version( $theme_slug ) {
$theme = wp_remote_retrieve_body(
wp_safe_remote_get(
"https://api.wordpress.org/themes/info/1.1/?action=theme_information&request[slug]={$theme_slug}"
)
);
if ( ! $theme ) {
return false;
}
$theme = json_decode( $theme );
return $theme->version;
}
/**
* Retrieves the current stable version of a plugin.
*
* @param string $plugin_slug Slug of a plugin.
* @return false|string False on failure, version on success.
*/
private function get_latest_plugin_version( $plugin_slug ) {
$plugin = wp_remote_retrieve_body(
wp_safe_remote_get(
"https://api.wordpress.org/plugins/info/1.0/{$plugin_slug}.json"
)
);
if ( ! $plugin ) {
return false;
}
$plugin = json_decode( $plugin );
return $plugin->version;
}
/**
* Retrieves active language packs for a theme/plugin of a version.
*
* @param string $type Type of the language pack. Supports 'plugin' or 'theme'.
* @param string $domain Slug of a theme/plugin.
* @param string $version Version of a theme/plugin.
* @return array Array of active language packs.
*/
private function get_active_language_packs( $type, $domain, $version ) {
global $wpdb;
$active_language_packs = $wpdb->get_results( $wpdb->prepare(
'SELECT language, updated FROM language_packs WHERE type = %s AND domain = %s AND version = %s AND active = 1',
$type,
$domain,
$version
), OBJECT_K );
if ( ! $active_language_packs ) {
return [];
}
return $active_language_packs;
}
/**
* Whether there's at least one active language pack for a theme/plugin of any version.
*
* @param string $type Type of the language pack. Supports 'plugin' or 'theme'.
* @param string $domain Slug of a theme/plugin.
* @param string $locale WordPress locale.
* @return boolean True if theme/plugin has an active language pack, false if not.
*/
private function has_active_language_pack( $type, $domain, $locale ) {
global $wpdb;
return (bool) $wpdb->get_var( $wpdb->prepare(
'SELECT updated FROM language_packs WHERE type = %s AND domain = %s AND language = %s AND active = 1 LIMIT 1',
$type,
$domain,
$locale
) );
}
/**
* Retrieves the basic SVN command with authentication data.
*
* @return string The SVN command.
*/
private function get_svn_command() {
return sprintf(
'svn --no-auth-cache --non-interactive --username=%s --password=%s',
escapeshellarg( POT_SVN_USER ),
escapeshellarg( POT_SVN_PASS )
);
}
/**
* Updates a SVN directory.
*
* Creates directories if they don't exist yet.
*
* @param string $svn_directory The SVN directory.
*/
private function update_svn_directory( $svn_directory ) {
$svn_command = $this->get_svn_command();
// Update existing files.
$this->execute_command( sprintf(
'%s up --quiet --parents %s 2>/dev/null',
$svn_command,
escapeshellarg( $svn_directory )
) );
// Create missing SVN directories.
$this->execute_command( sprintf(
'%s mkdir --quiet --parents %s 2>/dev/null',
$svn_command,
escapeshellarg( $svn_directory )
) );
}
/**
* Build a mapping of JS files to translation entries occurring in those files.
* Translation entries occurring in other files are added to the 'po' key.
*
* @param Translation_Entry[] $entries The translation entries to map.
* @return array The mapping of sources to translation entries.
*/
private function build_mapping( $entries ) {
$mapping = [];
foreach ( $entries as $entry ) {
/** @var Translation_Entry $entry */
// Find all unique sources this translation originates from.
if ( ! empty( $entry->references ) ) {
$sources = array_map(
function ( $reference ) {
$parts = explode( ':', $reference );
$file = $parts[0];
if ( substr( $file, -7 ) === '.min.js' ) {
return substr( $file, 0, -7 ) . '.js';
}
if ( substr( $file, -3 ) === '.js' ) {
return $file;
}
return 'po';
},
$entry->references
);
$sources = array_unique( $sources );
} else {
$sources = [ 'po' ];
}
foreach ( $sources as $source ) {
$mapping[ $source ][] = $entry;
}
}
return $mapping;
}
/**
* Builds a a separate JSON file with translations for each JavaScript file.
*
* @param GP_Project $gp_project The GlotPress project.
* @param GP_Locale $gp_locale The GlotPress locale.
* @param GP_Translation_Set $set The translation set.
* @param array $mapping A mapping of files to translation entries.
* @param string $base_dest Destination file name.
* @return array An array of translation files built, may be empty if no translations in JS files exist.
*/
private function build_json_files( $gp_project, $gp_locale, $set, $mapping, $base_dest ) {
$files = array();
$format = gp_array_get( GP::$formats, 'jed1x' );
foreach ( $mapping as $file => $entries ) {
// Don't create JSON files for source files.
if ( 0 === strpos( $file, 'src/' ) || false !== strpos( $file, '/src/' ) ) {
continue;
}
// Get the translations in Jed 1.x compatible JSON format.
$json_content = $format->print_exported_file( $gp_project, $gp_locale, $set, $entries );
// Decode and add comment with file reference for debugging.
$json_content_decoded = json_decode( $json_content );
$json_content_decoded->comment = [ 'reference' => $file ];
$json_content = wp_json_encode( $json_content_decoded );
$hash = md5( $file );
$dest = "{$base_dest}-{$hash}.json";
file_put_contents( $dest, $json_content );
$files[] = $dest;
}
return $files;
}
/**
* Builds a PO file for translations.
*
* @param GP_Project $gp_project The GlotPress project.
* @param GP_Locale $gp_locale The GlotPress locale.
* @param GP_Translation_Set $set The translation set.
* @param Translation_Entry[] $entries The translation entries.
* @param string $dest Destination file name.
* @return string|WP_Error Last updated date on success, WP_Error on failure.
*/
private function build_po_file( $gp_project, $gp_locale, $set, $entries, $dest ) {
$format = gp_array_get( GP::$formats, 'po' );
$po_content = $format->print_exported_file( $gp_project, $gp_locale, $set, $entries );
// Get last updated.
preg_match( '/^"PO-Revision-Date: (.*)\+\d+\\\n/m', $po_content, $match );
if ( empty( $match[1] ) ) {
return new WP_Error( 'invalid_format', 'Date could not be parsed.' );
}
file_put_contents( $dest, $po_content );
return $match[1];
}
/**
* Builds a PHP file for translations.
*
* @param GP_Project $gp_project The GlotPress project.
* @param GP_Locale $gp_locale The GlotPress locale.
* @param GP_Translation_Set $set The translation set.
* @param Translation_Entry[] $entries The translation entries.
* @param string $dest Destination file name.
* @return bool True on success, false on error.
*/
private function build_php_file( $gp_project, $gp_locale, $set, $entries, $dest ) {
$format = gp_array_get( GP::$formats, 'php' );
$content = $format->print_exported_file( $gp_project, $gp_locale, $set, $entries );
return false !== file_put_contents( $dest, $content );
}
/**
* Executes a command via exec().
*
* @param string $command The escaped command to execute.
* @return true|WP_Error True on success, WP_Error on failure.
*/
private function execute_command( $command ) {
exec( $command, $output, $return_var );
if ( $return_var ) {
return new WP_Error( $return_var, 'Error while executing the command.', $output );
}
return true;
}
/**
* Inserts a language pack into database.
*
* @param string $type Type of the language pack.
* @param string $domain Slug of the theme/plugin.
* @param string $language Language the language pack is for.
* @param string $version Version of the theme/plugin.
* @param string $updated Last updated.
* @return true|WP_Error true when language pack was updated, WP_Error on failure.
*/
private function insert_language_pack( $type, $domain, $language, $version, $updated ) {
global $wpdb;
$existing = $wpdb->get_var( $wpdb->prepare(
'SELECT id FROM language_packs WHERE type = %s AND domain = %s AND language = %s AND version = %s AND updated = %s AND active = 1',
$type,
$domain,
$language,
$version,
$updated
) );
if ( $existing ) {
return true;
}
$now = current_time( 'mysql', 1 );
$inserted = $wpdb->insert( 'language_packs', [
'type' => $type,
'domain' => $domain,
'language' => $language,
'version' => $version,
'updated' => $updated,
'active' => 1,
'date_added' => $now,
'date_modified' => $now,
] );
if ( ! $inserted ) {
return new WP_Error( 'language_pack_not_inserted', 'The language pack was not inserted.' );
}
// Mark old language packs for the same version as inactive.
$wpdb->query( $wpdb->prepare(
'UPDATE language_packs SET active = 0, date_modified = %s WHERE type = %s AND domain = %s AND language = %s AND version = %s AND id <> %d',
$now,
$type,
$domain,
$language,
$version,
$wpdb->insert_id
) );
// Clear the API update-check translation caches. See api.wordpress.org/translations/lib.php
wp_cache_add_global_groups( [ 'update-check-translations', 'translations-query' ] );
wp_cache_delete( "{$type}:{$language}:{$domain}", 'update-check-translations' );
wp_cache_delete( "{$type}:{$domain}:{$version}", 'translations-query' );
wp_cache_delete( "{$type}:{$domain}", 'translations-query' );
return true;
}
/**
* Builds a language pack.
*
* @param object $data The data of a language pack.
*/
private function build_language_packs( $data ) {
$existing_packs = $this->get_active_language_packs( $data->type, $data->domain, $data->version );
$svn_command = $this->get_svn_command();
foreach ( $data->translation_sets as $set ) {
// Get WP locale.
$gp_locale = GP_Locales::by_slug( $set->locale );
if ( ! isset( $gp_locale->wp_locale ) ) {
continue;
}
// Change wp_locale until GlotPress returns the correct wp_locale for variants.
// https://meta.trac.wordpress.org/changeset/12176/
$wp_locale = $gp_locale->wp_locale;
if ( 'default' !== $set->slug && ! str_contains( $wp_locale, $set->slug ) ) {
$wp_locale = $wp_locale . '_' . $set->slug;
}
// Check if any current translations exist.
if ( 0 === $set->current_count() ) {
WP_CLI::log( "Skip {$wp_locale}, no translations." );
continue;
}
// Check if percent translated is above threshold for initial language pack.
$has_existing_pack = $this->has_active_language_pack( $data->type, $data->domain, $wp_locale );
if ( ! $has_existing_pack ) {
$percent_translated = $set->percent_translated();
if ( ! $this->force && $percent_translated < self::PACKAGE_THRESHOLD ) {
WP_CLI::log( "Skip {$wp_locale}, translations below threshold ({$percent_translated}%)." );
continue;
}
}
// Check if new translations are available since last build.
if ( ! $this->force && isset( $existing_packs[ $wp_locale ] ) ) {
$pack_time = strtotime( $existing_packs[ $wp_locale ]->updated );
$glotpress_time = strtotime( $set->last_modified() );
if ( $pack_time >= $glotpress_time ) {
WP_CLI::log( "Skip {$wp_locale}, no new translations." );
continue;
}
}
$entries = GP::$translation->for_export( $data->gp_project, $set, [ 'status' => 'current' ] );
if ( ! $entries ) {
WP_CLI::warning( "No current translations available for {$wp_locale}." );
continue;
}
$working_directory = "{$data->svn_checkout}/{$data->domain}";
$export_directory = "{$working_directory}/{$data->version}/{$wp_locale}";
$build_directory = self::BUILD_DIR . "/{$data->type}s/{$data->domain}/{$data->version}";
$filename = "{$data->domain}-{$wp_locale}";
$json_file_base = "{$export_directory}/{$filename}";
$po_file = "{$export_directory}/{$filename}.po";
$mo_file = "{$export_directory}/{$filename}.mo";
$php_file = "{$export_directory}/{$filename}.l10n.php";
$zip_file = "{$export_directory}/{$filename}.zip";
$build_zip_file = "{$build_directory}/{$wp_locale}.zip";
$build_sig_file = "{$build_zip_file}.sig";
// Update/create directories.
$this->update_svn_directory( $export_directory );
// Build a mapping based on where the translation entries occur and separate the po entries.
$mapping = $this->build_mapping( $entries );
$po_entries = array_key_exists( 'po', $mapping ) ? $mapping['po'] : [];
unset( $mapping['po'] );
// Create JED json files for each JS file.
$additional_files = $this->build_json_files( $data->gp_project, $gp_locale, $set, $mapping, $json_file_base );
// Create PHP file.
$php_file_written = $this->build_php_file( $data->gp_project, $gp_locale, $set, $po_entries, $php_file );
if ( $php_file_written ) {
$additional_files[] = $php_file;
}
// Create PO file.
$last_modified = $this->build_po_file( $data->gp_project, $gp_locale, $set, $po_entries, $po_file );
if ( is_wp_error( $last_modified ) ) {
WP_CLI::warning( sprintf( "PO generation for {$wp_locale} failed: %s", $last_modified->get_error_message() ) );
// Clean up.
$this->execute_command( sprintf( 'rm -rf %s', escapeshellarg( $working_directory ) ) );
continue;
}
// Create MO file.
$result = $this->execute_command( sprintf(
'msgfmt %s -o %s 2>&1',
escapeshellarg( $po_file ),
escapeshellarg( $mo_file )
) );
if ( is_wp_error( $result ) ) {
WP_CLI::error_multi_line( $result->get_error_data() );
WP_CLI::warning( "MO generation for {$wp_locale} failed." );
// Clean up.
$this->execute_command( sprintf( 'rm -rf %s', escapeshellarg( $working_directory ) ) );
continue;
}
// Create ZIP file.
$result = $this->execute_command( sprintf(
'zip -9 -j %s %s %s %s 2>&1',
escapeshellarg( $zip_file ),
escapeshellarg( $po_file ),
escapeshellarg( $mo_file ),
implode( ' ', array_map( 'escapeshellarg', $additional_files ) )
) );
if ( is_wp_error( $result ) ) {
WP_CLI::error_multi_line( $result->get_error_data() );
WP_CLI::warning( "ZIP generation for {$wp_locale} failed." );
// Clean up.
$this->execute_command( sprintf( 'rm -rf %s', escapeshellarg( $working_directory ) ) );
continue;
}
// Create build directories.
$result = $this->execute_command( sprintf(
'mkdir -p %s 2>&1',
escapeshellarg( $build_directory )
) );
if ( is_wp_error( $result ) ) {
WP_CLI::error_multi_line( $result->get_error_data() );
WP_CLI::warning( "Creating build directories for {$wp_locale} failed." );
// Clean up.
$this->execute_command( sprintf( 'rm -rf %s', escapeshellarg( $working_directory ) ) );
continue;
}
// Move ZIP file to build directory.
$result = $this->execute_command( sprintf(
'mv %s %s 2>&1',
escapeshellarg( $zip_file ),
escapeshellarg( $build_zip_file )
) );
if ( is_wp_error( $result ) ) {
WP_CLI::error_multi_line( $result->get_error_data() );
WP_CLI::warning( "Moving ZIP file for {$wp_locale} failed." );
// Clean up.
$this->execute_command( sprintf( 'rm -rf %s', escapeshellarg( $working_directory ) ) );
continue;
}
// Generate a signature for the ZIP file.
if ( false && function_exists( 'wporg_sign_file' ) ) {
$signatures = wporg_sign_file( $build_zip_file, 'translation' );
if ( $signatures ) {
file_put_contents( $build_sig_file, implode( "\n", $signatures ) );
}
}
// Insert language pack into database.
$result = $this->insert_language_pack( $data->type, $data->domain, $wp_locale, $data->version, $last_modified );
if ( is_wp_error( $result ) ) {
WP_CLI::warning( sprintf( "Language pack for {$wp_locale} failed: %s", $result->get_error_message() ) );
// Clean up.
$this->execute_command( sprintf( 'rm -rf %s', escapeshellarg( $working_directory ) ) );
continue;
}
// Add PO file to SVN.
$this->execute_command( sprintf(
'%s add --quiet --force --parents %s 2>/dev/null',
$svn_command,
escapeshellarg( $po_file )
) );
// Commit PO file.
$result = $this->execute_command( sprintf(
'%s commit --quiet %s -m %s 2>&1',
$svn_command,
escapeshellarg( $data->svn_checkout ),
escapeshellarg( "Update PO file for {$data->type} {$data->domain} {$data->version} {$wp_locale}." )
) );
if ( is_wp_error( $result ) ) {
WP_CLI::error_multi_line( $result->get_error_data() );
WP_CLI::warning( "SVN commit for {$wp_locale} failed." );
// Clean up.
$this->execute_command( sprintf( 'rm -rf %s', escapeshellarg( $working_directory ) ) );
continue;
}
// Clean up.
$this->execute_command( sprintf( 'rm -rf %s', escapeshellarg( $working_directory ) ) );
WP_CLI::success( "Language pack for {$wp_locale} generated." );
}
}
}