👁 查看:class-options-logger.php
路径:/home/forge/kingkrunch.com/wp/wp-content/plugins/simple-history/loggers/class-options-logger.php
大小:47.5 KB · 修改:2026-09-25 00:42:29 · 权限:0644 · 可写
<?php

namespace Simple_History\Loggers;

use Simple_History\Event_Details\Event_Details_Group;
use Simple_History\Event_Details\Event_Details_Item;
use Simple_History\Event_Details\Event_Details_Item_Image_Diff_Table_Row_Formatter;
use Simple_History\Helpers;

/**
 * Logs changes to WordPress options
 */
class Options_Logger extends Logger {
	/** @var string Logger slug */
	public $slug = 'SimpleOptionsLogger';

	/**
	 * Get array with information about this logger
	 *
	 * @return array
	 */
	public function get_info() {
		return [
			'name'        => __( 'Options Logger', 'simple-history' ),
			'description' => __( 'Logs updates to WordPress settings', 'simple-history' ),
			'capability'  => 'manage_options',
			'messages'    => array(
				'option_updated' => __( 'Updated setting "{option}" on the "{option_page}" settings page', 'simple-history' ),
			),
			'labels'      => array(
				'search' => array(
					'label'   => _x( 'Options', 'Options logger: search', 'simple-history' ),
					'options' => array(
						_x( 'Changed options', 'Options logger: search', 'simple-history' ) => array(
							'option_updated',
						),
					),
				),
			),
		];
	}

	/**
	 * Tracks whether the WP core /wp/v2/settings REST route is being processed.
	 * Set/cleared via the rest_request_before/after_callbacks filters so the
	 * `updated_option` handler can tell that a REST settings update is the
	 * source of the change (vs. an arbitrary plugin REST endpoint).
	 *
	 * @var bool
	 */
	private $is_processing_rest_settings_request = false;

	/**
	 * Called when logger is loaded.
	 */
	public function loaded() {
		// Hook updated_option unconditionally so REST and WP-CLI calls are
		// seen too. Source filtering happens inside the handler — see
		// on_updated_option().
		add_action( 'updated_option', array( $this, 'on_updated_option' ), 10, 3 );

		// Detect WP core's /wp/v2/settings REST route so its updates are
		// distinguished from arbitrary plugin REST endpoints.
		add_filter( 'rest_request_before_callbacks', array( $this, 'on_rest_request_before_callbacks' ), 10, 3 );
		add_filter( 'rest_request_after_callbacks', array( $this, 'on_rest_request_after_callbacks' ), 10, 3 );
	}

	/**
	 * Flip the REST settings flag on if the current request targets
	 * /wp/v2/settings.
	 *
	 * @param \WP_REST_Response|\WP_HTTP_Response|\WP_Error|mixed $response Response.
	 * @param array                                               $handler  Route handler.
	 * @param \WP_REST_Request                                    $request  Current request.
	 * @return mixed Passthrough of $response.
	 */
	public function on_rest_request_before_callbacks( $response, $handler, $request ) {
		if ( $request instanceof \WP_REST_Request ) {
			$route = $request->get_route();

			// Exact route match, or any subroute under /wp/v2/settings/.
			// Avoids matching unrelated plugin routes like /wp/v2/settings-foo.
			if ( $route === '/wp/v2/settings' || strpos( $route, '/wp/v2/settings/' ) === 0 ) {
				$this->is_processing_rest_settings_request = true;
			}
		}

		return $response;
	}

	/**
	 * Clear the REST settings flag after the route's callbacks have run.
	 *
	 * @param \WP_REST_Response|\WP_HTTP_Response|\WP_Error|mixed $response Response.
	 * @param array                                               $handler  Route handler.
	 * @param \WP_REST_Request                                    $request  Current request.
	 * @return mixed Passthrough of $response.
	 */
	public function on_rest_request_after_callbacks( $response, $handler, $request ) {
		$this->is_processing_rest_settings_request = false;

		return $response;
	}

	/**
	 * Get a list of all built in WordPress options.
	 *
	 * @return array
	 */
	protected function get_wordpress_built_in_options() {
		return [
			'general'    => [
				'translation'               => __( 'General', 'simple-history' ),
				'translation_settings_page' => __( 'General Settings Page', 'simple-history' ),
				'options'                   => [
					'siteurl'            => [ 'translation' => __( 'WordPress Address (URL)', 'simple-history' ) ],
					'home'               => [ 'translation' => __( 'Site Address (URL)', 'simple-history' ) ],
					'blogname'           => [ 'translation' => __( 'Site Title', 'simple-history' ) ],
					'blogdescription'    => [ 'translation' => __( 'Tagline', 'simple-history' ) ],
					'site_icon'          => [ 'translation' => __( 'Site Icon', 'simple-history' ) ],
					'admin_email'        => [ 'translation' => __( 'Administration Email Address', 'simple-history' ) ],
					'new_admin_email'    => [ 'translation' => __( 'New Email Address', 'simple-history' ) ],
					'users_can_register' => [
						'translation' => __( 'Anyone can register', 'simple-history' ),
						'type'        => 'onoff',
					],
					'default_role'       => [ 'translation' => __( 'New User Default Role', 'simple-history' ) ],
					'timezone_string'    => [ 'translation' => __( 'Timezone', 'simple-history' ) ],
					'date_format'        => [ 'translation' => __( 'Date Format', 'simple-history' ) ],
					'time_format'        => [ 'translation' => __( 'Time Format', 'simple-history' ) ],
					'start_of_week'      => [ 'translation' => __( 'Week Starts On', 'simple-history' ) ],
					'WPLANG'             => [ 'translation' => __( 'Site Language', 'simple-history' ) ],
				],
			],
			'writing'    => [
				'translation'               => __( 'Writing', 'simple-history' ),
				'translation_settings_page' => __( 'Writing Settings Page', 'simple-history' ),
				'options'                   => [
					'default_category'       => [ 'translation' => __( 'Default Post Category', 'simple-history' ) ],
					'default_post_format'    => [ 'translation' => __( 'Default Post Format', 'simple-history' ) ],
					'post_by_email'          => [ 'translation' => __( 'Post via Email settings (legacy)', 'simple-history' ) ],
					'mailserver_url'         => [ 'translation' => __( 'Mail Server', 'simple-history' ) ],
					'mailserver_login'       => [ 'translation' => __( 'Login Name', 'simple-history' ) ],
					'mailserver_pass'        => [ 'translation' => __( 'Password', 'simple-history' ) ],
					'mailserver_port'        => [ 'translation' => __( 'Default Mail Server Port', 'simple-history' ) ],
					'default_pingback_flag'  => [
						'translation' => __( 'Attempt to notify any blogs linked to from the article', 'simple-history' ),
						'type'        => 'onoff',
					],
					'default_ping_status'    => [ 'translation' => __( 'Allow link notifications from other blogs (pingbacks and trackbacks)', 'simple-history' ) ],
					'default_comment_status' => [ 'translation' => __( 'Allow people to submit comments on new posts', 'simple-history' ) ],
					'ping_sites'             => [ 'translation' => __( 'Update Services', 'simple-history' ) ],
				],
			],
			'reading'    => [
				'translation'               => __( 'Reading', 'simple-history' ),
				'translation_settings_page' => __( 'Reading Settings Page', 'simple-history' ),
				'options'                   => [
					'posts_per_page'  => [ 'translation' => __( 'Blog pages show at most', 'simple-history' ) ],
					'posts_per_rss'   => [ 'translation' => __( 'Syndication feeds show the most recent', 'simple-history' ) ],
					'rss_use_excerpt' => [ 'translation' => __( 'For each article in a feed, show', 'simple-history' ) ],
					'show_on_front'   => [ 'translation' => __( 'Front page displays', 'simple-history' ) ],
					'page_on_front'   => [ 'translation' => __( 'Front page', 'simple-history' ) ],
					'page_for_posts'  => [ 'translation' => __( 'Posts page', 'simple-history' ) ],
					'blog_public'     => [
						'translation' => __( 'Discourage search engines from indexing this site', 'simple-history' ),
						'type'        => 'reversed_onoff',
					],
				],
			],
			'discussion' => [
				'translation'               => __( 'Discussion', 'simple-history' ),
				'translation_settings_page' => __( 'Discussion Settings Page', 'simple-history' ),
				'options'                   => [
					'default_article_visibility'   => [ 'translation' => __( 'Default article visibility', 'simple-history' ) ],
					'default_comment_status'       => [ 'translation' => __( 'Allow people to submit comments on new posts', 'simple-history' ) ],
					'require_name_email'           => [
						'translation' => __( 'Comment author must fill out name and email', 'simple-history' ),
						'type'        => 'onoff',
					],
					'comment_registration'         => [
						'translation' => __( 'Users must be registered and logged in to comment', 'simple-history' ),
						'type'        => 'onoff',
					],
					'close_comments_for_old_posts' => [
						'translation' => __( 'Automatically close comments on posts older than', 'simple-history' ),
						'type'        => 'onoff',
					],
					'close_comments_days_old'      => [ 'translation' => __( 'Days before comments are closed', 'simple-history' ) ],
					'show_comments_cookies_opt_in' => [
						'translation' => __( 'Show comments cookies opt-in checkbox', 'simple-history' ),
						'type'        => 'onoff',
					],
					'thread_comments'              => [
						'translation' => __( 'Enable threaded (nested) comments', 'simple-history' ),
						'type'        => 'onoff',
					],
					'thread_comments_depth'        => [ 'translation' => __( 'Max depth for threaded comments', 'simple-history' ) ],
					'page_comments'                => [
						'translation' => __( 'Break comments into pages', 'simple-history' ),
						'type'        => 'onoff',
					],
					'comments_per_page'            => [ 'translation' => __( 'Top level comments per page', 'simple-history' ) ],
					'default_comments_page'        => [ 'translation' => __( 'Comments should be displayed with the', 'simple-history' ) ],
					'comment_order'                => [ 'translation' => __( 'Comments order', 'simple-history' ) ],
					'comment_previously_approved'  => [ 'translation' => __( 'Comment author must have a previously approved comment', 'simple-history' ) ],
					'comment_max_links'            => [ 'translation' => __( 'Hold a comment in the queue if it contains', 'simple-history' ) ],
					'moderation_keys'              => [ 'translation' => __( 'Comment Moderation', 'simple-history' ) ],
					'blacklist_keys'               => [ 'translation' => __( 'Disallowed Comment Keys', 'simple-history' ) ],
					'disallowed_keys'              => [ 'translation' => __( 'Disallowed Comment Keys', 'simple-history' ) ],
					'comment_moderation'           => [ 'translation' => __( 'Comment must be manually approved', 'simple-history' ) ],
					'comment_whitelist'            => [ 'translation' => __( 'Comment author must have a previously approved comment', 'simple-history' ) ],
					'comments_notify'              => [
						'translation' => __( 'Email me whenever anyone posts a comment', 'simple-history' ),
						'type'        => 'onoff',
					],
					'comment_notify'               => [ 'translation' => __( 'Email me whenever anyone posts a comment', 'simple-history' ) ],
					'moderation_notify'            => [
						'translation' => __( 'Email me whenever a comment is held for moderation', 'simple-history' ),
						'type'        => 'onoff',
					],
					'show_avatars'                 => [
						'translation' => __( 'Show Avatars', 'simple-history' ),
						'type'        => 'onoff',
					],
					'avatar_rating'                => [ 'translation' => __( 'Maximum Rating', 'simple-history' ) ],
					'avatar_default'               => [ 'translation' => __( 'Default Avatar', 'simple-history' ) ],
				],
			],
			'media'      => [
				'translation'               => __( 'Media', 'simple-history' ),
				'translation_settings_page' => __( 'Media Settings Page', 'simple-history' ),
				'options'                   => [
					'thumbnail_size_w'              => [ 'translation' => __( 'Thumbnail size width', 'simple-history' ) ],
					'thumbnail_size_h'              => [ 'translation' => __( 'Thumbnail size height', 'simple-history' ) ],
					'thumbnail_crop'                => [ 'translation' => __( 'Crop thumbnail to exact dimensions', 'simple-history' ) ],
					'medium_size_w'                 => [ 'translation' => __( 'Medium size width', 'simple-history' ) ],
					'medium_size_h'                 => [ 'translation' => __( 'Medium size height', 'simple-history' ) ],
					'large_size_w'                  => [ 'translation' => __( 'Large size width', 'simple-history' ) ],
					'large_size_h'                  => [ 'translation' => __( 'Large size height', 'simple-history' ) ],
					'uploads_use_yearmonth_folders' => [
						'translation' => __( 'Organize my uploads into month- and year-based folders', 'simple-history' ),
						'type'        => 'onoff',
					],
				],
			],
			'permalinks' => [
				'translation'               => __( 'Permalinks', 'simple-history' ),
				'translation_settings_page' => __( 'Permalink Settings Page', 'simple-history' ),
				'options'                   => [
					'permalink_structure' => [ 'translation' => __( 'Custom Structure', 'simple-history' ) ],
					'category_base'       => [ 'translation' => __( 'Category base', 'simple-history' ) ],
					'tag_base'            => [ 'translation' => __( 'Tag base', 'simple-history' ) ],
				],
			],
		];
	}

	/**
	 * Check if the option page is a built in WordPress options page.
	 *
	 * @param string $option_page Option page name.
	 * @return bool
	 */
	protected function is_wordpress_built_in_options_page( $option_page ) {
		$valid_option_pages = [
			'general',
			'discussion',
			'media',
			'reading',
			'writing',
		];

		return in_array( $option_page, $valid_option_pages, true );
	}

	/**
	 * Check if the form was submitted from the permalink settings page.
	 *
	 * @return bool
	 */
	protected function is_form_submitted_from_permalink_page() {
		$referer = wp_get_referer();
		return $referer && strpos( $referer, 'options-permalink.php' ) !== false;
	}

	/**
	 * Check if the option name is a built in WordPress option.
	 *
	 * @param string $option_name Option name.
	 */
	protected function is_built_in_wordpress_options_name( $option_name ) {
		return in_array( $option_name, $this->get_wordpress_options_keys(), true );
	}

	/**
	 * When a tracked WordPress option is updated from a known source.
	 *
	 * Gating, in order:
	 *  1. Option name must be in the built-in allowlist.
	 *  2. Source must be one of: wp-admin built-in Settings form, WP core
	 *     /wp/v2/settings REST endpoint, or WP-CLI. Anything else (a
	 *     plugin's admin page, a plugin's REST endpoint, cron, etc.) is
	 *     intentionally ignored to avoid logging every option write in
	 *     WordPress.
	 *
	 * @param string $option Option name.
	 * @param mixed  $old_value Old value.
	 * @param mixed  $new_value New value.
	 */
	public function on_updated_option( $option, $old_value, $new_value ) {
		if ( ! $this->is_built_in_wordpress_options_name( $option ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$posted_option_page = sanitize_text_field( wp_unslash( $_REQUEST['option_page'] ?? '' ) );
		$is_admin_form      = $this->is_wordpress_built_in_options_page( $posted_option_page )
			|| $this->is_form_submitted_from_permalink_page();
		$is_rest_settings   = $this->is_processing_rest_settings_request;
		$is_wp_cli          = Helpers::is_wp_cli();

		if ( ! $is_admin_form && ! $is_rest_settings && ! $is_wp_cli ) {
			return;
		}

		// Derive option_page: admin form posts it directly; permalink form
		// is a special case; REST/CLI fall back to the reverse mapping from
		// option name -> page slug.
		if ( $posted_option_page && $this->is_wordpress_built_in_options_page( $posted_option_page ) ) {
			$option_page = $posted_option_page;
		} elseif ( $this->is_form_submitted_from_permalink_page() ) {
			$option_page = 'permalink';
		} else {
			$option_page = $this->get_option_page_for_option_name( $option );

			// Normalize plural mapping key to the singular admin URL slug
			// (options-permalink.php).
			if ( $option_page === 'permalinks' ) {
				$option_page = 'permalink';
			}
		}

		if ( ! $option_page ) {
			return;
		}

		// If new value is null then store as empty string.
		if ( is_null( $new_value ) ) {
			$new_value = '';
		}

		$context = [
			'option'      => $option,
			'old_value'   => $old_value,
			'new_value'   => $new_value,
			'option_page' => $option_page,
		];

		// Store a bit more about some options
		// Like "page_on_front" we also store post title
		// Check for a method for current option in this class and calls it automagically.
		$methodname = 'add_context_for_option_' . strtolower( $option );
		if ( method_exists( $this, $methodname ) ) {
			$context = $this->$methodname( $context, $old_value, $new_value, $option, $option_page );
		}

		$this->info_message( 'option_updated', $context );
	}

	/**
	 * Modify plain output to include link to option page and make option in cleartext.
	 *
	 * @param object $row Row data.
	 */
	public function get_log_row_plain_text_output( $row ) {
		$context     = $row->context;
		$message_key = $context['_message_key'] ?? null;
		$option      = $context['option'] ?? null;
		$option_page = $context['option_page'] ?? null;
		$message     = $row->message;

		// Update message to include link to option page.
		if ( $message_key === 'option_updated' && $option_page && $option ) {

			// Show option translated name.
			$option_info        = $this->get_option_info( $option );
			$option_translation = $option_info['translation'] ?? $option;

			$context['option_translated'] = $option_translation;
			$context['option_page_link']  = admin_url( "options-{$option_page}.php" );

			// Show option page translated name.
			$options_page_info                 = $this->get_option_page_info( $option_page );
			$options_page_translation          = $options_page_info['translation_settings_page'] ?? $option_page;
			$context['option_page_translated'] = $options_page_translation;

			// This override skips the parent's esc_html(), so escape the interpolated
			// values here instead. option_page_link is built from admin_url() with an
			// allowlisted page and is used as an href, so it is left alone.
			$context = $this->esc_html_context_keys(
				$context,
				[ 'option_translated', 'option_page_translated' ]
			);

			$message = __( 'Updated setting "{option_translated}" on the <a href="{option_page_link}">{option_page_translated}</a>', 'simple-history' );
		}

		return Helpers::interpolate( $message, $context, $row );
	}

	/**
	 * Get detailed output
	 *
	 * @param object $row Log row object.
	 */
	public function get_log_row_details_output( $row ) {
		$context     = $row->context;
		$message_key = $context['_message_key'];

		// Bail if not option_updated message.
		if ( $message_key !== 'option_updated' ) {
			return '';
		}

		$option      = $context['option'] ?? null;
		$option_page = $context['option_page'] ?? null;
		$new_value   = $context['new_value'] ?? null;
		$old_value   = $context['old_value'] ?? null;

		if ( ! $new_value && ! $old_value ) {
			return '';
		}

		// Try custom output method for specific options.
		$methodname = 'get_details_group_for_option_' . strtolower( $option );

		if ( method_exists( $this, $methodname ) ) {
			$group = $this->$methodname( $context, $old_value, $new_value, $option, $option_page );
			if ( $group instanceof Event_Details_Group ) {
				return $group;
			}
		}

		// Try type-based output (onoff, reversed_onoff).
		$group = $this->get_group_for_option_with_type( $option, $new_value, $old_value );
		if ( $group instanceof Event_Details_Group ) {
			return $group;
		}

		// Fallback: inline-diff display of old -> new on a single row,
		// matching the style used by other loggers (e.g. User_Logger).
		$group = new Event_Details_Group();
		$item  = new Event_Details_Item( null, $this->get_inline_diff_label( $option ) );

		$has_new = $new_value !== null && $new_value !== '';
		$has_old = $old_value !== null && $old_value !== '';

		if ( $has_new && $has_old ) {
			$item->set_values(
				$this->excerptify( $new_value ),
				$this->excerptify( $old_value )
			);
		} elseif ( $has_new ) {
			$item->set_new_value( $this->excerptify( $new_value ) );
		} elseif ( $has_old ) {
			$item->set_prev_value( $this->excerptify( $old_value ) );
		}

		$group->add_item( $item );

		return $group;
	}

	/**
	 * Action link to the relevant Settings page for this option event,
	 * so the admin can jump straight to the page where the change happened.
	 *
	 * @param object $row Log row.
	 * @return array<array{url: string, label: string, action: string}>
	 */
	public function get_action_links( $row ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			return array();
		}

		$option_page = $row->context['option_page'] ?? null;

		if ( ! $option_page ) {
			return array();
		}

		$page_info = $this->get_option_page_info( $option_page );

		if ( ! is_array( $page_info ) || empty( $page_info['translation'] ) ) {
			return array();
		}

		return array(
			array(
				'url'    => admin_url( "options-{$option_page}.php" ),
				'label'  => sprintf(
					/* translators: %s: Translated WordPress settings page name, e.g. "General", "Permalinks". */
					__( 'Manage %s settings', 'simple-history' ),
					$page_info['translation']
				),
				'action' => 'edit',
			),
		);
	}

	/**
	 * Create a possible excerpt of a string, with ... appended.
	 *
	 * @param string $string_value String to create excerpt from.
	 * @param int    $length Length of excerpt.
	 * @return string Excerpt with ... added if the string was long.
	 */
	protected function excerptify( $string_value, $length = 250 ) {
		$more    = __( '&hellip;', 'simple-history' );
		$trimmed = substr( $string_value, 0, $length );

		if ( strlen( $string_value ) > $length ) {
			$trimmed .= $more;
		}

		return $trimmed;
	}

	/**
	 * Page on front = "Front page displays" -> Your latest posts / A static page
	 * value 0 = Your latest post
	 * value int n = A static page
	 *
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @return array context
	 */
	protected function add_context_for_option_page_on_front( $context, $old_value, $new_value, $option, $option_page ) {
		if ( ! empty( $old_value ) && is_numeric( $old_value ) ) {
			$old_post = get_post( $old_value );

			if ( $old_post instanceof \WP_Post ) {
				$context['old_post_title'] = $old_post->post_title;
			}
		}

		if ( ! empty( $new_value ) && is_numeric( $new_value ) ) {
			$new_post = get_post( $new_value );

			if ( $new_post instanceof \WP_Post ) {
				$context['new_post_title'] = $new_post->post_title;
			}
		}

		return $context;
	}

	/**
	 * Add context for option page_on_front for posts page.
	 *
	 * @param array $context context.
	 * @param mixed $old_value old value.
	 * @param mixed $new_value new value.
	 * @param mixed $option option name.
	 * @param mixed $option_page option page name.
	 * @return array Updated context.
	 */
	protected function add_context_for_option_page_for_posts( $context, $old_value, $new_value, $option, $option_page ) {
		// Get same info as for page_on_front.
		return call_user_func_array( array( $this, 'add_context_for_option_page_on_front' ), func_get_args() );
	}

	/**
	 * Get detailed output for page_on_front for posts page.
	 *
	 * @deprecated Use get_details_group_for_option_page_for_posts() instead.
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @return string output
	 */
	protected function get_details_output_for_option_page_for_posts( $context, $old_value, $new_value, $option, $option_page ) {
		return call_user_func_array( array( $this, 'get_details_output_for_option_page_on_front' ), func_get_args() );
	}

	/**
	 * Add detailed output for page_on_front
	 *
	 * @deprecated Use get_details_group_for_option_page_on_front() instead.
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @param string $tmpl_row template row.
	 * @return string output
	 */
	protected function get_details_output_for_option_page_on_front( $context, $old_value, $new_value, $option, $option_page, $tmpl_row = '' ) {
		$output = '';

		if ( $new_value && ! empty( $context['new_post_title'] ) ) {
			if ( get_post_status( $new_value ) ) {
				$post_title_with_link = sprintf( '<a href="%1$s">%2$s</a>', get_edit_post_link( $new_value ), esc_html( $context['new_post_title'] ) );
			} else {
				$post_title_with_link = esc_html( $context['new_post_title'] );
			}

			$output .= sprintf(
				'<dt>%1$s</dt><dd>%2$s</dd>',
				__( 'New value', 'simple-history' ),
				sprintf(
					/* translators: %s post title with link. */
					__( 'Page %s', 'simple-history' ),
					$post_title_with_link
				)
			);
		}
		if ( (int) $new_value === 0 ) {
			$output .= sprintf(
				'<dt>%1$s</dt><dd>%2$s</dd>',
				__( 'New value', 'simple-history' ),
				__( 'Your latest posts', 'simple-history' )
			);
		}

		if ( $old_value && ! empty( $context['old_post_title'] ) ) {
			if ( get_post_status( $old_value ) ) {
				$post_title_with_link = sprintf( '<a href="%1$s">%2$s</a>', get_edit_post_link( $old_value ), esc_html( $context['old_post_title'] ) );
			} else {
				$post_title_with_link = esc_html( $context['old_post_title'] );
			}

			$output .= sprintf(
				'<dt>%1$s</dt><dd>%2$s</dd>',
				__( 'Old value', 'simple-history' ),
				sprintf(
					/* translators: %s post title with link. */
					__( 'Page %s', 'simple-history' ),
					$post_title_with_link
				)
			);
		}

		if ( (int) $old_value === 0 ) {
			$output .= sprintf(
				'<dt>%1$s</dt><dd>%2$s</dd>',
				__( 'Old value', 'simple-history' ),
				__( 'Your latest posts', 'simple-history' )
			);
		}

		return $output;
	}

	/**
	 * Get Event_Details_Group for page_on_front option.
	 *
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @return Event_Details_Group
	 */
	protected function get_details_group_for_option_page_on_front( $context, $old_value, $new_value, $option, $option_page ) {
		$group = new Event_Details_Group();

		$formatted_new = '';
		$formatted_old = '';

		if ( $new_value && ! empty( $context['new_post_title'] ) ) {
			$formatted_new = sprintf(
				/* translators: %s post title. */
				__( 'Page %s', 'simple-history' ),
				$context['new_post_title']
			);
		} elseif ( (int) $new_value === 0 ) {
			$formatted_new = __( 'Your latest posts', 'simple-history' );
		}

		if ( $old_value && ! empty( $context['old_post_title'] ) ) {
			$formatted_old = sprintf(
				/* translators: %s post title. */
				__( 'Page %s', 'simple-history' ),
				$context['old_post_title']
			);
		} elseif ( (int) $old_value === 0 ) {
			$formatted_old = __( 'Your latest posts', 'simple-history' );
		}

		if ( $formatted_new || $formatted_old ) {
			$item = new Event_Details_Item( null, $this->get_inline_diff_label( $option ) );
			if ( $formatted_new && $formatted_old ) {
				$item->set_values( $formatted_new, $formatted_old );
			} elseif ( $formatted_new ) {
				$item->set_new_value( $formatted_new );
			} else {
				$item->set_prev_value( $formatted_old );
			}
			$group->add_item( $item );
		}

		return $group;
	}

	/**
	 * Get Event_Details_Group for page_for_posts option.
	 *
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @return Event_Details_Group
	 */
	protected function get_details_group_for_option_page_for_posts( $context, $old_value, $new_value, $option, $option_page ) {
		return $this->get_details_group_for_option_page_on_front( $context, $old_value, $new_value, $option, $option_page );
	}

	/**
	 * Add context for option site_icon.
	 *
	 * Captures the icon URL and filename at log time so the event can still
	 * say what the icon was if the attachment is deleted later.
	 *
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @return array context
	 */
	protected function add_context_for_option_site_icon( $context, $old_value, $new_value, $option, $option_page ) {
		foreach ( [
			'old' => $old_value,
			'new' => $new_value,
		] as $prefix => $attachment_id ) {
			if ( empty( $attachment_id ) || ! is_numeric( $attachment_id ) ) {
				continue;
			}

			$url = wp_get_attachment_image_url( (int) $attachment_id, 'thumbnail' );

			if ( $url ) {
				$context[ "{$prefix}_site_icon_url" ] = $url;
			}

			$attached_file = get_attached_file( (int) $attachment_id );

			if ( ! $attached_file ) {
				continue;
			}

			$context[ "{$prefix}_site_icon_filename" ] = wp_basename( $attached_file );
		}

		return $context;
	}

	/**
	 * "default_category" = Writing Settings » Default Post Category
	 *
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 */
	protected function add_context_for_option_default_category( $context, $old_value, $new_value, $option, $option_page ) {
		if ( ! empty( $old_value ) && is_numeric( $old_value ) ) {
			$old_category_name = get_the_category_by_ID( $old_value );

			if ( ! is_wp_error( $old_category_name ) ) {
				$context['old_category_name'] = $old_category_name;
			}
		}

		if ( ! empty( $new_value ) && is_numeric( $new_value ) ) {
			$new_category_name = get_the_category_by_ID( $new_value );

			if ( ! is_wp_error( $new_category_name ) ) {
				$context['new_category_name'] = $new_category_name;
			}
		}

		return $context;
	}

	/**
	 * Add context for option default_category for default_email_category.
	 *
	 * @param array $context context.
	 * @param mixed $old_value old value.
	 * @param mixed $new_value new value.
	 * @param mixed $option option name.
	 * @param mixed $option_page option page name.
	 * @return array Updated context.
	 */
	protected function add_context_for_option_default_email_category( $context, $old_value, $new_value, $option, $option_page ) {
		return call_user_func_array( array( $this, 'add_context_for_option_default_category' ), func_get_args() );
	}

	/**
	 * Modify context for WPLANG option.
	 * If any value is empty then we set it to "en_US" because that is the default value.
	 *
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @return array Updated context.
	 */
	protected function add_context_for_option_wplang( $context, $old_value, $new_value, $option, $option_page ) {
		if ( empty( $old_value ) ) {
			$context['old_value'] = 'en_US';
		}

		if ( empty( $new_value ) ) {
			$context['new_value'] = 'en_US';
		}

		return $context;
	}

	/**
	 * Modify option mailserver_pass to remove the new and old value from the context,
	 * because we don't want to log the password.
	 *
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @return array Updated context.
	 */
	protected function add_context_for_option_mailserver_pass( $context, $old_value, $new_value, $option, $option_page ) {
		$context['old_value'] = '';
		$context['new_value'] = '';

		return $context;
	}

	/**
	 * Add detailed output for default_category
	 *
	 * @deprecated Use get_details_group_for_option_default_category() instead.
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @param string $tmpl_row template row.
	 * @return string output
	 */
	protected function get_details_output_for_option_default_category( $context, $old_value, $new_value, $option, $option_page, $tmpl_row ) {
		$old_category_name = $context['old_category_name'] ?? null;
		$new_category_name = $context['new_category_name'] ?? null;
		$output            = '';

		if ( $old_category_name ) {
			$output .= sprintf(
				$tmpl_row,
				__( 'Old value', 'simple-history' ),
				esc_html( $old_category_name )
			);
		}

		if ( $new_category_name ) {
			$output .= sprintf(
				$tmpl_row,
				__( 'New value', 'simple-history' ),
				esc_html( $new_category_name )
			);
		}

		return $output;
	}

	/**
	 * Get detailed output for default_category for default_email_category.
	 *
	 * @deprecated Use get_details_group_for_option_default_email_category() instead.
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @param string $tmpl_row template row.
	 * @return string output
	 */
	protected function get_details_output_for_option_default_email_category( $context, $old_value, $new_value, $option, $option_page, $tmpl_row ) {
		return call_user_func_array( array( $this, 'get_details_output_for_option_default_category' ), func_get_args() );
	}

	/**
	 * Get Event_Details_Group for site_icon option.
	 *
	 * Shows the previous and the new icon side by side in the same red/green
	 * diff table the post logger uses for featured images, instead of the
	 * attachment IDs WordPress stores.
	 *
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @return Event_Details_Group
	 */
	protected function get_details_group_for_option_site_icon( $context, $old_value, $new_value, $option, $option_page ) {
		$new = $this->get_site_icon_value( $new_value, $context['new_site_icon_url'] ?? '', $context['new_site_icon_filename'] ?? '' );
		$old = $this->get_site_icon_value( $old_value, $context['old_site_icon_url'] ?? '', $context['old_site_icon_filename'] ?? '' );

		$item = new Event_Details_Item( null, $this->get_inline_diff_label( $option ) );
		$item->set_values( $new['plain'], $old['plain'] );

		// A favicon is only ever seen at 16 to 64px, so keep the preview small too.
		$formatter = new Event_Details_Item_Image_Diff_Table_Row_Formatter();
		$formatter->set_new_image( $new['src'], $new['caption'] );
		$formatter->set_prev_image( $old['src'], $old['caption'] );
		$formatter->set_size( 'small' );

		$item->set_formatter( $formatter );

		$group = new Event_Details_Group();
		$group->add_item( $item );

		return $group;
	}

	/**
	 * Resolve one site icon value to an image URL, a caption, and a plain
	 * text representation for JSON.
	 *
	 * @param mixed  $attachment_id Attachment ID, or 0 for no icon.
	 * @param string $captured_url  Icon URL captured when the event was logged.
	 * @param string $filename      Icon filename captured when the event was logged.
	 * @return array{src: string, caption: string, plain: string}
	 */
	private function get_site_icon_value( $attachment_id, $captured_url, $filename ) {
		$attachment_id = (int) $attachment_id;

		// A value of 0 means no site icon. Empty src and caption make the
		// formatter print "None".
		if ( $attachment_id === 0 ) {
			return [
				'src'     => '',
				'caption' => '',
				'plain'   => __( 'None', 'simple-history' ),
			];
		}

		$image_src = wp_get_attachment_image_src( $attachment_id, 'thumbnail' );

		if ( $image_src ) {
			$current_filename = wp_basename( (string) get_attached_file( $attachment_id ) );

			return [
				'src'     => $image_src[0],
				'caption' => $current_filename !== '' ? $current_filename : $filename,
				'plain'   => $image_src[0],
			];
		}

		// Attachment is gone. Fall back to what was captured at log time.
		if ( $filename ) {
			$caption = sprintf(
				/* translators: 1: image filename, 2: attachment ID. */
				__( '%1$s (ID %2$d)', 'simple-history' ),
				$filename,
				$attachment_id
			);
		} else {
			$caption = (string) $attachment_id;
		}

		return [
			'src'     => '',
			'caption' => $caption,
			'plain'   => $captured_url ? $captured_url : $caption,
		];
	}

	/**
	 * Get Event_Details_Group for default_category option.
	 *
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @return Event_Details_Group|null
	 */
	protected function get_details_group_for_option_default_category( $context, $old_value, $new_value, $option, $option_page ) {
		$old_category_name = $context['old_category_name'] ?? null;
		$new_category_name = $context['new_category_name'] ?? null;

		if ( ! $old_category_name && ! $new_category_name ) {
			return null;
		}

		$group = new Event_Details_Group();
		$item  = new Event_Details_Item( null, $this->get_inline_diff_label( $option ) );

		if ( $new_category_name && $old_category_name ) {
			$item->set_values( $new_category_name, $old_category_name );
		} elseif ( $new_category_name ) {
			$item->set_new_value( $new_category_name );
		} else {
			$item->set_prev_value( $old_category_name );
		}

		$group->add_item( $item );
		return $group;
	}

	/**
	 * Get Event_Details_Group for default_email_category option.
	 *
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @return Event_Details_Group|null
	 */
	protected function get_details_group_for_option_default_email_category( $context, $old_value, $new_value, $option, $option_page ) {
		return $this->get_details_group_for_option_default_category( $context, $old_value, $new_value, $option, $option_page );
	}

	/**
	 * Get Event_Details_Group for start_of_week option.
	 *
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @return Event_Details_Group|null
	 */
	protected function get_details_group_for_option_start_of_week( $context, $old_value, $new_value, $option, $option_page ) {
		/** @var \WP_Locale Logger slug */
		global $wp_locale;

		if ( ! ( $wp_locale instanceof \WP_Locale ) ) {
			return null;
		}

		$group = new Event_Details_Group();
		$group->add_item(
			( new Event_Details_Item( null, $this->get_inline_diff_label( $option ) ) )
				->set_values(
					$wp_locale->get_weekday( $new_value ),
					$wp_locale->get_weekday( $old_value )
				)
		);

		return $group;
	}

	/**
	 * Get Event_Details_Group for rss_use_excerpt option.
	 *
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @return Event_Details_Group
	 */
	protected function get_details_group_for_option_rss_use_excerpt( $context, $old_value, $new_value, $option, $option_page ) {
		// 0 full text, 1 excerpt.
		// phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- Value may be string '0' or int 0 from database.
		if ( $old_value == 0 ) {
			$old_display = __( 'Full text', 'simple-history' );
			$new_display = __( 'Excerpt', 'simple-history' );
		} else {
			$old_display = __( 'Excerpt', 'simple-history' );
			$new_display = __( 'Full text', 'simple-history' );
		}

		$group = new Event_Details_Group();
		$group->add_item(
			( new Event_Details_Item( null, $this->get_inline_diff_label( $option ) ) )
				->set_values( $new_display, $old_display )
		);

		return $group;
	}

	/**
	 * Get detailed output for start_of_week option.
	 *
	 * @deprecated Use get_details_group_for_option_start_of_week() instead.
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @param string $tmpl_row template row.
	 */
	protected function get_details_output_for_option_start_of_week( $context, $old_value, $new_value, $option, $option_page, $tmpl_row ) {
		/** @var \WP_Locale Logger slug */
		global $wp_locale;

		if ( ! ( $wp_locale instanceof \WP_Locale ) ) {
			return '';
		}

		$output = '';

		$prev_weekday_name = $wp_locale->get_weekday( $old_value );
		$new_weekday_name  = $wp_locale->get_weekday( $new_value );

		$output .= sprintf(
			$tmpl_row,
			__( 'New value', 'simple-history' ),
			esc_html( $new_weekday_name )
		);

		$output .= sprintf(
			$tmpl_row,
			__( 'Old value', 'simple-history' ),
			esc_html( $prev_weekday_name )
		);

		return $output;
	}

	/**
	 * Get detailed output for rss_use_excerpt option.
	 *
	 * @deprecated Use get_details_group_for_option_rss_use_excerpt() instead.
	 * @param array  $context context.
	 * @param mixed  $old_value old value.
	 * @param mixed  $new_value new value.
	 * @param string $option option name.
	 * @param string $option_page option page name.
	 * @param string $tmpl_row template row.
	 */
	protected function get_details_output_for_option_rss_use_excerpt( $context, $old_value, $new_value, $option, $option_page, $tmpl_row ) {
		$output = '';

		// 0 full text, 1 excerpt.
		// phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- Value may be string '0' or int 0 from database.
		if ( $old_value == 0 ) {
			$old_value = __( 'Full text', 'simple-history' );
			$new_value = __( 'Excerpt', 'simple-history' );
		} else {
			$old_value = __( 'Excerpt', 'simple-history' );
			$new_value = __( 'Full text', 'simple-history' );
		}

		$output .= sprintf(
			$tmpl_row,
			__( 'New value', 'simple-history' ),
			esc_html( $new_value )
		);

		$output .= sprintf(
			$tmpl_row,
			__( 'Old value', 'simple-history' ),
			esc_html( $old_value )
		);

		return $output;
	}

	/**
	 * Reverse-map a built-in option name to the slug of the settings page
	 * it belongs to. Used when no admin form posts `option_page` (REST and
	 * WP-CLI paths).
	 *
	 * @param string $option_name Option name.
	 * @return string|null Page slug (e.g. 'general', 'permalinks'), or null
	 *                     if the option isn't tracked.
	 */
	protected function get_option_page_for_option_name( $option_name ) {
		foreach ( $this->get_wordpress_built_in_options() as $page_slug => $page_info ) {
			if ( array_key_exists( $option_name, $page_info['options'] ) ) {
				return $page_slug;
			}
		}

		return null;
	}

	/**
	 * Get all keys for built in WordPress options.
	 *
	 * @return array
	 */
	protected function get_wordpress_options_keys() {
		$keys = [];

		foreach ( $this->get_wordpress_built_in_options() as $options_page ) {
			foreach ( $options_page['options'] as $option_name => $option_info ) {
				$keys[] = $option_name;
			}
		}

		return $keys;
	}

	/**
	 * Get option information array.
	 *
	 * @param string $option_name Option name.
	 * @return array|false Option info if found or false if not found.
	 */
	protected function get_option_info( $option_name ) {
		$all_options = $this->get_wordpress_built_in_options();

		// Check for option in all option groups.
		foreach ( $all_options as $option_group_info ) {
			$option_group = $option_group_info['options'];
			if ( array_key_exists( $option_name, $option_group_info['options'] ) ) {
				return $option_group[ $option_name ];
			}
		}

		return false;
	}

	/**
	 * Return the user-friendly label to use as the inline diff title for
	 * an option event — the option's translated name (e.g. "Site Title",
	 * "Tagline", "Update Services"). Falls back to a generic "Value" label
	 * for options without a translation entry.
	 *
	 * @param string $option Option name.
	 * @return string Translated option name, or "Value" fallback.
	 */
	protected function get_inline_diff_label( $option ) {
		$info = $this->get_option_info( $option );

		if ( is_array( $info ) && ! empty( $info['translation'] ) ) {
			return $info['translation'];
		}

		return __( 'Value', 'simple-history' );
	}

	/**
	 * Get option page information array.
	 *
	 * Accepts the singular 'permalink' slug (as stored in context) and
	 * maps it back to the 'permalinks' key used internally so callers
	 * get the translated page label and not just the raw slug.
	 *
	 * @param string $option_page Option page name.
	 * @return array|false Option page info if found or false if not found.
	 */
	protected function get_option_page_info( $option_page ) {
		// Normalize singular admin URL slug to the plural mapping key.
		if ( $option_page === 'permalink' ) {
			$option_page = 'permalinks';
		}

		$all_options = $this->get_wordpress_built_in_options();

		// Check for option in all option groups.
		foreach ( $all_options as $option_group_name => $option_group_info ) {
			if ( $option_group_name === $option_page ) {
				return $option_group_info;
			}
		}

		return false;
	}

	/**
	 * Get Event_Details_Group for options with type (onoff, reversed_onoff).
	 *
	 * @param string $option_name Option name.
	 * @param mixed  $new_value New value.
	 * @param mixed  $old_value Old value.
	 * @return Event_Details_Group|null Group or null if no type match.
	 */
	protected function get_group_for_option_with_type( $option_name, $new_value, $old_value ) {
		$option_info = $this->get_option_info( $option_name );
		$option_type = $option_info['type'] ?? '';

		if ( ! $option_type ) {
			return null;
		}

		switch ( $option_type ) {
			case 'onoff':
			case 'reversed_onoff':
				if ( $option_type === 'onoff' ) {
					$true_value  = __( 'On', 'simple-history' );
					$false_value = __( 'Off', 'simple-history' );
				} else {
					$true_value  = __( 'Off', 'simple-history' );
					$false_value = __( 'On', 'simple-history' );
				}

				$old_display = $old_value ? $true_value : $false_value;
				$new_display = $new_value ? $true_value : $false_value;

				$group = new Event_Details_Group();
				$group->add_item(
					( new Event_Details_Item( null, $this->get_inline_diff_label( $option_name ) ) )
						->set_values( $new_display, $old_display )
				);
				return $group;
		}

		return null;
	}

	/**
	 * Many options store values as 0 or 1, but we want to show them as for example "yes" or "no", or "Full text" or "Excerpt".
	 * 'type' => 'onoff', = Show 0 as "Off" and 1 as "On".
	 *
	 * @deprecated Use get_group_for_option_with_type() instead.
	 * @param string $option_name Option name.
	 * @param mixed  $new_value New value.
	 * @param mixed  $old_value Old value.
	 * @param string $option_custom_output Custom output.
	 * @param string $tmpl_row Template row.
	 * @return string Custom output.
	 */
	protected function get_output_for_option_with_type_option( $option_name, $new_value, $old_value, $option_custom_output, $tmpl_row ) {
		$option_info = $this->get_option_info( $option_name );
		$option_type = $option_info['type'] ?? '';

		if ( ! $option_type ) {
			return '';
		}

		$option_custom_output = '';

		switch ( $option_type ) {
			case 'onoff':
			case 'reversed_onoff':
				$true_value  = '';
				$false_value = '';

				if ( $option_type === 'onoff' ) {
					// 1 is on, 0 is off.
					$true_value  = __( 'On', 'simple-history' );
					$false_value = __( 'Off', 'simple-history' );
				} elseif ( $option_type === 'reversed_onoff' ) {
					// 1 is off, 0 is on.
					// Used on for example "blog_public".
					$true_value  = __( 'Off', 'simple-history' );
					$false_value = __( 'On', 'simple-history' );
				}

				$old_value = $old_value ? $true_value : $false_value;
				$new_value = $new_value ? $true_value : $false_value;

				$option_custom_output = sprintf(
					$tmpl_row,
					__( 'New value', 'simple-history' ),
					esc_html( $new_value )
				);

				$option_custom_output .= sprintf(
					$tmpl_row,
					__( 'Old value', 'simple-history' ),
					esc_html( $old_value )
				);

				break;
		}

		return $option_custom_output;
	}
}