diff --git a/src/css/common/_theme.scss b/src/css/common/_theme.scss index 5affb5ab5..48c61acb3 100644 --- a/src/css/common/_theme.scss +++ b/src/css/common/_theme.scss @@ -14,6 +14,7 @@ --cs-color-surface-subtle: #f6f7f7; --cs-color-surface-muted: #f0f0f1; --cs-color-surface-info: #f0f6fc; + --cs-color-surface-warning: #f2ebc3; --cs-color-border: #c3c4c7; --cs-color-border-subtle: #dcdcde; --cs-color-info: #72aee6; diff --git a/src/css/settings.scss b/src/css/settings.scss index 8ec952aba..252753f91 100644 --- a/src/css/settings.scss +++ b/src/css/settings.scss @@ -196,15 +196,11 @@ body.js { margin-block-start: 20px !important; padding: 12px 16px; border-inline-start: 4px solid var(--cs-color-warning); - background: var(--cs-color-surface-subtle); + background: var(--cs-color-surface-warning); p { margin: 0; - color: #8f6914; - - strong { - color: #8f6914; - } + color: var(--cs-color-text); } } diff --git a/src/js/services/settings/tabs.ts b/src/js/services/settings/tabs.ts index a1c135456..5d4eb2351 100644 --- a/src/js/services/settings/tabs.ts +++ b/src/js/services/settings/tabs.ts @@ -13,10 +13,6 @@ const selectTab = (tabsWrapper: Element, tab: Element, section: string) => { document.querySelectorAll('.cloud-message').forEach(element => { element.classList.add('hidden') }) - - if ('version-switch' !== section) { - document.getElementById('version-switch-warning')?.classList.add('hidden') - } } // Refresh the editor preview whenever tabs change. CodeMirror measures itself diff --git a/src/js/services/settings/version.ts b/src/js/services/settings/version.ts index a8082da25..7e1f70e6a 100644 --- a/src/js/services/settings/version.ts +++ b/src/js/services/settings/version.ts @@ -42,9 +42,17 @@ const bindDropdown = ( currentVersion: string ): void => { const warningNotice = document.getElementById('version-switch-warning') + const targetWarning = document.getElementById('version-switch-target-warning') dropdown.addEventListener('change', () => { const selectedVersion = dropdown.value + const selectedWarning = dropdown.selectedOptions.item(0)?.dataset.warning ?? '' + + if (targetWarning) { + targetWarning.textContent = selectedWarning + targetWarning.hidden = !selectedWarning + } + if (!button) { return } @@ -61,6 +69,16 @@ const bindDropdown = ( const SUCCESS_RELOAD_MS = 3000 +const showResult = (result: HTMLElement, type: 'warning' | 'info' | 'success' | 'error', message = ''): void => { + const paragraph = document.createElement('p') + paragraph.textContent = message + + // Admin notices without the plugin's own class are hidden on its screens. + result.className = `notice notice-${type} code-snippets-notice` + result.replaceChildren(paragraph) + result.style.display = '' +} + const postForm = async (data: Record, config: VersionConfig): Promise => { const body = new URLSearchParams() Object.keys(data).forEach(k => body.append(k, data[k])) @@ -90,9 +108,7 @@ const bindSwitch = ( void (async (): Promise => { const targetVersion = dropdown.value if (!targetVersion || targetVersion === currentVersion) { - result.className = 'notice notice-warning' - result.innerHTML = `

${i18n?.selectDifferent}

` - result.style.display = '' + showResult(result, 'warning', i18n?.selectDifferent) return } @@ -100,9 +116,7 @@ const bindSwitch = ( const originalText = button.textContent ?? '' button.textContent = i18n?.switching ?? '' - result.className = 'notice notice-info' - result.innerHTML = `

${i18n?.processing}

` - result.style.display = '' + showResult(result, 'info', i18n?.processing) try { const response = await postForm({ @@ -112,19 +126,16 @@ const bindSwitch = ( }, cfg) if (response.success) { - result.className = 'notice notice-success' - result.innerHTML = `

${response.data?.message ?? ''}

` + showResult(result, 'success', response.data?.message ?? '') setTimeout(() => window.location.reload(), SUCCESS_RELOAD_MS) return } - result.className = 'notice notice-error' - result.innerHTML = `

${response.data?.message ?? i18n?.error}

` + showResult(result, 'error', response.data?.message ?? i18n?.error) button.disabled = false button.textContent = originalText } catch (_err) { - result.className = 'notice notice-error' - result.innerHTML = `

${i18n?.errorSwitch}

` + showResult(result, 'error', i18n?.errorSwitch) button.disabled = false button.textContent = originalText } @@ -142,7 +153,7 @@ const bindRefresh = ( void (async (): Promise => { const original = btn.textContent ?? '' btn.disabled = true - btn.textContent = i18n?.error ?? '' + btn.textContent = i18n?.refreshing ?? '' try { await postForm({ @@ -169,7 +180,7 @@ export const initVersionSwitch = (): void => { const config = window.code_snippets_version_switch if (!config) { - throw Error('version switch config missing') + return } const button = document.getElementById('switch-version-btn') diff --git a/src/php/Core/Uninstaller.php b/src/php/Core/Uninstaller.php index ad323e5e1..1be59843f 100644 --- a/src/php/Core/Uninstaller.php +++ b/src/php/Core/Uninstaller.php @@ -14,6 +14,7 @@ use Code_Snippets\Client\Feedback_Client; use Code_Snippets\Model\Feedback_Connection; use Code_Snippets\Search\Universal_Search; +use Code_Snippets\Settings\Version_Switch; use function Code_Snippets\flush_versioned_cache_groups; /** @@ -96,6 +97,10 @@ private function uninstall_current_site() { delete_option( Feedback_Connection::CREDENTIALS_OPTION ); delete_transient( Feedback_Panel::SUMMARY_TRANSIENT ); delete_transient( Feedback_Client::REGISTRATION_FAILURE_TRANSIENT ); + + // The switcher caches under a key belonging to whichever source is in + // force, so it has to do its own clearing rather than name the keys here. + Version_Switch::clear_version_caches(); } /** diff --git a/src/php/Core/Upgrader.php b/src/php/Core/Upgrader.php index 817e2ac12..ca355af36 100644 --- a/src/php/Core/Upgrader.php +++ b/src/php/Core/Upgrader.php @@ -134,6 +134,13 @@ private function do_site_upgrades() { $role->remove_cap( apply_filters( 'code_snippets_cap', 'manage_snippets' ) ); } + // The version switcher caches its catalogue under a key belonging to the + // source that produced it. A site whose source changed across this upgrade + // would otherwise keep serving the previous source's list until it expired. + if ( version_compare( $prev_version, '4.0.0', '<' ) ) { + delete_transient( 'code_snippets_available_versions' ); + } + if ( false === $prev_version ) { add_action( 'init', [ $this, 'create_sample_content' ] ); } diff --git a/src/php/Settings/Package_Installer.php b/src/php/Settings/Package_Installer.php new file mode 100644 index 000000000..197f33828 --- /dev/null +++ b/src/php/Settings/Package_Installer.php @@ -0,0 +1,27 @@ + [ 'name' => __( 'Reset Caches', 'code-snippets' ), 'type' => 'action', - 'desc' => __( 'Use this button to manually clear snippets caches. Worth doing before switching to an older version of the plugin, if your site uses a persistent object cache.', 'code-snippets' ), + 'desc' => __( 'Use this button to manually clear snippets caches and refetch the list of plugin versions available to install. Worth doing before switching to an older version of the plugin, if your site uses a persistent object cache.', 'code-snippets' ), ], ]; @@ -101,11 +101,6 @@ public static function get_field_definitions(): array { 'type' => 'callback', 'render_callback' => [ '\\Code_Snippets\\Settings\\Version_Switch', 'render_refresh_versions_field' ], ], - 'version_warning' => [ - 'name' => '', - 'type' => 'callback', - 'render_callback' => [ '\\Code_Snippets\\Settings\\Version_Switch', 'render_version_switch_warning' ], - ], ]; $fields['general'] = [ diff --git a/src/php/Settings/Settings_Layout.php b/src/php/Settings/Settings_Layout.php index 6ff042b97..a38e461a1 100644 --- a/src/php/Settings/Settings_Layout.php +++ b/src/php/Settings/Settings_Layout.php @@ -94,7 +94,6 @@ public static function get_tab_contents(): array { [ 'permissions', 'role_capabilities' ], [ 'version-switch', 'version_switcher' ], [ 'version-switch', 'refresh_versions' ], - [ 'version-switch', 'version_warning' ], [ 'debug', 'reset_caches' ], [ 'debug', 'database_update' ], [ 'general', 'complete_uninstall' ], @@ -210,7 +209,7 @@ public static function get_descriptions(): array { 'max_revisions' => __( 'Saves a copy each time a snippet changes, so you can compare versions and roll back a bad edit.', 'code-snippets' ), 'preserve_on_delete' => __( 'Keep the revision history after a snippet is deleted, so it can still be recovered.', 'code-snippets' ), 'enable_admin_bar' => __( 'Jump to any snippet from anywhere in the admin, and see which ones ran on the page you are looking at.', 'code-snippets' ), - 'reset_caches' => __( 'Clears stored snippet data. Worth doing before switching to an older version of the plugin.', 'code-snippets' ), + 'reset_caches' => __( 'Clears stored snippet data and refetches the list of plugin versions available to install. Worth doing before switching to an older version of the plugin.', 'code-snippets' ), ]; } } diff --git a/src/php/Settings/Upgrader_Package_Installer.php b/src/php/Settings/Upgrader_Package_Installer.php new file mode 100644 index 000000000..974a44837 --- /dev/null +++ b/src/php/Settings/Upgrader_Package_Installer.php @@ -0,0 +1,120 @@ +install( + $package, + [ + 'overwrite_package' => true, + 'clear_update_cache' => true, + ] + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( ! $result ) { + $messages = $this->extract_handler_messages( $update_handler, $upgrader ); + + return new WP_Error( + 'package_install_failed', + $messages + ? wp_trim_words( wp_strip_all_tags( $messages ), 40 ) + : __( 'The package could not be installed.', 'code-snippets' ) + ); + } + + // The upgrader reports nothing about what it wrote, so the caller falls + // back to the version it asked for. + return [ 'version' => '' ]; + } + + /** + * Extract error messages from an upgrade handler. + * + * @param WP_Upgrader_Skin|null $update_handler Update handler. + * @param Plugin_Upgrader|null $upgrader Plugin upgrader. + * + * @return string + * + * phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_print_r + */ + public function extract_handler_messages( ?WP_Upgrader_Skin $update_handler, ?Plugin_Upgrader $upgrader ): string { + $handler_messages = ''; + + if ( isset( $update_handler ) ) { + if ( method_exists( $update_handler, 'get_errors' ) ) { + $errs = $update_handler->get_errors(); + if ( $errs instanceof WP_Error && $errs->has_errors() ) { + $handler_messages .= implode( "\n", $errs->get_error_messages() ); + } + } + if ( method_exists( $update_handler, 'get_error_messages' ) ) { + $em = $update_handler->get_error_messages(); + if ( $em ) { + $handler_messages .= "\n" . $em; + } + } + if ( method_exists( $update_handler, 'get_upgrade_messages' ) ) { + $upgrade_msgs = $update_handler->get_upgrade_messages(); + if ( is_array( $upgrade_msgs ) ) { + $handler_messages .= "\n" . implode( "\n", $upgrade_msgs ); + } elseif ( $upgrade_msgs ) { + $handler_messages .= "\n" . $upgrade_msgs; + } + } + } + + if ( empty( $handler_messages ) && isset( $upgrader->result ) ) { + if ( is_wp_error( $upgrader->result ) ) { + $handler_messages = implode( "\n", $upgrader->result->get_error_messages() ); + } else { + $handler_messages = is_scalar( $upgrader->result ) + ? (string) $upgrader->result + : print_r( $upgrader->result, true ); + } + } + + return trim( $handler_messages ); + } +} diff --git a/src/php/Settings/Version_Source.php b/src/php/Settings/Version_Source.php new file mode 100644 index 000000000..e690f8ac5 --- /dev/null +++ b/src/php/Settings/Version_Source.php @@ -0,0 +1,122 @@ +>, floor: string}|WP_Error + */ + public function fetch_catalogue(); + + /** + * Explain why the catalogue stops at the floor it reported. + * + * Only consulted when fetch_catalogue() returned a non-empty `floor`. + * + * @param string $floor Oldest installable version. + * + * @return string + */ + public function get_floor_notice( string $floor ): string; + + /** + * Warn about a consequence of switching to a particular version, beyond the + * general warning every switch carries. + * + * Shown when the version is selected. An empty string shows nothing extra. + * + * @param array $version_info Catalogue entry for the version. + * + * @return string + */ + public function get_switch_warning( array $version_info ): string; + + /** + * Obtain the package for a version. + * + * `package` is either a local file path or a download URL — the installer + * accepts both. Set `cleanup` when the switcher should delete the package + * once the install has finished, as it must for a temporary download. + * + * @param array $version_info Catalogue entry for the version. + * + * @return array{package: string, cleanup: bool}|WP_Error + */ + public function fetch_package( array $version_info ); + + /** + * Record a completed switch with whatever service tracks this site. + * + * Best-effort: an implementation must never turn a successful switch into a + * reported failure. + * + * @param string $version Version now installed. + * + * @return void + */ + public function report_installed_version( string $version ): void; +} diff --git a/src/php/Settings/Version_Switch.php b/src/php/Settings/Version_Switch.php index fd035c34b..c743e9f9d 100644 --- a/src/php/Settings/Version_Switch.php +++ b/src/php/Settings/Version_Switch.php @@ -2,39 +2,33 @@ namespace Code_Snippets\Settings; -use Plugin_Upgrader; -use WP_Ajax_Upgrader_Skin; use WP_Error; -use WP_Upgrader_Skin; use function Code_Snippets\code_snippets; /** * Version switching functionality for the Code Snippets plugin. * + * This class owns the parts of switching that do not vary: the caches, the + * progress marker, the capability and nonce checks, and the rendering. Where + * versions come from, and how a package is written to disk, are supplied by a + * Version_Source and a Package_Installer, which an edition replaces through the + * `code_snippets_version_source` and `code_snippets_package_installer` filters. + * * @package Code_Snippets * @subpackage Settings */ class Version_Switch { /** - * Transient key where the available version data is cached. + * Transient key used to indicate when a version switch is currently taking place. */ - private const CACHE_KEY = 'code_snippets_available_versions'; + private const PROGRESS_KEY = 'code_snippets_version_switch_progress'; /** - * Seconds to wait for the WordPress.org plugin API. - * - * Requested while the settings screen renders, so it gives up before the - * screen stalls, whatever the site's default timeout. - * - * @var int + * Transient key holding the code and message of the last failed catalogue + * request. */ - private const REQUEST_TIMEOUT = 5; - - /** - * Transient key used to indicate when a version switch is currently taking place. - */ - private const PROGRESS_KEY = 'code_snippets_version_switch_progress'; + private const ERROR_KEY = 'code_snippets_version_switch_error'; /** * Duration of the version cache transient. @@ -42,14 +36,14 @@ class Version_Switch { private const VERSION_CACHE_DURATION = HOUR_IN_SECONDS; /** - * Duration of the 'in progress' transient. + * Duration of the last-error transient. */ - private const PROGRESS_TIMEOUT = 5 * MINUTE_IN_SECONDS; + private const ERROR_CACHE_DURATION = 5 * MINUTE_IN_SECONDS; /** - * API endpoint for checking for available plugin versions. + * Duration of the 'in progress' transient. */ - private const WORDPRESS_API_ENDPOINT = 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&slug=code-snippets'; + private const PROGRESS_TIMEOUT = 5 * MINUTE_IN_SECONDS; /** * Initialise class. @@ -59,57 +53,214 @@ class Version_Switch { public static function init(): void { add_action( 'wp_ajax_code_snippets_switch_version', [ __CLASS__, 'ajax_switch_version' ] ); add_action( 'wp_ajax_code_snippets_refresh_versions', [ __CLASS__, 'ajax_refresh_versions' ] ); + add_filter( 'code_snippets_settings_tab_contents', [ __CLASS__, 'hide_refresh_when_unavailable' ] ); + } + + /** + * Drop the refresh button from the settings screen while versions cannot be + * listed, since the unavailable notice already says what to do instead. + * + * @param array> $contents Settings tab contents. + * + * @return array> + */ + public static function hide_refresh_when_unavailable( array $contents ): array { + if ( empty( $contents['advanced'] ) || self::is_available() ) { + return $contents; + } + + $contents['advanced'] = array_values( + array_filter( + $contents['advanced'], + function ( array $entry ): bool { + return [ 'version-switch', 'refresh_versions' ] !== $entry; + } + ) + ); + + return $contents; + } + + /** + * Retrieve the source supplying available versions. + * + * @return Version_Source + */ + public static function get_source(): Version_Source { + /** + * Filters the source the version switcher lists versions from. + * + * @param Version_Source $source Default source. + */ + $source = apply_filters( 'code_snippets_version_source', new WordPress_Org_Version_Source() ); + + return $source instanceof Version_Source ? $source : new WordPress_Org_Version_Source(); + } + + /** + * Retrieve the installer that writes a package to disk. + * + * @return Package_Installer + */ + public static function get_installer(): Package_Installer { + /** + * Filters the installer the version switcher writes packages with. + * + * @param Package_Installer $installer Default installer. + */ + $installer = apply_filters( 'code_snippets_package_installer', new Upgrader_Package_Installer() ); + + return $installer instanceof Package_Installer ? $installer : new Upgrader_Package_Installer(); + } + + /** + * Determine whether versions can currently be listed. + * + * @return bool + */ + public static function is_available(): bool { + return self::get_source()->is_available(); + } + + /** + * Explain why the switcher is unavailable. + * + * @return array{message: string, action_url: string, action_label: string} + */ + public static function get_unavailable_notice(): array { + return self::get_source()->get_unavailable_notice(); + } + + /** + * Retrieve the cached catalogue, fetching it from the source when the cache + * is cold. + * + * A failed request is not cached: a transient outage must not blank the + * switcher for the full cache duration. + * + * @return array{versions: array>, floor: string} + */ + private static function get_catalogue(): array { + $source = self::get_source(); + $cached = get_transient( $source->get_cache_key() ); + + if ( is_array( $cached ) && isset( $cached['versions'] ) ) { + return $cached; + } + + $empty = [ + 'versions' => [], + 'floor' => '', + ]; + + if ( ! $source->is_available() ) { + return $empty; + } + + $result = $source->fetch_catalogue(); + + if ( is_wp_error( $result ) ) { + self::record_error( $result ); + return $empty; + } + + $catalogue = [ + 'versions' => $result['versions'], + 'floor' => $result['floor'], + ]; + + delete_transient( self::ERROR_KEY ); + set_transient( $source->get_cache_key(), $catalogue, self::VERSION_CACHE_DURATION ); + + return $catalogue; + } + + /** + * Remember why the last request to the source failed. + * + * A source reads the code back through get_last_error_code() to decide + * whether it is still available, so a package request refused for the same + * reason as a catalogue request has to be recorded the same way. + * + * @param WP_Error $error Failure returned by the source. + * + * @return void + */ + private static function record_error( WP_Error $error ): void { + set_transient( + self::ERROR_KEY, + [ + 'code' => $error->get_error_code(), + 'message' => $error->get_error_message(), + ], + self::ERROR_CACHE_DURATION + ); } /** * Retrieve a list of plugin versions available for switching. * - * @return array + * @return array> */ public static function get_available_versions(): array { - $versions = get_transient( self::CACHE_KEY ); + return self::get_catalogue()['versions']; + } - if ( false === $versions ) { - $response = wp_remote_get( - self::WORDPRESS_API_ENDPOINT, - [ 'timeout' => self::REQUEST_TIMEOUT ] - ); + /** + * Retrieve the oldest installable version, if the source reported one. + * + * @return string + */ + public static function get_version_floor(): string { + return self::get_catalogue()['floor']; + } - if ( is_wp_error( $response ) ) { - return []; - } + /** + * Retrieve the code of the last catalogue request failure. + * + * @return string + */ + public static function get_last_error_code(): string { + $error = get_transient( self::ERROR_KEY ); - $body = wp_remote_retrieve_body( $response ); - $data = json_decode( $body, true ); + return is_array( $error ) && isset( $error['code'] ) ? (string) $error['code'] : ''; + } - if ( ! $data || ! isset( $data['versions'] ) ) { - return []; - } + /** + * Retrieve the message explaining the last catalogue request failure. + * + * @return string + */ + public static function get_last_error_message(): string { + $error = get_transient( self::ERROR_KEY ); - // Filter out 'trunk' and sort versions. - $versions = []; - foreach ( $data['versions'] as $version => $download_url ) { - if ( 'trunk' !== $version ) { - $versions[] = [ - 'version' => $version, - 'url' => $download_url, - ]; - } - } + return is_array( $error ) && isset( $error['message'] ) ? (string) $error['message'] : ''; + } - // Sort versions in descending order. - usort( - $versions, - function ( $a, $b ) { - return version_compare( $b['version'], $a['version'] ); - } - ); + /** + * Discard the cached catalogue and fetch a fresh one from the source. + * + * Used by the maintenance tools, where the point of clearing caches is to see + * what the source is serving right now rather than what it served an hour ago. + * + * @return array> The freshly fetched versions. + */ + public static function refresh_available_versions(): array { + delete_transient( self::get_source()->get_cache_key() ); + delete_transient( self::ERROR_KEY ); - // Cache for configured duration. - set_transient( self::CACHE_KEY, $versions, self::VERSION_CACHE_DURATION ); - } + return self::get_available_versions(); + } - return $versions; + /** + * Explain why versions could not be listed after a refresh. + * + * @return string Reason the source is unavailable or its request failed, or an empty string when neither. + */ + public static function get_refresh_failure(): string { + $source = self::get_source(); + + return $source->is_available() ? self::get_last_error_message() : $source->get_unavailable_notice()['message']; } /** @@ -136,41 +287,59 @@ public static function is_version_switch_in_progress(): bool { * @return void */ public static function clear_version_caches(): void { - delete_transient( self::CACHE_KEY ); + delete_transient( self::get_source()->get_cache_key() ); + delete_transient( ( new WordPress_Org_Version_Source() )->get_cache_key() ); delete_transient( self::PROGRESS_KEY ); + delete_transient( self::ERROR_KEY ); + } + + /** + * Locate a version in a catalogue. + * + * @param string $target_version Version to find. + * @param array> $available_versions Catalogue entries. + * + * @return array|null + */ + private static function find_version( string $target_version, array $available_versions ): ?array { + foreach ( $available_versions as $version_info ) { + if ( isset( $version_info['version'] ) && $version_info['version'] === $target_version ) { + return $version_info; + } + } + + return null; } /** * Validate that a target version is valid. * - * @param string $target_version Target version for switching. - * @param array $available_versions List of available versions. + * This is a usability guard rather than a security boundary: a source is + * free to refuse the version again when the package is requested. * - * @return array + * @param string $target_version Target version for switching. + * @param array> $available_versions List of available versions. + * + * @return array{success: bool, message: string} */ public static function validate_target_version( string $target_version, array $available_versions ): array { if ( empty( $target_version ) ) { return [ - 'success' => false, - 'message' => __( 'No target version specified.', 'code-snippets' ), - 'download_url' => '', + 'success' => false, + 'message' => __( 'No target version specified.', 'code-snippets' ), ]; } - foreach ( $available_versions as $version_info ) { - if ( $version_info['version'] === $target_version ) { - return [ - 'success' => true, - 'message' => '', - 'download_url' => $version_info['url'], - ]; - } + if ( null === self::find_version( $target_version, $available_versions ) ) { + return [ + 'success' => false, + 'message' => __( 'Invalid version specified.', 'code-snippets' ), + ]; } return [ - 'success' => false, - 'message' => __( 'Invalid version specified.', 'code-snippets' ), - 'download_url' => '', + 'success' => true, + 'message' => '', ]; } @@ -198,131 +367,14 @@ public static function create_error_response( string $message, string $technical } /** - * Install a plugin version from a URL. - * - * @param string $download_url Download URL. - * - * @return array|bool|WP_Error - */ - public static function perform_version_install( string $download_url ) { - if ( ! function_exists( 'wp_update_plugins' ) ) { - require_once ABSPATH . 'wp-admin/includes/update.php'; - } - if ( ! function_exists( 'show_message' ) ) { - require_once ABSPATH . 'wp-admin/includes/misc.php'; - } - if ( ! class_exists( 'Plugin_Upgrader' ) ) { - require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; - } - - $update_handler = new WP_Ajax_Upgrader_Skin(); - $upgrader = new Plugin_Upgrader( $update_handler ); - - global $code_snippets_last_update_handler, $code_snippets_last_upgrader; - $code_snippets_last_update_handler = $update_handler; - $code_snippets_last_upgrader = $upgrader; - - return $upgrader->install( - $download_url, - [ - 'overwrite_package' => true, - 'clear_update_cache' => true, - ] - ); - } - - /** - * Extract error message from an upgrade handler. - * - * @param WP_Upgrader_Skin|null $update_handler Update handler. - * @param Plugin_Upgrader|null $upgrader Plugin upgrader. + * Install a plugin version from a package. * - * @return string + * @param string $package Local path to a package, or a download URL. * - * phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_print_r + * @return array{version: string}|WP_Error */ - public static function extract_handler_messages( ?WP_Upgrader_Skin $update_handler, ?Plugin_Upgrader $upgrader ): string { - $handler_messages = ''; - - if ( isset( $update_handler ) ) { - if ( method_exists( $update_handler, 'get_errors' ) ) { - $errs = $update_handler->get_errors(); - if ( $errs instanceof WP_Error && $errs->has_errors() ) { - $handler_messages .= implode( "\n", $errs->get_error_messages() ); - } - } - if ( method_exists( $update_handler, 'get_error_messages' ) ) { - $em = $update_handler->get_error_messages(); - if ( $em ) { - $handler_messages .= "\n" . $em; - } - } - if ( method_exists( $update_handler, 'get_upgrade_messages' ) ) { - $upgrade_msgs = $update_handler->get_upgrade_messages(); - if ( is_array( $upgrade_msgs ) ) { - $handler_messages .= "\n" . implode( "\n", $upgrade_msgs ); - } elseif ( $upgrade_msgs ) { - $handler_messages .= "\n" . $upgrade_msgs; - } - } - } - - if ( empty( $handler_messages ) && isset( $upgrader->result ) ) { - if ( is_wp_error( $upgrader->result ) ) { - $handler_messages = implode( "\n", $upgrader->result->get_error_messages() ); - } else { - $handler_messages = is_scalar( $upgrader->result ) - ? (string) $upgrader->result - : print_r( $upgrader->result, true ); - } - } - - return trim( $handler_messages ); - } - - /** - * Report the failure of a version switch attempt. - * - * @param string $target_version Version number of attempted upgrade. - * @param mixed $result Result of upgrade. - * @param string $details Additional details. - * - * @return void - * - * phpcs:disable WordPress.PHP.DevelopmentFunctions - */ - private static function log_version_switch_attempt( string $target_version, $result, string $details = '' ): void { - if ( function_exists( 'error_log' ) ) { - error_log( sprintf( 'Code Snippets version switch failed. target=%s, result=%s, details=%s', $target_version, var_export( $result, true ), $details ) ); - } - } - - /** - * Handle the failure to install a new version. - * - * @param string $target_version Version used for attempted installation. - * @param string $download_url URL used for downloading new version. - * @param mixed $install_result Result of installation attempt. - * - * @return array - */ - private static function handle_installation_failure( string $target_version, string $download_url, $install_result ): array { - global $code_snippets_last_update_handler, $code_snippets_last_upgrader; - - $handler_messages = self::extract_handler_messages( $code_snippets_last_update_handler, $code_snippets_last_upgrader ); - self::log_version_switch_attempt( $target_version, $install_result, "URL: $download_url, Messages: $handler_messages" ); - - $fallback_message = __( 'Failed to switch versions. Please try again.', 'code-snippets' ); - - if ( ! empty( $handler_messages ) ) { - $short = wp_trim_words( wp_strip_all_tags( $handler_messages ), 40 ); - $fallback_message = sprintf( '%s %s', $fallback_message, $short ); - } - - return [ - 'success' => false, - 'message' => $fallback_message, - ]; + public static function perform_version_install( string $package ) { + return self::get_installer()->install( $package ); } /** @@ -337,6 +389,12 @@ public static function handle_version_switch( string $target_version ): array { return self::create_error_response( __( 'You do not have permission to update plugins.', 'code-snippets' ) ); } + $source = self::get_source(); + + if ( ! $source->is_available() ) { + return self::create_error_response( $source->get_unavailable_notice()['message'] ); + } + $available_versions = self::get_available_versions(); $validation = self::validate_target_version( $target_version, $available_versions ); @@ -350,27 +408,60 @@ public static function handle_version_switch( string $target_version ): array { set_transient( self::PROGRESS_KEY, $target_version, self::PROGRESS_TIMEOUT ); - $install_result = self::perform_version_install( $validation['download_url'] ); + $package = $source->fetch_package( self::find_version( $target_version, $available_versions ) ); - delete_transient( self::PROGRESS_KEY ); + if ( is_wp_error( $package ) ) { + delete_transient( self::PROGRESS_KEY ); + self::record_error( $package ); - if ( is_wp_error( $install_result ) ) { - return self::create_error_response( $install_result->get_error_message() ); + // The cached catalogue is stale when the source no longer recognises a + // version it listed, so discard it and let the next render refetch. + delete_transient( $source->get_cache_key() ); + + return self::create_error_response( $package->get_error_message() ); } - if ( $install_result ) { - delete_transient( self::CACHE_KEY ); + try { + $install_result = self::perform_version_install( $package['package'] ); + } finally { + if ( $package['cleanup'] ) { + wp_delete_file( $package['package'] ); + } - // translators: %s: new version number. - $message = esc_html__( 'Successfully switched to version %s. Please refresh the page to see changes.', 'code-snippets' ); + delete_transient( self::PROGRESS_KEY ); + } - return [ - 'success' => true, - 'message' => sprintf( $message, $target_version ), - ]; - } else { - return self::handle_installation_failure( $target_version, $validation['download_url'], $install_result ); + if ( is_wp_error( $install_result ) ) { + return self::create_error_response( + sprintf( + // translators: %s: reason the installation failed. + __( 'Failed to switch versions: %s', 'code-snippets' ), + $install_result->get_error_message() + ), + sprintf( + 'target=%s, code=%s, messages=%s', + $target_version, + $install_result->get_error_code(), + implode( ' | ', $install_result->get_error_messages() ) + ) + ); } + + // The version on disk is the authoritative one when the installer can read + // it, since the package decides what was actually written. + $installed_version = $install_result['version'] ? $install_result['version'] : $target_version; + + delete_transient( $source->get_cache_key() ); + $source->report_installed_version( $installed_version ); + + return [ + 'success' => true, + 'message' => sprintf( + // translators: %s: new version number. + __( 'Successfully switched to version %s. Please refresh the page to see changes.', 'code-snippets' ), + $installed_version + ), + ]; } /** @@ -380,8 +471,6 @@ public static function handle_version_switch( string $target_version ): array { */ public static function render_version_switch_field(): void { $current_version = self::get_current_version(); - $available_versions = self::get_available_versions(); - $is_switching = self::is_version_switch_in_progress(); ?>
@@ -390,44 +479,168 @@ public static function render_version_switch_field(): void {

- +

- +
+ + + + + get_floor_notice( $floor ) : ''; + + ?>

- - + get_switch_warning( $version_info ); + ?>

+ +

+ + + +

-

- - + $version_info Catalogue entry. + * @param string $current_version Version currently installed. + * + * @return string + */ + private static function describe_version( array $version_info, string $current_version ): string { + $version = (string) $version_info['version']; + $release_mode = isset( $version_info['release_mode'] ) ? (string) $version_info['release_mode'] : ''; + $tested_up_to = isset( $version_info['tested_up_to'] ) ? (string) $version_info['tested_up_to'] : ''; + + if ( $version === $current_version ) { + // translators: %s: plugin version number. + $label = sprintf( __( '%s (current)', 'code-snippets' ), $version ); + } elseif ( 'beta' === $release_mode ) { + // translators: %s: plugin version number. + $label = sprintf( __( '%s (beta)', 'code-snippets' ), $version ); + } elseif ( 'rc' === $release_mode ) { + // translators: %s: plugin version number. + $label = sprintf( __( '%s (release candidate)', 'code-snippets' ), $version ); + } elseif ( ! empty( $version_info['is_latest'] ) ) { + // translators: %s: plugin version number. + $label = sprintf( __( '%s (latest)', 'code-snippets' ), $version ); + } else { + $label = $version; + } + + if ( ! $tested_up_to ) { + return $label; + } + + return sprintf( + // translators: 1: labelled plugin version, 2: WordPress version number. + __( '%1$s — tested up to WordPress %2$s', 'code-snippets' ), + $label, + $tested_up_to + ); + } + + /** + * Render the explanation shown in place of the switcher when the catalogue + * holds no versions. + * + * The list is empty both when the request for it failed and when the source + * answered with nothing to install, which are not the same thing to the user. + * + * @return void + */ + private static function render_empty_catalogue_notice(): void { + $error_message = self::get_last_error_message(); + + if ( $error_message ) { + ?> +
+

+
+ +

+ +

+ +
+

+ +

+ + + +

+ +
+ %s

', - esc_html__( 'Check for the latest available plugin versions from WordPress.org.', 'code-snippets' ) + esc_html( self::get_source()->get_refresh_description() ) ); } /** - * AJAX handler for refreshing the installed version. + * AJAX handler for refreshing the list of available versions. * * @return void */ @@ -486,24 +699,33 @@ public static function ajax_refresh_versions(): void { wp_send_json_error( [ 'message' => __( 'You do not have permission to manage options.', 'code-snippets' ) ] ); } - delete_transient( self::CACHE_KEY ); - self::get_available_versions(); + self::refresh_available_versions(); + $failure = self::get_refresh_failure(); + + if ( $failure ) { + wp_send_json_error( [ 'message' => $failure ] ); + } wp_send_json_success( [ 'message' => __( 'Available versions updated successfully.', 'code-snippets' ) ] ); } /** - * Render warning notice. + * Render the warning shown once a different version is selected. + * + * The `inline` class keeps WordPress from moving the notice to the top of the + * screen, away from the switcher it refers to. * * @return void */ - public static function render_version_switch_warning(): void { + private static function render_version_switch_warning(): void { ?> -