diff --git a/features/core-download.feature b/features/core-download.feature index 06eec016..52912266 100644 --- a/features/core-download.feature +++ b/features/core-download.feature @@ -51,6 +51,74 @@ Feature: Download WordPress Then the wp-settings.php file should exist And the {SUITE_CACHE_DIR}/core/wordpress-{VERSION}-de_DE.tar.gz file should exist + Scenario: Error when requested locale is not available for the latest version + Given an empty directory + And an empty cache + And that HTTP requests to https://api.wordpress.org/core/version-check/1.7/ will respond with: + """ + HTTP/1.1 200 OK + Content-Type: application/json + + {"offers":[{"response":"upgrade","download":"https://downloads.wordpress.org/release/wordpress-6.9.4.zip","locale":"en_US","packages":{"full":"https://downloads.wordpress.org/release/wordpress-6.9.4.zip","no_content":"https://downloads.wordpress.org/release/wordpress-6.9.4-no-content.zip","new_bundled":"https://downloads.wordpress.org/release/wordpress-6.9.4-new-bundled.zip","partial":false,"rollback":false},"current":"6.9.4","version":"6.9.4","php_version":"7.2.24","mysql_version":"5.5.5","new_bundled":"6.7","partial_version":false}]} + """ + + When I try `wp core download --locale=de_DE` + Then the return code should be 1 + And STDERR should contain: + """ + Error: The requested locale (de_DE) was not found. + """ + + Scenario: Download older locale version when latest is not yet available using --skip-locale-check + Given an empty directory + And an empty cache + And that HTTP requests to https://api.wordpress.org/core/version-check/1.7/ will respond with: + """ + HTTP/1.1 200 OK + Content-Type: application/json + + {"offers":[{"response":"upgrade","download":"https://downloads.wordpress.org/release/wordpress-6.9.4.zip","locale":"en_US","packages":{"full":"https://downloads.wordpress.org/release/wordpress-6.9.4.zip","no_content":"https://downloads.wordpress.org/release/wordpress-6.9.4-no-content.zip","new_bundled":"https://downloads.wordpress.org/release/wordpress-6.9.4-new-bundled.zip","partial":false,"rollback":false},"current":"6.9.4","version":"6.9.4","php_version":"7.2.24","mysql_version":"5.5.5","new_bundled":"6.7","partial_version":false}]} + """ + And that HTTP requests to https://api.wordpress.org/translations/core/1.0/ will respond with: + """ + HTTP/1.1 200 OK + Content-Type: application/json + + {"translations":[{"language":"de_DE","version":"4.4.2","updated":"2024-01-01 00:00:00","english_name":"German","native_name":"Deutsch","package":"https://downloads.wordpress.org/translation/core/4.4.2/de_DE.zip"}]} + """ + + When I try `wp core download --locale=de_DE --skip-locale-check` + Then the wp-settings.php file should exist + And STDERR should contain: + """ + Warning: The latest WordPress version is not yet available in the de_DE locale. Downloading version 4.4.2 instead. + """ + + Scenario: Error when --skip-locale-check is set but no translation exists for locale + Given an empty directory + And an empty cache + And that HTTP requests to https://api.wordpress.org/core/version-check/1.7/ will respond with: + """ + HTTP/1.1 200 OK + Content-Type: application/json + + {"offers":[{"response":"upgrade","download":"https://downloads.wordpress.org/release/wordpress-6.9.4.zip","locale":"en_US","packages":{"full":"https://downloads.wordpress.org/release/wordpress-6.9.4.zip","no_content":"https://downloads.wordpress.org/release/wordpress-6.9.4-no-content.zip","new_bundled":"https://downloads.wordpress.org/release/wordpress-6.9.4-new-bundled.zip","partial":false,"rollback":false},"current":"6.9.4","version":"6.9.4","php_version":"7.2.24","mysql_version":"5.5.5","new_bundled":"6.7","partial_version":false}]} + """ + And that HTTP requests to https://api.wordpress.org/translations/core/1.0/ will respond with: + """ + HTTP/1.1 200 OK + Content-Type: application/json + + {"translations":[]} + """ + + When I try `wp core download --locale=de_DE --skip-locale-check` + Then the return code should be 1 + And STDERR should contain: + """ + Error: The requested locale (de_DE) was not found. + """ + Scenario: Catch download of non-existent WP version Given an empty directory diff --git a/src/Core_Command.php b/src/Core_Command.php index a20c9b51..4f7dfcf7 100644 --- a/src/Core_Command.php +++ b/src/Core_Command.php @@ -4,6 +4,7 @@ use WP_CLI\Extractor; use WP_CLI\Iterators\Table as TableIterator; use WP_CLI\Utils; +use WP_CLI\Path; use WP_CLI\Formatter; use WP_CLI\Loggers; use WP_CLI\WpOrgApi; @@ -149,6 +150,9 @@ public function check_update( $args, $assoc_args ) { * [--extract] * : Whether to extract the downloaded file. Defaults to true. * + * [--skip-locale-check] + * : If specified, allows downloading an older version of WordPress when the requested locale is not available for the latest release. + * * ## EXAMPLES * * $ wp core download --locale=nl_NL @@ -160,7 +164,7 @@ public function check_update( $args, $assoc_args ) { * @when before_wp_load * * @param array{0?: string} $args Positional arguments. - * @param array{path?: string, locale?: string, version?: string, 'skip-content'?: bool, force?: bool, insecure?: bool, extract?: bool} $assoc_args Associative arguments. + * @param array{path?: string, locale?: string, version?: string, 'skip-content'?: bool, force?: bool, insecure?: bool, extract?: bool, 'skip-locale-check'?: bool} $assoc_args Associative arguments. */ public function download( $args, $assoc_args ) { /** @@ -233,14 +237,22 @@ public function download( $args, $assoc_args ) { $download_url = $this->get_download_url( $version, $locale, $extension ); } else { + $wp_org_api = new WpOrgApi( [ 'insecure' => $insecure ] ); try { - $offer = ( new WpOrgApi( [ 'insecure' => $insecure ] ) ) - ->get_core_download_offer( $locale ); + $offer = $wp_org_api->get_core_download_offer( $locale ); } catch ( Exception $exception ) { WP_CLI::error( $exception ); } if ( ! $offer ) { - WP_CLI::error( "The requested locale ({$locale}) was not found." ); + if ( Utils\get_flag_value( $assoc_args, 'skip-locale-check', false ) ) { + $offer = $this->find_latest_offer_for_locale( $locale, $insecure ); + if ( is_array( $offer ) ) { + WP_CLI::warning( "The latest WordPress version is not yet available in the {$locale} locale. Downloading version {$offer['current']} instead." ); + } + } + if ( ! $offer ) { + WP_CLI::error( "The requested locale ({$locale}) was not found." ); + } } $version = $offer['current']; $download_url = $offer['download']; @@ -703,8 +715,8 @@ private function set_server_url_vars( $url ) { $_SERVER['SCRIPT_NAME'] = $path; // Set SCRIPT_FILENAME to the actual WordPress index.php if available. - if ( file_exists( Utils\trailingslashit( ABSPATH ) . 'index.php' ) ) { - $_SERVER['SCRIPT_FILENAME'] = Utils\trailingslashit( ABSPATH ) . 'index.php'; + if ( file_exists( Path::trailingslashit( ABSPATH ) . 'index.php' ) ) { + $_SERVER['SCRIPT_FILENAME'] = Path::trailingslashit( ABSPATH ) . 'index.php'; } } @@ -1063,7 +1075,7 @@ private static function get_wp_details( $abspath = ABSPATH ) { * Gets the template path based on installation type. */ private static function get_template_path( $template ) { - $command_root = Utils\phar_safe_path( dirname( __DIR__ ) ); + $command_root = Path::phar_safe( dirname( __DIR__ ) ); $template_path = "{$command_root}/templates/{$template}"; if ( ! file_exists( $template_path ) ) { @@ -1671,6 +1683,57 @@ private function get_download_url( $version, $locale = 'en_US', $file_type = 'zi return "https://{$locale_subdomain}wordpress.org/wordpress-{$version}{$locale_suffix}.{$file_type}"; } + /** + * Finds the latest available WordPress download offer for a given locale by consulting + * the WordPress.org translations API. + * + * Used as a fallback when the primary version-check API does not return an offer for + * the requested locale (e.g., when a new WordPress release hasn't been translated yet). + * + * @param string $locale Locale to find an offer for. + * @param bool $insecure Whether to disable SSL verification. + * @return array{current: string, download: string}|false Offer array on success, false on failure. + */ + private function find_latest_offer_for_locale( $locale, $insecure ) { + $headers = [ 'Accept' => 'application/json' ]; + $options = [ + 'timeout' => 30, + 'insecure' => $insecure, + ]; + + try { + /** @var \WpOrg\Requests\Response $response */ + $response = Utils\http_request( 'GET', 'https://api.wordpress.org/translations/core/1.0/', null, $headers, $options ); + } catch ( Exception $exception ) { + return false; + } + + if ( $response->status_code < 200 || $response->status_code >= 300 ) { + return false; + } + + /** @var array{translations: array}|null $body */ + $body = json_decode( $response->body, true ); + + if ( ! is_array( $body ) || empty( $body['translations'] ) ) { + return false; + } + + foreach ( $body['translations'] as $translation ) { + if ( + isset( $translation['language'], $translation['version'] ) + && $locale === $translation['language'] + ) { + return [ + 'current' => $translation['version'], + 'download' => $this->get_download_url( $translation['version'], $locale, 'zip' ), + ]; + } + } + + return false; + } + /** * Returns update information. * @@ -2027,7 +2090,7 @@ private function remove_old_files_from_list( $files ) { WP_CLI::debug( 'Failed to resolve ABSPATH realpath', 'core' ); return $count; } - $abspath_realpath_trailing = Utils\trailingslashit( $abspath_realpath ); + $abspath_realpath_trailing = Path::trailingslashit( $abspath_realpath ); foreach ( $files as $file ) { $file_path = ABSPATH . $file; @@ -2041,7 +2104,7 @@ private function remove_old_files_from_list( $files ) { if ( is_link( $file_path ) ) { $normalized_path = realpath( dirname( $file_path ) ); if ( false === $normalized_path - || 0 !== strpos( Utils\trailingslashit( $normalized_path ), $abspath_realpath_trailing ) + || 0 !== strpos( Path::trailingslashit( $normalized_path ), $abspath_realpath_trailing ) ) { WP_CLI::debug( "Skipping symbolic link outside of ABSPATH: {$file}", 'core' ); continue; @@ -2057,7 +2120,7 @@ private function remove_old_files_from_list( $files ) { // Regular files/directories: validate real path is within ABSPATH. $file_realpath = realpath( $file_path ); - if ( false === $file_realpath || 0 !== strpos( Utils\trailingslashit( $file_realpath ), $abspath_realpath_trailing ) ) { + if ( false === $file_realpath || 0 !== strpos( Path::trailingslashit( $file_realpath ), $abspath_realpath_trailing ) ) { WP_CLI::debug( "Skipping file outside of ABSPATH: {$file}", 'core' ); continue; } @@ -2093,7 +2156,7 @@ private function remove_directory( $dir, $abspath_realpath_trailing ) { WP_CLI::debug( "Failed to resolve realpath for directory: {$dir}", 'core' ); return false; } - if ( 0 !== strpos( Utils\trailingslashit( $dir_realpath ), $abspath_realpath_trailing ) ) { + if ( 0 !== strpos( Path::trailingslashit( $dir_realpath ), $abspath_realpath_trailing ) ) { WP_CLI::debug( "Attempted to remove directory outside of ABSPATH: {$dir_realpath}", 'core' ); return false; }