44use WP_CLI \Extractor ;
55use WP_CLI \Iterators \Table as TableIterator ;
66use WP_CLI \Utils ;
7+ use WP_CLI \Path ;
78use WP_CLI \Formatter ;
89use WP_CLI \Loggers ;
910use WP_CLI \WpOrgApi ;
@@ -149,6 +150,9 @@ public function check_update( $args, $assoc_args ) {
149150 * [--extract]
150151 * : Whether to extract the downloaded file. Defaults to true.
151152 *
153+ * [--skip-locale-check]
154+ * : If specified, allows downloading an older version of WordPress when the requested locale is not available for the latest release.
155+ *
152156 * ## EXAMPLES
153157 *
154158 * $ wp core download --locale=nl_NL
@@ -160,7 +164,7 @@ public function check_update( $args, $assoc_args ) {
160164 * @when before_wp_load
161165 *
162166 * @param array{0?: string} $args Positional arguments.
163- * @param array{path?: string, locale?: string, version?: string, 'skip-content'?: bool, force?: bool, insecure?: bool, extract?: bool} $assoc_args Associative arguments.
167+ * @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.
164168 */
165169 public function download ( $ args , $ assoc_args ) {
166170 /**
@@ -233,14 +237,22 @@ public function download( $args, $assoc_args ) {
233237
234238 $ download_url = $ this ->get_download_url ( $ version , $ locale , $ extension );
235239 } else {
240+ $ wp_org_api = new WpOrgApi ( [ 'insecure ' => $ insecure ] );
236241 try {
237- $ offer = ( new WpOrgApi ( [ 'insecure ' => $ insecure ] ) )
238- ->get_core_download_offer ( $ locale );
242+ $ offer = $ wp_org_api ->get_core_download_offer ( $ locale );
239243 } catch ( Exception $ exception ) {
240244 WP_CLI ::error ( $ exception );
241245 }
242246 if ( ! $ offer ) {
243- WP_CLI ::error ( "The requested locale ( {$ locale }) was not found. " );
247+ if ( Utils \get_flag_value ( $ assoc_args , 'skip-locale-check ' , false ) ) {
248+ $ offer = $ this ->find_latest_offer_for_locale ( $ locale , $ insecure );
249+ if ( is_array ( $ offer ) ) {
250+ WP_CLI ::warning ( "The latest WordPress version is not yet available in the {$ locale } locale. Downloading version {$ offer ['current ' ]} instead. " );
251+ }
252+ }
253+ if ( ! $ offer ) {
254+ WP_CLI ::error ( "The requested locale ( {$ locale }) was not found. " );
255+ }
244256 }
245257 $ version = $ offer ['current ' ];
246258 $ download_url = $ offer ['download ' ];
@@ -703,8 +715,8 @@ private function set_server_url_vars( $url ) {
703715 $ _SERVER ['SCRIPT_NAME ' ] = $ path ;
704716
705717 // Set SCRIPT_FILENAME to the actual WordPress index.php if available.
706- if ( file_exists ( Utils \ trailingslashit ( ABSPATH ) . 'index.php ' ) ) {
707- $ _SERVER ['SCRIPT_FILENAME ' ] = Utils \ trailingslashit ( ABSPATH ) . 'index.php ' ;
718+ if ( file_exists ( Path:: trailingslashit ( ABSPATH ) . 'index.php ' ) ) {
719+ $ _SERVER ['SCRIPT_FILENAME ' ] = Path:: trailingslashit ( ABSPATH ) . 'index.php ' ;
708720 }
709721 }
710722
@@ -1063,7 +1075,7 @@ private static function get_wp_details( $abspath = ABSPATH ) {
10631075 * Gets the template path based on installation type.
10641076 */
10651077 private static function get_template_path ( $ template ) {
1066- $ command_root = Utils \phar_safe_path ( dirname ( __DIR__ ) );
1078+ $ command_root = Path:: phar_safe ( dirname ( __DIR__ ) );
10671079 $ template_path = "{$ command_root }/templates/ {$ template }" ;
10681080
10691081 if ( ! file_exists ( $ template_path ) ) {
@@ -1671,6 +1683,57 @@ private function get_download_url( $version, $locale = 'en_US', $file_type = 'zi
16711683 return "https:// {$ locale_subdomain }wordpress.org/wordpress- {$ version }{$ locale_suffix }. {$ file_type }" ;
16721684 }
16731685
1686+ /**
1687+ * Finds the latest available WordPress download offer for a given locale by consulting
1688+ * the WordPress.org translations API.
1689+ *
1690+ * Used as a fallback when the primary version-check API does not return an offer for
1691+ * the requested locale (e.g., when a new WordPress release hasn't been translated yet).
1692+ *
1693+ * @param string $locale Locale to find an offer for.
1694+ * @param bool $insecure Whether to disable SSL verification.
1695+ * @return array{current: string, download: string}|false Offer array on success, false on failure.
1696+ */
1697+ private function find_latest_offer_for_locale ( $ locale , $ insecure ) {
1698+ $ headers = [ 'Accept ' => 'application/json ' ];
1699+ $ options = [
1700+ 'timeout ' => 30 ,
1701+ 'insecure ' => $ insecure ,
1702+ ];
1703+
1704+ try {
1705+ /** @var \WpOrg\Requests\Response $response */
1706+ $ response = Utils \http_request ( 'GET ' , 'https://api.wordpress.org/translations/core/1.0/ ' , null , $ headers , $ options );
1707+ } catch ( Exception $ exception ) {
1708+ return false ;
1709+ }
1710+
1711+ if ( $ response ->status_code < 200 || $ response ->status_code >= 300 ) {
1712+ return false ;
1713+ }
1714+
1715+ /** @var array{translations: array<int, array{language: string, version: string}>}|null $body */
1716+ $ body = json_decode ( $ response ->body , true );
1717+
1718+ if ( ! is_array ( $ body ) || empty ( $ body ['translations ' ] ) ) {
1719+ return false ;
1720+ }
1721+
1722+ foreach ( $ body ['translations ' ] as $ translation ) {
1723+ if (
1724+ isset ( $ translation ['language ' ], $ translation ['version ' ] )
1725+ && $ locale === $ translation ['language ' ]
1726+ ) {
1727+ return [
1728+ 'current ' => $ translation ['version ' ],
1729+ 'download ' => $ this ->get_download_url ( $ translation ['version ' ], $ locale , 'zip ' ),
1730+ ];
1731+ }
1732+ }
1733+
1734+ return false ;
1735+ }
1736+
16741737 /**
16751738 * Returns update information.
16761739 *
@@ -2027,7 +2090,7 @@ private function remove_old_files_from_list( $files ) {
20272090 WP_CLI ::debug ( 'Failed to resolve ABSPATH realpath ' , 'core ' );
20282091 return $ count ;
20292092 }
2030- $ abspath_realpath_trailing = Utils \ trailingslashit ( $ abspath_realpath );
2093+ $ abspath_realpath_trailing = Path:: trailingslashit ( $ abspath_realpath );
20312094
20322095 foreach ( $ files as $ file ) {
20332096 $ file_path = ABSPATH . $ file ;
@@ -2041,7 +2104,7 @@ private function remove_old_files_from_list( $files ) {
20412104 if ( is_link ( $ file_path ) ) {
20422105 $ normalized_path = realpath ( dirname ( $ file_path ) );
20432106 if ( false === $ normalized_path
2044- || 0 !== strpos ( Utils \ trailingslashit ( $ normalized_path ), $ abspath_realpath_trailing )
2107+ || 0 !== strpos ( Path:: trailingslashit ( $ normalized_path ), $ abspath_realpath_trailing )
20452108 ) {
20462109 WP_CLI ::debug ( "Skipping symbolic link outside of ABSPATH: {$ file }" , 'core ' );
20472110 continue ;
@@ -2057,7 +2120,7 @@ private function remove_old_files_from_list( $files ) {
20572120
20582121 // Regular files/directories: validate real path is within ABSPATH.
20592122 $ file_realpath = realpath ( $ file_path );
2060- if ( false === $ file_realpath || 0 !== strpos ( Utils \ trailingslashit ( $ file_realpath ), $ abspath_realpath_trailing ) ) {
2123+ if ( false === $ file_realpath || 0 !== strpos ( Path:: trailingslashit ( $ file_realpath ), $ abspath_realpath_trailing ) ) {
20612124 WP_CLI ::debug ( "Skipping file outside of ABSPATH: {$ file }" , 'core ' );
20622125 continue ;
20632126 }
@@ -2093,7 +2156,7 @@ private function remove_directory( $dir, $abspath_realpath_trailing ) {
20932156 WP_CLI ::debug ( "Failed to resolve realpath for directory: {$ dir }" , 'core ' );
20942157 return false ;
20952158 }
2096- if ( 0 !== strpos ( Utils \ trailingslashit ( $ dir_realpath ), $ abspath_realpath_trailing ) ) {
2159+ if ( 0 !== strpos ( Path:: trailingslashit ( $ dir_realpath ), $ abspath_realpath_trailing ) ) {
20972160 WP_CLI ::debug ( "Attempted to remove directory outside of ABSPATH: {$ dir_realpath }" , 'core ' );
20982161 return false ;
20992162 }
0 commit comments