diff --git a/src/wp-admin/includes/class-language-pack-upgrader.php b/src/wp-admin/includes/class-language-pack-upgrader.php index 733b109183846..9ca02425b0fc6 100644 --- a/src/wp-admin/includes/class-language-pack-upgrader.php +++ b/src/wp-admin/includes/class-language-pack-upgrader.php @@ -70,6 +70,10 @@ public static function async_upgrade( $upgrader = false ) { foreach ( $language_updates as $key => $language_update ) { $update = ! empty( $language_update->autoupdate ); + if ( wp_is_translation_update_deferred( $language_update ) ) { + $update = false; + } + /** * Filters whether to asynchronously update translation for core, a plugin, or a theme. * diff --git a/src/wp-admin/includes/class-wp-automatic-updater.php b/src/wp-admin/includes/class-wp-automatic-updater.php index 2facbeb1d522f..a0297a4b2aa42 100644 --- a/src/wp-admin/includes/class-wp-automatic-updater.php +++ b/src/wp-admin/includes/class-wp-automatic-updater.php @@ -229,6 +229,10 @@ public function should_update( $type, $item, $context ) { } } else { $update = ! empty( $item->autoupdate ); + + if ( wp_is_translation_update_deferred( $item ) ) { + $update = false; + } } // If the `disable_autoupdate` flag is set, override any user-choice, but allow filters. diff --git a/src/wp-admin/includes/update.php b/src/wp-admin/includes/update.php index f5aeea835bd12..420135c7ad37c 100644 --- a/src/wp-admin/includes/update.php +++ b/src/wp-admin/includes/update.php @@ -643,6 +643,146 @@ function get_theme_updates() { return $update_themes; } +/** + * Gets the display name for a translation update. + * + * @since 7.1.0 + * + * @param object $update Translation update object. + * @return string The translation update name. + */ +function wp_get_translation_update_name( $update ) { + $type = isset( $update->type ) ? $update->type : ''; + $slug = isset( $update->slug ) ? $update->slug : ''; + + switch ( $type ) { + case 'core': + return 'WordPress'; // Not translated. + + case 'theme': + $theme = wp_get_theme( $slug ); + if ( $theme->exists() ) { + return $theme->get( 'Name' ); + } + break; + + case 'plugin': + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + $plugin_names = array(); + $all_plugins = get_plugins(); + $plugin_update = get_site_transient( 'update_plugins' ); + + if ( is_object( $plugin_update ) ) { + $plugin_updates = array(); + + if ( ! empty( $plugin_update->response ) && is_array( $plugin_update->response ) ) { + $plugin_updates = array_merge( $plugin_updates, $plugin_update->response ); + } + + if ( ! empty( $plugin_update->no_update ) && is_array( $plugin_update->no_update ) ) { + $plugin_updates = array_merge( $plugin_updates, $plugin_update->no_update ); + } + + foreach ( $plugin_updates as $plugin_file => $plugin_data ) { + $plugin_data = (object) $plugin_data; + + if ( ! empty( $plugin_data->slug ) && ! empty( $all_plugins[ $plugin_file ]['Name'] ) ) { + $plugin_names[ $plugin_data->slug ] = $all_plugins[ $plugin_file ]['Name']; + } + } + } + + foreach ( $all_plugins as $plugin_file => $plugin_data ) { + $plugin_basename = dirname( $plugin_file ); + + if ( isset( $plugin_data['Name'] ) ) { + $plugin_names[ $plugin_file ] = $plugin_data['Name']; + $plugin_names[ basename( $plugin_file, '.php' ) ] = $plugin_data['Name']; + + if ( '.' !== $plugin_basename ) { + $plugin_names[ $plugin_basename ] = $plugin_data['Name']; + } + } + } + + if ( ! empty( $plugin_names[ $slug ] ) ) { + return $plugin_names[ $slug ]; + } + break; + } + + return $slug; +} + +/** + * Gets the display language for a translation update. + * + * @since 7.1.0 + * + * @param string $locale Translation update locale. + * @return string The translation update language. + */ +function wp_get_translation_update_language( $locale ) { + $translations = get_site_transient( 'available_translations' ); + + if ( is_array( $translations ) && ! empty( $translations[ $locale ]['native_name'] ) ) { + return sprintf( + /* translators: 1: Native language name, 2: Locale. */ + __( '%1$s (%2$s)' ), + $translations[ $locale ]['native_name'], + $locale + ); + } + + return $locale; +} + +/** + * Gets display data for available translation updates. + * + * @since 7.1.0 + * + * @return array[] { + * An array of translation update display data. + * + * @type bool $checked Whether the translation update is selected for installation. + * @type bool $deferred Whether the translation update has been deferred. + * @type string $id Translation update identifier. + * @type string $language Translation update language. + * @type string $language_code Translation update locale. + * @type string $name Translation update name. + * @type string $slug Translation update slug. + * @type string $type Translation update type. + * @type string $version Translation update version. + * } + */ +function wp_get_translation_update_data() { + $available_updates = wp_get_translation_updates(); + $deferred_translation_updates = wp_get_deferred_translation_updates( $available_updates ); + $translation_updates = array(); + + foreach ( $available_updates as $update ) { + $translation_update_id = wp_get_translation_update_id( $update ); + $language = isset( $update->language ) ? $update->language : ''; + $deferred = isset( $deferred_translation_updates[ $translation_update_id ] ); + + $translation_updates[] = array( + 'checked' => ! $deferred, + 'deferred' => $deferred, + 'id' => $translation_update_id, + 'language' => wp_get_translation_update_language( $language ), + 'language_code' => $language, + 'name' => wp_get_translation_update_name( $update ), + 'slug' => isset( $update->slug ) ? $update->slug : '', + 'type' => isset( $update->type ) ? $update->type : '', + 'version' => isset( $update->version ) ? $update->version : '', + ); + } + + return $translation_updates; +} + /** * Adds a callback to display update information for themes with updates available. * diff --git a/src/wp-admin/update-core.php b/src/wp-admin/update-core.php index f1cd0c0a66132..81db47ed0c135 100644 --- a/src/wp-admin/update-core.php +++ b/src/wp-admin/update-core.php @@ -813,8 +813,8 @@ function list_theme_updates() { * @since 3.7.0 */ function list_translation_updates() { - $updates = wp_get_translation_updates(); - if ( ! $updates ) { + $translation_updates = wp_get_translation_update_data(); + if ( ! $translation_updates ) { if ( 'en_US' !== get_locale() ) { echo '
' . __( 'Your translations are all up to date.' ) . '
'; @@ -822,13 +822,79 @@ function list_translation_updates() { return; } - $form_action = 'update-core.php?action=do-translation-upgrade'; + $form_action = 'update-core.php?action=do-translation-upgrade'; + $updates_count = count( $translation_updates ); ?> - +' . __( 'Translations — The files translating WordPress into your language are updated for you whenever any other updates occur. But if these files are out of date, you can click the “Update Translations” button.' ) . '
'; + $updates_howto .= '' . __( 'Translations — Translation updates are selected for installation by default. Leave any translation updates unchecked if you want to install them later, then click the “Update Translations” button. Translation updates you leave unchecked will remain available until you select them.' ) . '
'; } get_current_screen()->add_help_tab( @@ -1092,6 +1158,15 @@ function do_undismiss_core_update() { } } + if ( isset( $_GET['translation_updates'] ) && 'deferred' === $_GET['translation_updates'] ) { + wp_admin_notice( + __( 'The unchecked translation updates will remain available until you select them.' ), + array( + 'type' => 'success', + ) + ); + } + $last_update_check = false; $current = get_site_transient( 'update_core' ); @@ -1275,6 +1350,55 @@ function do_undismiss_core_update() { check_admin_referer( 'upgrade-translations' ); + if ( empty( $_POST['translations'] ) ) { + wp_redirect( self_admin_url( 'update-core.php' ) ); + exit; + } + + $translation_updates = wp_get_translation_updates_by_id(); + + $translation_update_ids = array_unique( + array_map( + 'sanitize_text_field', + wp_unslash( (array) $_POST['translations'] ) + ) + ); + + $translation_updates = array_intersect_key( $translation_updates, array_flip( $translation_update_ids ) ); + + if ( empty( $translation_updates ) ) { + wp_redirect( self_admin_url( 'update-core.php' ) ); + exit; + } + + $selected_translation_updates = array(); + + if ( ! empty( $_POST['checked'] ) ) { + $selected_translation_update_ids = array_unique( + array_map( + 'sanitize_text_field', + wp_unslash( (array) $_POST['checked'] ) + ) + ); + + $selected_translation_updates = array_intersect_key( $translation_updates, array_flip( $selected_translation_update_ids ) ); + } + + $current_translation_updates = wp_get_translation_updates_by_id(); + $deferred_translation_updates = array_intersect_key( + $current_translation_updates, + wp_get_deferred_translation_updates( $current_translation_updates ) + ); + $deferred_translation_updates = array_diff_key( $deferred_translation_updates, $translation_updates ); + $deferred_translation_updates += array_diff_key( $translation_updates, $selected_translation_updates ); + + wp_set_deferred_translation_updates( $deferred_translation_updates ); + + if ( empty( $selected_translation_updates ) ) { + wp_redirect( add_query_arg( 'translation_updates', 'deferred', self_admin_url( 'update-core.php' ) ) ); + exit; + } + require_once ABSPATH . 'wp-admin/admin-header.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; @@ -1284,7 +1408,7 @@ function do_undismiss_core_update() { $context = WP_LANG_DIR; $upgrader = new Language_Pack_Upgrader( new Language_Pack_Upgrader_Skin( compact( 'url', 'nonce', 'title', 'context' ) ) ); - $result = $upgrader->bulk_upgrade(); + $result = $upgrader->bulk_upgrade( array_values( $selected_translation_updates ) ); wp_localize_script( 'updates', diff --git a/src/wp-includes/update.php b/src/wp-includes/update.php index b7bf5a03780e7..fb277f778a333 100644 --- a/src/wp-includes/update.php +++ b/src/wp-includes/update.php @@ -920,6 +920,144 @@ function wp_get_translation_updates() { return $updates; } +/** + * Gets a stable identifier for a translation update. + * + * @since 7.1.0 + * + * @param array|object $update Translation update data. + * @return string Translation update identifier. + */ +function wp_get_translation_update_id( $update ) { + $update = (object) $update; + + return md5( + wp_json_encode( + array( + 'type' => $update->type ?? '', + 'slug' => $update->slug ?? '', + 'language' => $update->language ?? '', + 'version' => $update->version ?? '', + ) + ) + ); +} + +/** + * Gets translation updates keyed by their stable identifiers. + * + * @since 7.1.0 + * + * @param object[]|null $updates Optional. Translation update objects. Default null. + * @return object[] Translation updates keyed by identifier. + */ +function wp_get_translation_updates_by_id( $updates = null ) { + if ( null === $updates ) { + $updates = wp_get_translation_updates(); + } + + $translation_updates = array(); + + foreach ( (array) $updates as $update ) { + $translation_updates[ wp_get_translation_update_id( $update ) ] = (object) $update; + } + + return $translation_updates; +} + +/** + * Gets deferred translation updates. + * + * Deferred translation updates remain available for later installation and are + * skipped by background translation updates until they are selected again. + * + * @since 7.1.0 + * + * @param object[]|null $updates Optional. Translation update objects used to filter deferred updates + * to currently available updates. Default null. + * @return array[] Deferred translation updates keyed by identifier. + */ +function wp_get_deferred_translation_updates( $updates = null ) { + $stored_updates = get_site_option( 'deferred_translation_updates', array() ); + + if ( ! is_array( $stored_updates ) ) { + return array(); + } + + $deferred_updates = array(); + + foreach ( $stored_updates as $stored_update ) { + if ( ! is_array( $stored_update ) ) { + continue; + } + + $stored_update = (object) array( + 'type' => $stored_update['type'] ?? '', + 'slug' => $stored_update['slug'] ?? '', + 'language' => $stored_update['language'] ?? '', + 'version' => $stored_update['version'] ?? '', + ); + + $deferred_updates[ wp_get_translation_update_id( $stored_update ) ] = (array) $stored_update; + } + + if ( null !== $updates ) { + $deferred_updates = array_intersect_key( $deferred_updates, wp_get_translation_updates_by_id( $updates ) ); + } + + return $deferred_updates; +} + +/** + * Determines whether a translation update has been deferred. + * + * @since 7.1.0 + * + * @param array|object $update Translation update data. + * @param array[]|null $deferred_updates Optional. Deferred translation updates keyed by identifier. + * Default null. + * @return bool Whether the translation update has been deferred. + */ +function wp_is_translation_update_deferred( $update, $deferred_updates = null ) { + if ( null === $deferred_updates ) { + $deferred_updates = wp_get_deferred_translation_updates(); + } + + return isset( $deferred_updates[ wp_get_translation_update_id( $update ) ] ); +} + +/** + * Stores deferred translation updates. + * + * @since 7.1.0 + * + * @param object[]|array[] $updates Translation updates to defer. + * @return void + */ +function wp_set_deferred_translation_updates( $updates ) { + $deferred_updates = array(); + + foreach ( (array) $updates as $update ) { + $update = (object) $update; + + $translation_update = array( + 'type' => $update->type ?? '', + 'slug' => $update->slug ?? '', + 'language' => $update->language ?? '', + 'version' => $update->version ?? '', + ); + + $deferred_updates[ wp_get_translation_update_id( $translation_update ) ] = $translation_update; + } + + if ( empty( $deferred_updates ) ) { + delete_site_option( 'deferred_translation_updates' ); + return; + } + + update_site_option( 'deferred_translation_updates', $deferred_updates ); +} + /** * Collects counts and UI strings for available updates. * diff --git a/tests/phpunit/tests/admin/includesUpdate.php b/tests/phpunit/tests/admin/includesUpdate.php new file mode 100644 index 0000000000000..f94be8a866ade --- /dev/null +++ b/tests/phpunit/tests/admin/includesUpdate.php @@ -0,0 +1,331 @@ + 'core', + 'slug' => 'default', + 'language' => 'de_DE', + 'version' => '6.7-beta3', + ); + + $plugin_update = (object) array( + 'type' => 'plugin', + 'slug' => 'custom-internationalized-plugin', + 'language' => 'de_DE', + 'version' => '1.0.0', + ); + + $theme_update = (object) array( + 'type' => 'theme', + 'slug' => 'custom-internationalized-theme', + 'language' => 'de_DE', + 'version' => '1.0.0', + ); + + set_site_transient( + 'available_translations', + array( + 'de_DE' => array( + 'native_name' => 'Deutsch', + ), + ) + ); + + set_site_transient( + 'update_core', + (object) array( + 'translations' => array( + (array) $core_update, + ), + ) + ); + + set_site_transient( + 'update_plugins', + (object) array( + 'translations' => array( + (array) $plugin_update, + ), + ) + ); + + set_site_transient( + 'update_themes', + (object) array( + 'translations' => array( + (array) $theme_update, + ), + ) + ); + + $this->assertSame( + array( + array( + 'checked' => true, + 'deferred' => false, + 'id' => wp_get_translation_update_id( $core_update ), + 'language' => 'Deutsch (de_DE)', + 'language_code' => 'de_DE', + 'name' => 'WordPress', + 'slug' => 'default', + 'type' => 'core', + 'version' => '6.7-beta3', + ), + array( + 'checked' => true, + 'deferred' => false, + 'id' => wp_get_translation_update_id( $plugin_update ), + 'language' => 'Deutsch (de_DE)', + 'language_code' => 'de_DE', + 'name' => 'Custom Dummy Plugin', + 'slug' => 'custom-internationalized-plugin', + 'type' => 'plugin', + 'version' => '1.0.0', + ), + array( + 'checked' => true, + 'deferred' => false, + 'id' => wp_get_translation_update_id( $theme_update ), + 'language' => 'Deutsch (de_DE)', + 'language_code' => 'de_DE', + 'name' => 'Custom Internationalized Theme', + 'slug' => 'custom-internationalized-theme', + 'type' => 'theme', + 'version' => '1.0.0', + ), + ), + wp_get_translation_update_data() + ); + } + + /** + * @ticket 42281 + */ + public function test_wp_get_translation_update_data_falls_back_to_locale_and_slug() { + $plugin_update = (object) array( + 'type' => 'plugin', + 'slug' => 'missing-plugin', + 'language' => 'it_IT', + 'version' => '2.0.0', + ); + + set_site_transient( + 'update_plugins', + (object) array( + 'translations' => array( + (array) $plugin_update, + ), + ) + ); + + $this->assertSame( + array( + array( + 'checked' => true, + 'deferred' => false, + 'id' => wp_get_translation_update_id( $plugin_update ), + 'language' => 'it_IT', + 'language_code' => 'it_IT', + 'name' => 'missing-plugin', + 'slug' => 'missing-plugin', + 'type' => 'plugin', + 'version' => '2.0.0', + ), + ), + wp_get_translation_update_data() + ); + } + + /** + * @ticket 42281 + */ + public function test_wp_get_translation_update_data_matches_plugin_update_slug_to_plugin_file() { + $plugin_update = (object) array( + 'type' => 'plugin', + 'slug' => 'hello-dolly', + 'language' => 'de_DE', + 'version' => '1.7.2', + ); + + add_filter( + 'all_plugins', + static function () { + return array( + 'hello.php' => array( + 'Name' => 'Hello Dolly', + ), + ); + } + ); + + set_site_transient( + 'update_plugins', + (object) array( + 'translations' => array( + (array) $plugin_update, + ), + 'no_update' => array( + 'hello.php' => (object) array( + 'slug' => 'hello-dolly', + ), + ), + ) + ); + + $this->assertSame( + array( + array( + 'checked' => true, + 'deferred' => false, + 'id' => wp_get_translation_update_id( $plugin_update ), + 'language' => 'de_DE', + 'language_code' => 'de_DE', + 'name' => 'Hello Dolly', + 'slug' => 'hello-dolly', + 'type' => 'plugin', + 'version' => '1.7.2', + ), + ), + wp_get_translation_update_data() + ); + } + + /** + * @ticket 42281 + */ + public function test_wp_get_translation_updates_by_id_keys_updates_by_identifier() { + $core_update = (object) array( + 'type' => 'core', + 'slug' => 'default', + 'language' => 'de_DE', + 'version' => '6.7-beta3', + ); + + $this->assertSame( + array( + wp_get_translation_update_id( $core_update ) => $core_update, + ), + wp_get_translation_updates_by_id( array( $core_update ) ) + ); + } + + /** + * @ticket 42281 + */ + public function test_wp_get_translation_update_data_marks_deferred_translation_updates() { + $plugin_update = (object) array( + 'type' => 'plugin', + 'slug' => 'deferred-plugin', + 'language' => 'de_DE', + 'version' => '1.0.0', + ); + + set_site_transient( + 'update_plugins', + (object) array( + 'translations' => array( + (array) $plugin_update, + ), + ) + ); + + wp_set_deferred_translation_updates( array( $plugin_update ) ); + + $this->assertSame( + array( + array( + 'checked' => false, + 'deferred' => true, + 'id' => wp_get_translation_update_id( $plugin_update ), + 'language' => 'de_DE', + 'language_code' => 'de_DE', + 'name' => 'deferred-plugin', + 'slug' => 'deferred-plugin', + 'type' => 'plugin', + 'version' => '1.0.0', + ), + ), + wp_get_translation_update_data() + ); + } + + /** + * @ticket 42281 + */ + public function test_wp_get_deferred_translation_updates_filters_to_available_updates() { + $available_update = (object) array( + 'type' => 'plugin', + 'slug' => 'available-plugin', + 'language' => 'de_DE', + 'version' => '1.0.0', + ); + + $stale_update = (object) array( + 'type' => 'plugin', + 'slug' => 'stale-plugin', + 'language' => 'de_DE', + 'version' => '1.0.0', + ); + + wp_set_deferred_translation_updates( array( $available_update, $stale_update ) ); + + $this->assertSame( + array( + wp_get_translation_update_id( $available_update ) => array( + 'type' => 'plugin', + 'slug' => 'available-plugin', + 'language' => 'de_DE', + 'version' => '1.0.0', + ), + ), + wp_get_deferred_translation_updates( array( $available_update ) ) + ); + + $this->assertTrue( wp_is_translation_update_deferred( $available_update ) ); + $this->assertFalse( + wp_is_translation_update_deferred( + (object) array( + 'type' => 'plugin', + 'slug' => 'different-plugin', + 'language' => 'de_DE', + 'version' => '1.0.0', + ) + ) + ); + } +}