diff --git a/src/css/settings.scss b/src/css/settings.scss index 35817074..e651a00d 100644 --- a/src/css/settings.scss +++ b/src/css/settings.scss @@ -1,6 +1,6 @@ @use 'common/editor'; -$sections: general, editor, debug; +$sections: general, editor, debug, version-switch; p.submit { display: flex; @@ -128,3 +128,85 @@ body.js { .cloud-settings tbody tr:nth-child(n+5) { display: none; } + +// Version Switch Styles +.code-snippets-version-switch { + .current-version { + font-family: monospace; + font-size: 1.1em; + font-weight: bold; + color: #0073aa; + background: #f0f6fc; + padding: 2px 8px; + border-radius: 3px; + border: 1px solid #c3c4c7; + } + + #target_version { + min-width: 200px; + margin-inline-start: 8px; + } + + #switch-version-btn { + &[disabled] { + opacity: 0.6; + cursor: not-allowed; + background-color: #f0f0f1 !important; + color: #a7aaad !important; + border-color: #dcdcde !important; + } + } + + // Warning box styling + #version-switch-warning { + margin-top: 20px !important; + padding: 12px 16px; + border-left: 4px solid #dba617; + background: #fff8e5; + border-radius: 4px; + + p { + margin: 0; + color: #8f6914; + + strong { + color: #8f6914; + } + } + } + + #version-switch-result { + margin-block-start: 12px; + + &.notice { + padding: 8px 12px; + border-radius: 4px; + } + } + + .notice { + &.notice-success { + border-left-color: #00a32a; + } + + &.notice-error { + border-left-color: #d63638; + } + + &.notice-warning { + border-left-color: #dba617; + } + + &.notice-info { + border-left-color: #72aee6; + } + } +} + +.version-switch-settings { + .form-table { + th { + width: 180px; + } + } +} diff --git a/src/php/class-plugin.php b/src/php/class-plugin.php index 92bebfa3..d1d63e5f 100644 --- a/src/php/class-plugin.php +++ b/src/php/class-plugin.php @@ -114,6 +114,7 @@ public function load_plugin() { // Settings component. require_once $includes_path . '/settings/settings-fields.php'; require_once $includes_path . '/settings/editor-preview.php'; + require_once $includes_path . '/settings/version-switch.php'; require_once $includes_path . '/settings/settings.php'; // Cloud List Table shared functions. diff --git a/src/php/settings/class-setting-field.php b/src/php/settings/class-setting-field.php index e8d64dcd..e9887456 100644 --- a/src/php/settings/class-setting-field.php +++ b/src/php/settings/class-setting-field.php @@ -117,7 +117,11 @@ public function render() { * Render a callback field. */ public function render_callback_field() { - call_user_func( $this->render_callback ); + if ( ! is_callable( $this->render_callback ) ) { + return; + } + + call_user_func( $this->render_callback, $this->args ); } /** diff --git a/src/php/settings/settings-fields.php b/src/php/settings/settings-fields.php index 593635b3..6ec79b3e 100644 --- a/src/php/settings/settings-fields.php +++ b/src/php/settings/settings-fields.php @@ -46,6 +46,9 @@ function get_default_settings(): array { 'keymap' => 'default', 'theme' => 'default', ], + 'version-switch' => [ + 'selected_version' => '', + ], ]; $defaults = apply_filters( 'code_snippets_settings_defaults', $defaults ); @@ -81,6 +84,24 @@ function get_settings_fields(): array { ], ]; + $fields['version-switch'] = [ + 'version_switcher' => [ + 'name' => __( 'Switch Version', 'code-snippets' ), + 'type' => 'callback', + 'render_callback' => 'Code_Snippets\Settings\VersionSwitch\render_version_switch_field', + ], + 'refresh_versions' => [ + 'name' => __( 'Refresh Versions', 'code-snippets' ), + 'type' => 'callback', + 'render_callback' => 'Code_Snippets\Settings\VersionSwitch\render_refresh_versions_field', + ], + 'version_warning' => [ + 'name' => '', + 'type' => 'callback', + 'render_callback' => 'Code_Snippets\Settings\VersionSwitch\render_version_switch_warning', + ], + ]; + $fields['general'] = [ 'activate_by_default' => [ 'name' => __( 'Activate by Default', 'code-snippets' ), diff --git a/src/php/settings/settings.php b/src/php/settings/settings.php index cf94f8b9..3d54b960 100644 --- a/src/php/settings/settings.php +++ b/src/php/settings/settings.php @@ -136,9 +136,10 @@ function update_setting( string $section, string $field, $new_value ): bool { */ function get_settings_sections(): array { $sections = array( - 'general' => __( 'General', 'code-snippets' ), - 'editor' => __( 'Code Editor', 'code-snippets' ), - 'debug' => __( 'Debug', 'code-snippets' ), + 'general' => __( 'General', 'code-snippets' ), + 'editor' => __( 'Code Editor', 'code-snippets' ), + 'debug' => __( 'Debug', 'code-snippets' ), + 'version-switch' => __( 'Version', 'code-snippets' ), ); return apply_filters( 'code_snippets_settings_sections', $sections ); diff --git a/src/php/settings/version-switch.php b/src/php/settings/version-switch.php new file mode 100644 index 00000000..c7bd37b5 --- /dev/null +++ b/src/php/settings/version-switch.php @@ -0,0 +1,579 @@ + $download_url ) { + if ( 'trunk' !== $version ) { + $versions[] = [ + 'version' => $version, + 'url' => $download_url, + ]; + } + } + + // Sort versions in descending order + usort( $versions, function( $a, $b ) { + return version_compare( $b['version'], $a['version'] ); + }); + + // Cache for configured duration + set_transient( VERSION_CACHE_KEY, $versions, VERSION_CACHE_DURATION ); + } + + return $versions; +} + +/** + * Get current plugin version + * + * @return string Current version + */ +function get_current_version(): string { + return defined( 'CODE_SNIPPETS_VERSION' ) ? CODE_SNIPPETS_VERSION : '0.0.0'; +} + +/** + * Check if a version switch is in progress + * + * @return bool True if switch is in progress + */ +function is_version_switch_in_progress(): bool { + return get_transient( PROGRESS_KEY ) !== false; +} + +/** + * Clear version-related caches + * + * @return void + */ +function clear_version_caches(): void { + delete_transient( VERSION_CACHE_KEY ); + delete_transient( PROGRESS_KEY ); +} + +/** + * Validate target version against available versions + * + * @param string $target_version Target version to validate + * @param array $available_versions Array of available versions + * @return array Validation result with success status, message, and download URL + */ +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' => '', + ]; + } + + foreach ( $available_versions as $version_info ) { + if ( $version_info['version'] === $target_version ) { + return [ + 'success' => true, + 'message' => '', + 'download_url' => $version_info['url'], + ]; + } + } + + return [ + 'success' => false, + 'message' => __( 'Invalid version specified.', 'code-snippets' ), + 'download_url' => '', + ]; +} + +/** + * Create a standardized error response + * + * @param string $message User-friendly error message + * @param string $technical_details Technical details for debugging (optional) + * @return array Error response array + */ +function create_error_response( string $message, string $technical_details = '' ): array { + if ( ! empty( $technical_details ) ) { + // Log technical details for debugging + if ( function_exists( 'error_log' ) ) { + error_log( sprintf( 'Code Snippets version switch error: %s. Details: %s', $message, $technical_details ) ); + } + } + + return [ + 'success' => false, + 'message' => $message, + ]; +} + +/** + * Perform the actual version installation using WordPress upgrader + * + * @param string $download_url URL to download the plugin version + * @return bool|\WP_Error Installation result + */ +function perform_version_install( string $download_url ) { + // Include WordPress upgrade functions + 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'; + } + + // Create update handler (captures Ajax responses and errors) and upgrader instance + $update_handler = new \WP_Ajax_Upgrader_Skin(); + $upgrader = new \Plugin_Upgrader( $update_handler ); + + // Store the handler globally so we can access it later for error extraction + global $code_snippets_last_update_handler, $code_snippets_last_upgrader; + $code_snippets_last_update_handler = $update_handler; + $code_snippets_last_upgrader = $upgrader; + + // Perform the install/overwrite using the package download URL from WordPress.org + return $upgrader->install( $download_url, [ + 'overwrite_package' => true, + 'clear_update_cache' => true, + ] ); +} + +/** + * Handle installation failure and extract useful error information + * + * @param string $target_version The target version that failed to install + * @param string $download_url The download URL used + * @param mixed $install_result The result from the upgrader + * @return array Error response with extracted information + */ +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 = extract_handler_messages( $code_snippets_last_update_handler, $code_snippets_last_upgrader ); + + // Log details for server-side debugging + log_version_switch_attempt( $target_version, $install_result, "URL: $download_url, Messages: $handler_messages" ); + + // Return a more informative message when possible (still user-friendly) + $fallback_message = __( 'Failed to switch versions. Please try again.', 'code-snippets' ); + if ( ! empty( $handler_messages ) ) { + // Trim and sanitize a bit for output + $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, + ]; +} + +/** + * Extract helpful messages from the update handler + * + * @param mixed $update_handler The WP_Ajax_Upgrader_Skin instance + * @param mixed $upgrader The Plugin_Upgrader instance + * @return string Extracted messages + */ +function extract_handler_messages( $update_handler, $upgrader ): string { + $handler_messages = ''; + + if ( isset( $update_handler ) ) { + // Errors (WP_Ajax_Upgrader_Skin stores them) + 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() ); + } + } + // Error messages string + if ( method_exists( $update_handler, 'get_error_messages' ) ) { + $em = $update_handler->get_error_messages(); + if ( $em ) { + $handler_messages .= "\n" . $em; + } + } + // Upgrade messages (feedback/info) + 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" . (string) $upgrade_msgs; + } + } + } + + // Fallback: if upgrader populated result with info, include it + 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 ); +} + +/** + * Log version switch attempt for debugging + * + * @param string $target_version Target version + * @param mixed $result Installation result + * @param string $details Additional details + * @return void + */ +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 version switch request + * + * @param string $target_version Target version to switch to + * @return array Result array with success status and message + */ +function handle_version_switch( string $target_version ): array { + // Check user capabilities + if ( ! current_user_can( 'update_plugins' ) ) { + return create_error_response( __( 'You do not have permission to update plugins.', 'code-snippets' ) ); + } + + // Validate target version + $available_versions = get_available_versions(); + $validation = validate_target_version( $target_version, $available_versions ); + + if ( ! $validation['success'] ) { + return create_error_response( $validation['message'] ); + } + + // Check if already on target version + if ( get_current_version() === $target_version ) { + return create_error_response( __( 'Already on the specified version.', 'code-snippets' ) ); + } + + // Set switch in progress + set_transient( PROGRESS_KEY, $target_version, PROGRESS_TIMEOUT ); + + // Perform the version installation + $install_result = perform_version_install( $validation['download_url'] ); + + // Clear progress transient + delete_transient( PROGRESS_KEY ); + + // Handle the result + if ( is_wp_error( $install_result ) ) { + return create_error_response( $install_result->get_error_message() ); + } + + if ( $install_result ) { + // Clear version cache on success + delete_transient( VERSION_CACHE_KEY ); + + return [ + 'success' => true, + 'message' => sprintf( + __( 'Successfully switched to version %s. Please refresh the page to see changes.', 'code-snippets' ), + $target_version + ), + ]; + } + + // If we get here, the installation failed but didn't return a WP_Error + return handle_installation_failure( $target_version, $validation['download_url'], $install_result ); +} + +/** + * Render the version switch field + * + * @param array $args Field arguments + */ +function render_version_switch_field( array $args ): void { + $current_version = get_current_version(); + $available_versions = get_available_versions(); + $is_switching = is_version_switch_in_progress(); + + ?> +
+

+ + +

+ + +
+

+
+ +

+ + +

+ +

+ +

+ + + +
+ + + __( 'You do not have permission to update plugins.', 'code-snippets' ), + ] ); + } + + $target_version = sanitize_text_field( $_POST['target_version'] ?? '' ); + + if ( empty( $target_version ) ) { + wp_send_json_error( [ + 'message' => __( 'No target version specified.', 'code-snippets' ), + ] ); + } + + $result = handle_version_switch( $target_version ); + + if ( $result['success'] ) { + wp_send_json_success( $result ); + } else { + wp_send_json_error( $result ); + } +} + +// Register AJAX handler +add_action( 'wp_ajax_code_snippets_switch_version', __NAMESPACE__ . '\\ajax_switch_version' ); + +/** + * Render refresh versions cache button + * + * @param array $args Field arguments + */ +function render_refresh_versions_field( array $args ): void { + ?> + +

+ +

+ + + __( 'You do not have permission to manage options.', 'code-snippets' ), + ] ); + } + + // Clear the cache using our helper function + delete_transient( VERSION_CACHE_KEY ); + + // Fetch fresh data + get_available_versions(); + + wp_send_json_success( [ + 'message' => __( 'Available versions updated successfully.', 'code-snippets' ), + ] ); +} + +// Register AJAX handler +add_action( 'wp_ajax_code_snippets_refresh_versions', __NAMESPACE__ . '\\ajax_refresh_versions' ); + +/** + * Render the version switch warning that appears at the bottom + * This should be called after all other version-related fields + */ +function render_version_switch_warning(): void { + ?> + +