✏️ 编辑:class-post-logger.php
路径:
/home/forge/kingkrunch.com/wp/wp-content/plugins/simple-history/loggers/class-post-logger.php
大小:112.6 KB · 修改:2026-09-25 00:42:29 · 权限:0644 · 可写
← 返回目录
👁 查看
⬇ 下载
<?php namespace Simple_History\Loggers; use Simple_History\Event_Details\Event_Details_Container; use Simple_History\Event_Details\Event_Details_Group; use Simple_History\Event_Details\Event_Details_Group_Diff_Table_Formatter; use Simple_History\Event_Details\Event_Details_Item; use Simple_History\Event_Details\Event_Details_Item_Image_Diff_Table_Row_Formatter; use Simple_History\Event_Details\Event_Details_Item_Table_Row_RAW_Formatter; use Simple_History\Helpers; use Simple_History\Vendor\Jfcherng\Diff\DiffHelper; /** * Logs changes to posts and pages, including custom post types. */ class Post_Logger extends Logger { /** @var string Logger slug */ public $slug = 'SimplePostLogger'; /** * How many custom field names to store per bucket in the context. * * The count stays the truth; the names are a sample, capped so a page builder * save touching hundreds of meta rows does not bloat the context table. * * @var int */ const MAX_META_KEYS_IN_CONTEXT = 20; /** * Array that will contain previous post data, before data is updated. * * Array format is * [post_id] => [post_data, post_meta]. * post_data = WP_Post object, post_meta = post meta array. * * @var array<int, array> */ protected $old_post_data = []; /** * Revision ids created during this request, keyed by post id. * * Holds a revision until there is an event to put it on. Only used by the * saves that log after the revision exists — see on_wp_put_post_revision() * for which those are. * * @var array<int, int> */ protected $post_revision_ids = []; /** * Event ids that have already been given a revision id. * * An event takes at most one. Without this, a later save that Simple History * declines to log still produces a revision, and since last_insert_id still * points at the previous event that revision would be appended to it as a * second value. * * @var array<int, bool> */ protected $events_given_a_revision_id = []; /** * Posts and revisions already looked up while rendering this request, * including the ones that turned out not to exist. * * @var array<int, \WP_Post|null> */ private $looked_up_posts = []; /** * "Before" snapshot for a meta-box-loader request, taken on * admin_action_editpost, before edit_post() writes the submitted meta. * * Keyed by post id. Only populated for requests with $_GET['meta-box-loader'] * set — classic editor single-request saves already log from * on_transition_post_status() and don't need this. * * @var array<int, array{user_id: int, post_modified_gmt: string, post_meta: array}> */ protected $meta_box_loader_snapshots = []; /** * Context handed over by a meta box's own save logic (e.g. Plugin_ACF_Logger) * for the post currently being saved, to merge into the same append/new-event * decision as the core meta diff in on_wp_after_insert_post_meta_box_loader(). * * Keyed by post id. * * @var array<int, array> */ protected $meta_box_save_context = []; /** * Get array with information about this logger. * * @return array */ public function get_info() { return [ 'name' => __( 'Post Logger', 'simple-history' ), 'description' => __( 'Logs the creation and modification of posts and pages', 'simple-history' ), 'capability' => 'edit_pages', 'messages' => array( 'post_created' => __( 'Created {post_type} "{post_title}"', 'simple-history' ), 'post_updated' => __( 'Updated {post_type} "{post_title}"', 'simple-history' ), 'post_restored' => __( 'Restored {post_type} "{post_title}" from trash', 'simple-history' ), 'post_deleted' => __( 'Deleted {post_type} "{post_title}"', 'simple-history' ), 'post_trashed' => __( 'Moved {post_type} "{post_title}" to the trash', 'simple-history' ), 'page_set_as_homepage' => __( 'Set {post_type} "{post_title}" as the homepage', 'simple-history' ), 'page_removed_as_homepage' => __( 'Removed {post_type} "{post_title}" as the homepage', 'simple-history' ), 'page_set_as_posts_page' => __( 'Set {post_type} "{post_title}" as the posts page', 'simple-history' ), 'page_removed_as_posts_page' => __( 'Removed {post_type} "{post_title}" as the posts page', 'simple-history' ), ), 'labels' => array( 'search' => array( 'label' => _x( 'Posts & Pages', 'Post logger: search', 'simple-history' ), 'label_all' => _x( 'All posts & pages activity', 'Post logger: search', 'simple-history' ), 'options' => array( _x( 'Posts created', 'Post logger: search', 'simple-history' ) => array( 'post_created' ), _x( 'Posts updated', 'Post logger: search', 'simple-history' ) => array( 'post_updated' ), _x( 'Posts trashed', 'Post logger: search', 'simple-history' ) => array( 'post_trashed' ), _x( 'Posts deleted', 'Post logger: search', 'simple-history' ) => array( 'post_deleted' ), _x( 'Posts restored', 'Post logger: search', 'simple-history' ) => array( 'post_restored' ), _x( 'Pages set as homepage or posts page', 'Post logger: search', 'simple-history' ) => array( 'page_set_as_homepage', 'page_removed_as_homepage', 'page_set_as_posts_page', 'page_removed_as_posts_page', ), ), ), ), ]; } /** * @inheritdoc */ public function loaded() { // Save old/prev post values before post is updated. add_action( 'admin_action_editpost', array( $this, 'on_admin_action_editpost_save_prev_post' ) ); // Run quick edit changes old post save with prio 0 to run before WordPress core does its thing, which is at prio 1. add_action( 'wp_ajax_inline-save', array( $this, 'on_admin_action_editpost_save_prev_post' ), 0 ); // Save prev post for bulk edit. // Bulk edit does not use ajax (like quick edit does). Instead it's a regular GET request to edit.php. // wp function bulk_edit_posts() takes care of making changes. add_action( 'admin_action_edit', array( $this, 'on_admin_action_edit_save_prev_post' ) ); // Detect regular post edits. add_action( 'transition_post_status', array( $this, 'on_transition_post_status' ), 10, 3 ); // Detect posts changing status from future to publish. add_action( 'transition_post_status', array( $this, 'on_transition_post_status_future' ), 10, 3 ); add_action( 'delete_post', array( $this, 'on_delete_post' ) ); add_action( 'untrash_post', array( $this, 'on_untrash_post' ) ); $this->add_xml_rpc_hooks(); // Add rest hooks late to increase chance of getting all registered post types. add_action( 'init', array( $this, 'add_rest_hooks' ), 99 ); // WP-CLI post update path. on_transition_post_status bails for WP-CLI to avoid // double-logging; these two hooks handle the prev/new snapshot + log instead. add_action( 'pre_post_update', array( $this, 'on_pre_post_update' ), 10, 1 ); add_action( 'wp_after_insert_post', array( $this, 'on_wp_after_insert_post' ), 10, 4 ); // Block editor meta-box-loader path. maybe_log_post_change() deliberately // bails for this request (see the meta-box-loader check there) so the // REST API save isn't logged twice. These two hooks snapshot before/after // so the custom field changes it makes can still be attached to (or, if // nothing matches, logged as) a post_updated event. add_action( 'admin_action_editpost', array( $this, 'on_admin_action_editpost_meta_box_loader' ) ); add_action( 'wp_after_insert_post', array( $this, 'on_wp_after_insert_post_meta_box_loader' ), 10, 4 ); add_action( 'update_option_page_on_front', array( $this, 'on_update_option_page_on_front' ), 10, 2 ); add_action( 'update_option_page_for_posts', array( $this, 'on_update_option_page_for_posts' ), 10, 2 ); add_filter( 'simple_history/rss_item_link', array( $this, 'filter_rss_item_link' ), 10, 2 ); // Fires whenever core stores a revision. Where that lands relative to this // logger's own event depends on how the post was saved — see the handler. add_action( '_wp_put_post_revision', array( $this, 'on_wp_put_post_revision' ), 1, 2 ); } /** * Fired when a post is saved using save button and does have changes. * Does not track autosave. * This is done after simple history has logged the post change. * So we need to update the context with the revision id. * * @param int $revision_id The revision ID. * @param int|null $post_id The post ID. Only passed by WordPress 6.4 and later. */ public function on_wp_put_post_revision( $revision_id, $post_id = null ) { // WordPress only started passing the post id with this action in 6.4, and // the plugin supports 6.3 — requiring an argument core does not send is a // fatal ArgumentCountError under PHP 8, on every post save that creates a // revision. Fall back to the revision's parent, which is what 6.4 passes. if ( $post_id === null ) { $post_id = wp_get_post_parent_id( $revision_id ); } if ( ! $post_id ) { return; } $post_id = (int) $post_id; // Which side of the event this fires on depends on how the post was // saved, so both orderings have to work. // // The classic editor and Quick Edit log from transition_post_status, // which core fires from wp_insert_post() before the revision is saved — // on 6.4+ from wp_after_insert_post priority 9, and on 6.3 from // post_updated. The version differs, the ordering does not: the event // already exists, so the id is attached to it. // // Gutenberg and WP-CLI log after the revision, from rest_after_insert_* // and wp_after_insert_post priority 10. There is nothing to attach to // yet, so the id is held for maybe_log_post_change() to pick up when it // builds the context. $logged_event_is_for_this_post = $this->last_insert_id && (int) ( $this->last_insert_context['post_id'] ?? 0 ) === $post_id; if ( $logged_event_is_for_this_post ) { // One per event. A later save that is not logged — an ignored post // type, an ok_to_log filter, the meta-box-loader bail — still makes a // revision, and last_insert_id would still be pointing here. if ( isset( $this->events_given_a_revision_id[ $this->last_insert_id ] ) ) { return; } $this->append_context( $this->last_insert_id, [ 'post_revision_id' => $revision_id, ] ); $this->events_given_a_revision_id[ $this->last_insert_id ] = true; return; } $this->post_revision_ids[ $post_id ] = (int) $revision_id; } /** * Add hooks to catch updates via REST API, i.e. from the Gutenberg editor. */ public function add_rest_hooks() { /** * Filter the post types we are logging information from. * * @param array $post_types Core, public and private post types. * @return array $post_types Filtered post types. * * @since 2.37 */ $post_types = apply_filters( 'simple_history/post_logger/post_types', get_post_types( array(), 'object' ) ); // Add actions for each post type. foreach ( $post_types as $post_type ) { // class-wp-rest-posts-controller.php fires two actions in // the update_item() method: pre_insert and after_insert. // Rest pre insert is fired before an updated post is inserted into db. add_filter( "rest_pre_insert_{$post_type->name}", array( $this, 'on_rest_pre_insert' ), 10, 2 ); // Rest insert happens after the post has been updated: "Fires after a single post is completely created or updated via the REST API.". add_action( "rest_after_insert_{$post_type->name}", array( $this, 'on_rest_after_insert' ), 10, 3 ); // Rest delete is fired "immediately after a single post is deleted or trashed via the REST API". add_action( "rest_delete_{$post_type->name}", array( $this, 'on_rest_delete' ), 10, 3 ); } } /** * Fired after a single post is deleted or trashed via the REST API. * * @param \WP_Post $post The deleted or trashed post. * @param \WP_REST_Response $response The response data. * @param \WP_REST_Request $request The request sent to the API. */ public function on_rest_delete( $post, $response, $request ) { if ( ! $post instanceof \WP_Post ) { return; } if ( ! $this->ok_to_log_post_posttype( $post ) ) { return; } $this->info_message( 'post_trashed', [ 'post_id' => $post->ID, 'post_type' => $post->post_type, 'post_title' => $post->post_title, ] ); } /** * Filter "rest_pre_insert_{$this->post_type}" filters a post before it is inserted via the REST API. * Fired from class-wp-rest-posts-controller.php. * * Here we can get the old post object. * * @param \stdClass $prepared_post An object representing a single post prepared * for inserting or updating the database, i.e. the new updated post. * @param \WP_REST_Request $request Request object. * @return \stdClass $prepared_post */ public function on_rest_pre_insert( $prepared_post, $request ) { // $prepared_post = stdClass Object with new and modified content. // changes are not saved to post in db yet, so get_post( $prepared_post->ID ) will get old contents. // Not all posts have ID, for example attachment uploaded in block editor does not. if ( empty( $prepared_post->ID ) ) { return $prepared_post; } $this->save_prev_post_data( $prepared_post->ID ); return $prepared_post; } /** * Fires after a single post is completely created or updated via the REST API. * * This is fired when a post is saved: * - Using the Gutenberg block editor * - ...possible more times... * * Here we get the updated post, after it is updated in the db. * * @param \WP_Post $updated_post Inserted or updated post object. * @param \WP_REST_Request $request Request object. * @param bool $creating True when creating a post, false when updating. */ public function on_rest_after_insert( $updated_post, $request, $creating ) { $updated_post = get_post( $updated_post->ID ); $post_meta = get_post_custom( $updated_post->ID ); $old_post = $this->old_post_data[ $updated_post->ID ]['post_data'] ?? null; $old_post_meta = $this->old_post_data[ $updated_post->ID ]['post_meta'] ?? null; $old_post_terms = $this->old_post_data[ $updated_post->ID ]['post_terms'] ?? null; // If WordPress says this is a new post being created, and we don't have old post data, // assume it was transitioning from auto-draft status. // This ensures post creation is properly detected even when old data wasn't captured. $old_status = $old_post ? $old_post->post_status : null; if ( $creating && ! $old_status ) { $old_status = 'auto-draft'; } $args = array( 'new_post' => $updated_post, 'new_post_meta' => $post_meta, 'new_post_terms' => wp_get_object_terms( $updated_post->ID, get_object_taxonomies( $updated_post->post_type ) ), 'old_post' => $old_post, 'old_post_meta' => $old_post_meta, 'old_post_terms' => $old_post_terms, 'old_status' => $old_status, ); $this->maybe_log_post_change( $args ); } /** * Filters to XML RPC calls needs to be added early, admin_init is to late. */ public function add_xml_rpc_hooks() { add_action( 'xmlrpc_call_success_blogger_newPost', array( $this, 'on_xmlrpc_newPost' ), 10, 2 ); add_action( 'xmlrpc_call_success_mw_newPost', array( $this, 'on_xmlrpc_newPost' ), 10, 2 ); add_action( 'xmlrpc_call_success_blogger_editPost', array( $this, 'on_xmlrpc_editPost' ), 10, 2 ); add_action( 'xmlrpc_call_success_mw_editPost', array( $this, 'on_xmlrpc_editPost' ), 10, 2 ); add_action( 'xmlrpc_call_success_blogger_deletePost', array( $this, 'on_xmlrpc_deletePost' ), 10, 2 ); add_action( 'xmlrpc_call_success_wp_deletePage', array( $this, 'on_xmlrpc_deletePost' ), 10, 2 ); add_action( 'xmlrpc_call', array( $this, 'on_xmlrpc_call' ), 10, 1 ); } /** * Detect when a post is deleted using a XML-RPC call. * Fired from action "xmlrpc_call". * * @param string $method Method called. */ public function on_xmlrpc_call( $method ) { if ( $method !== 'wp.deletePost' ) { return; } // Parse the XML-RPC body to extract the post id from the method params. // Do not store the raw body or parsed message in the log: wp.deletePost // params are [blog_id, username, password, post_id], so persisting them // would leak the caller's credentials. // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsRemoteFile $raw_post_data = file_get_contents( 'php://input' ); $message = new \IXR_Message( $raw_post_data ); if ( ! $message->parse() ) { return; } // 4 params, where the last is the post id. if ( ! isset( $message->params[3] ) ) { return; } $post_ID = $message->params[3]; $post = get_post( $post_ID ); $context = array( 'post_id' => $post->ID, 'post_type' => get_post_type( $post ), 'post_title' => get_the_title( $post ), ); $this->info_message( 'post_trashed', $context ); } /** * Fired when posts are saved in the admin area using bulk edit. */ public function on_admin_action_edit_save_prev_post() { global $pagenow; if ( $pagenow !== 'edit.php' ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $post_ids = array_map( 'intval', (array) ( $_GET['post'] ?? [] ) ); foreach ( $post_ids as $one_post_id ) { $this->save_prev_post_data( $one_post_id ); } } /** * Save old info about a post that is going to be edited. * Needed to later compare old data with new data, to detect differences. * * @param mixed $post_ID Post ID. */ protected function save_prev_post_data( $post_ID ) { $prev_post_data = get_post( $post_ID ); if ( ! $prev_post_data instanceof \WP_Post ) { return; } $this->old_post_data[ $post_ID ] = [ 'post_data' => $prev_post_data, 'post_meta' => get_post_custom( $post_ID ), 'post_terms' => wp_get_object_terms( $post_ID, get_object_taxonomies( $prev_post_data->post_type ) ), ]; } /** * Capture prev post state for WP-CLI updates. * * Admin path captures prev state in on_admin_action_editpost_save_prev_post() * — pre_post_update would see new values there because custom fields are * written via a separate AJAX call before the form submit. * * @param int $post_ID The post ID being updated. */ public function on_pre_post_update( $post_ID ) { if ( is_admin() ) { return; } if ( ! Helpers::is_wp_cli() ) { return; } $this->save_prev_post_data( $post_ID ); } /** * Log a post create or update made via WP-CLI. Mirrors on_rest_after_insert. * * @param int $post_id ID of the saved post. * @param \WP_Post $post The saved post object. * @param bool $update True when updating, false when creating. * @param \WP_Post|null $post_before The post before the update, or null for new posts. */ public function on_wp_after_insert_post( $post_id, $post, $update, $post_before ) { if ( ! Helpers::is_wp_cli() ) { return; } if ( ! $post instanceof \WP_Post ) { return; } if ( ! $this->ok_to_log_post_posttype( $post ) ) { return; } if ( $update ) { $old_post = $this->old_post_data[ $post->ID ]['post_data'] ?? null; $old_post_meta = $this->old_post_data[ $post->ID ]['post_meta'] ?? null; $old_post_terms = $this->old_post_data[ $post->ID ]['post_terms'] ?? null; $old_status = $old_post ? $old_post->post_status : null; } else { $old_post = null; $old_post_meta = null; $old_post_terms = null; // 'new' is WordPress's status transition placeholder when no prior post exists. $old_status = 'new'; } $args = array( 'new_post' => $post, 'new_post_meta' => get_post_custom( $post->ID ), 'new_post_terms' => wp_get_object_terms( $post->ID, get_object_taxonomies( $post->post_type ) ), 'old_post' => $old_post, 'old_post_meta' => $old_post_meta, 'old_post_terms' => $old_post_terms, 'old_status' => $old_status, ); $this->maybe_log_post_change( $args ); } /** * Get and store old info about a post that is going to be edited. * Needed to later compare old data with new data, to detect differences. * This function is called on edit screen but before post edits are saved. * * Can't use the regular filters like "pre_post_update" because custom fields are already written by then * when editing via the classic admin form (custom fields are saved via AJAX before the form submit). * * This function is not fired when using the block editor — REST API hooks are used instead. * * @since 2.0.29 */ public function on_admin_action_editpost_save_prev_post() { // phpcs:ignore WordPress.Security.NonceVerification.Missing $post_ID = isset( $_POST['post_ID'] ) ? (int) $_POST['post_ID'] : 0; if ( $post_ID === 0 ) { return; } if ( ! current_user_can( 'edit_post', $post_ID ) ) { return; } $this->save_prev_post_data( $post_ID ); } /** * Snapshot the "before" state for a block editor meta-box-loader request, * before edit_post() writes the meta box values that were submitted. * * Only meta-box-loader requests are handled here — the classic editor's * single-request save already logs everything it needs from * on_transition_post_status(), and on_admin_action_editpost_save_prev_post() * already covers its "before" snapshot. * * The recorded post_modified_gmt is still the value the earlier REST API * save (request 2 of the block editor's Update) wrote — see the * post_modified_gmt context key added in maybe_log_post_change() — which * is what on_wp_after_insert_post_meta_box_loader() uses to find that * event again. * * @since 5.34.0 */ public function on_admin_action_editpost_meta_box_loader() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( empty( $_GET['meta-box-loader'] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing $post_id = isset( $_POST['post_ID'] ) ? (int) $_POST['post_ID'] : 0; if ( $post_id === 0 ) { return; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return; } $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return; } // Cheap early exit — no point snapshotting meta for a post type this // logger will never log anyway. See maybe_log_post_change(), which // applies the same check for every other save path. if ( ! $this->ok_to_log_post_posttype( $post ) ) { return; } $this->meta_box_loader_snapshots[ $post_id ] = [ 'user_id' => get_current_user_id(), 'post_modified_gmt' => $post->post_modified_gmt, 'post_meta' => get_post_custom( $post_id ), ]; } /** * Finish a block editor meta-box-loader request: diff the meta snapshot * taken on admin_action_editpost against the meta as it is now, merge in * any context a meta box handed over via add_meta_box_save_context(), and * either attach the result to the post_updated event the earlier REST API * save logged, or — if that event can't be found — log a new one. * * Runs on wp_after_insert_post so it sees ACF's and other meta boxes' * saves, which happen on save_post (fired earlier in edit_post(), from * inside wp_update_post()). * * @since 5.34.0 * * @param int $post_id Post ID. * @param \WP_Post $post Post object. * @param bool $update Whether this is an existing post being updated. * @param \WP_Post|null $post_before Post object before the update. */ public function on_wp_after_insert_post_meta_box_loader( $post_id, $post, $update, $post_before ) { if ( ! isset( $this->meta_box_loader_snapshots[ $post_id ] ) ) { return; } $snapshot = $this->meta_box_loader_snapshots[ $post_id ]; unset( $this->meta_box_loader_snapshots[ $post_id ] ); $new_meta = get_post_custom( $post_id ); $context = $this->add_post_meta_diff_to_context( [], $snapshot['post_meta'], $new_meta ); // Merge in context a meta box handed over via add_meta_box_save_context(), // e.g. Plugin_ACF_Logger's own richer, labelled diff of its fields. if ( isset( $this->meta_box_save_context[ $post_id ] ) ) { $context = array_merge( $context, $this->meta_box_save_context[ $post_id ] ); unset( $this->meta_box_save_context[ $post_id ] ); } // Nothing changed (or everything that changed is ignored) — do nothing. if ( empty( $context ) ) { return; } $matched_event_id = $this->find_matching_post_updated_event_id( $post_id, $snapshot['post_modified_gmt'], $snapshot['user_id'] ); if ( $matched_event_id ) { // The matched event may already have rows for some of these keys — // e.g. post_meta_changed_keys, if the earlier REST API save also // changed custom fields (footnotes, saved through the block // editor's own REST call, in addition to color here). append_context() // would add a second row for the same key, and only one of the two // survives on read. Merge the meta buckets and replace existing // rows instead of duplicating them. $existing_values = $this->get_existing_context_values( $matched_event_id, array_keys( $context ) ); $context = $this->merge_post_meta_buckets( $context, $existing_values ); $this->replace_context( $matched_event_id, $context, array_keys( $existing_values ) ); return; } // No matching event — e.g. only meta changed here and the earlier // REST API save had nothing to log, or another save happened in // between. Log a new post_updated event with just the meta changes. // // This is the same gating maybe_log_post_change() applies before // logging a post_updated event: the post type filter, and the // ok_to_log/context filters. There is no status transition here — a // meta-box-loader-only save only changes custom fields — so // $new_status and $old_status are both the post's current status. if ( ! $this->ok_to_log_post_posttype( $post ) ) { return; } $context['post_id'] = $post_id; $context['post_type'] = get_post_type( $post ); $context['post_title'] = get_the_title( $post ); $context['post_modified_gmt'] = $post->post_modified_gmt; /** * Filter to control logging. Same filter maybe_log_post_change() applies. * * @param bool $ok_to_log * @param string|null $new_status * @param string|null $old_status * @param \WP_Post $post * * @return bool True to log, false to not log. */ $ok_to_log = apply_filters( 'simple_history/post_logger/post_updated/ok_to_log', true, $post->post_status, $post->post_status, $post ); if ( ! $ok_to_log ) { return; } /** * Modify the context saved. Same filter maybe_log_post_change() applies. * * @param array $context * @param \WP_Post $post */ $context = apply_filters( 'simple_history/post_logger/post_updated/context', $context, $post ); $this->info_message( 'post_updated', $context ); } /** * Accept context computed by a meta box's own save logic — e.g. ACF's * field diff — to attach to the post_updated event for $post_id. * * Called from Plugin_ACF_Logger::on_acf_save_post() so the ACF logger no * longer has to know whether it is appending to an event already logged * in this request, or one that will be logged (or found) later. Two * callers, both handled here: * * - Classic editor / single-request saves have already logged the * post_updated event for $post_id by the time save_post fires (this is * $this->last_insert_id), so the context is appended immediately — * same as before this method existed. * - The block editor's meta-box-loader request logs (or appends to) its * event later, from on_wp_after_insert_post_meta_box_loader(), so the * context is held until then. * * @since 5.34.0 * * @param int $post_id Post the context belongs to. * @param array $context Context to attach. */ public function add_meta_box_save_context( $post_id, array $context ) { if ( empty( $context ) ) { return; } $post_id = (int) $post_id; $already_logged_this_request = $this->last_insert_id && (int) ( $this->last_insert_context['post_id'] ?? 0 ) === $post_id; if ( $already_logged_this_request ) { $this->append_context( $this->last_insert_id, $context ); return; } $this->meta_box_save_context[ $post_id ] = array_merge( $this->meta_box_save_context[ $post_id ] ?? [], $context ); } /** * Find the most recent post_updated event for a post, logged for a given * user, at a given post_modified_gmt. * * Used to attach a meta-box-loader request's changes to the event the * preceding REST API save (of the same block editor "Update" click) * logged — see on_wp_after_insert_post_meta_box_loader(). Matching by * post_modified_gmt rather than time proximity means a save that happens * to land close in time to an unrelated edit is not mismatched. * * Plain joins and placeholders only, no MySQL-specific SQL, so this works * against both MySQL/MariaDB and SQLite. * * @since 5.34.0 * * @param int $post_id Post ID. * @param string $post_modified_gmt post_modified_gmt value recorded on the wanted event. * @param int $user_id User id the wanted event was logged for. * @return int Event id, or 0 if no match was found. */ protected function find_matching_post_updated_event_id( $post_id, $post_modified_gmt, $user_id ) { global $wpdb; $events_table = $this->simple_history->get_events_table_name(); $contexts_table = $this->simple_history->get_contexts_table_name(); // The event was logged in the same request that set post_modified_gmt, // so it cannot be older than that. Event dates are stored in GMT too. // Bounding on date lets the query use the logger + date index and look // at a handful of rows, instead of every post_modified_gmt context row // ever stored. One minute of slack covers a slow request. $date_cutoff = gmdate( 'Y-m-d H:i:s', (int) strtotime( $post_modified_gmt . ' UTC' ) - MINUTE_IN_SECONDS ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names, not user input. $sql = " SELECT h.id FROM {$events_table} AS h INNER JOIN {$contexts_table} AS c_msg ON ( c_msg.history_id = h.id AND c_msg.key = '_message_key' AND c_msg.value = %s ) INNER JOIN {$contexts_table} AS c_post ON ( c_post.history_id = h.id AND c_post.key = 'post_id' AND c_post.value = %s ) INNER JOIN {$contexts_table} AS c_mod ON ( c_mod.history_id = h.id AND c_mod.key = 'post_modified_gmt' AND c_mod.value = %s ) INNER JOIN {$contexts_table} AS c_user ON ( c_user.history_id = h.id AND c_user.key = '_user_id' AND c_user.value = %s ) WHERE h.logger = %s AND h.date >= %s ORDER BY h.date DESC, h.id DESC LIMIT 1 "; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $event_id = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is the literal built above; phpcs can't see through the variable. $sql, 'post_updated', (string) $post_id, (string) $post_modified_gmt, (string) $user_id, $this->slug, $date_cutoff ) ); return $event_id ? (int) $event_id : 0; } /** * Read the current context values for a set of keys on an already logged * event. * * Used by on_wp_after_insert_post_meta_box_loader() to find out which of * the keys it is about to write already exist on the matched event, so * merge_post_meta_buckets() and replace_context() can merge/overwrite * instead of appending duplicate rows. * * Plain SELECT with placeholders only, no MySQL-specific SQL, so this * works against both MySQL/MariaDB and SQLite. * * @since 5.34.0 * * @param int $event_id Event to read context for. * @param array<string> $keys Context keys to look for. * @return array<string, string> Existing value for each key found, keyed by context key. */ protected function get_existing_context_values( $event_id, array $keys ) { if ( empty( $keys ) ) { return []; } global $wpdb; $contexts_table = $this->simple_history->get_contexts_table_name(); $placeholders = implode( ', ', array_fill( 0, count( $keys ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name and a %s placeholder list, not user input. $sql = " SELECT `key`, value FROM {$contexts_table} WHERE history_id = %d AND `key` IN ({$placeholders}) ORDER BY context_id ASC "; $params = array_merge( [ $event_id ], $keys ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is the literal built above; phpcs can't see through the variable. $wpdb->prepare( $sql, $params ), ARRAY_A ); $values = []; foreach ( (array) $rows as $row ) { // If a key somehow already has more than one row (e.g. left over // from before this method existed), the last one is what reads // back today — see Log_Query::add_contexts_to_log_rows() — so // match that here too. $values[ $row['key'] ] = $row['value']; } return $values; } /** * Merge this request's post_meta_added/changed/removed buckets into the * matching buckets already stored on an event, so an event that already * has e.g. post_meta_changed_keys (set by the REST API save that ran * moments earlier) ends up with a merged, deduplicated set of names and a * consistent count — not a second row that hides the first on read. * * Names are unioned in order (existing names first) and capped at * MAX_META_KEYS_IN_CONTEXT, same as get_meta_keys_sample(). The count is * the true union size when both sides still have their full name list * (i.e. neither was capped — count equals the number of names stored). * If either side was already capped, the names that didn't make the cut * aren't known any more, so an accurate union can't be computed; the two * counts are simply added instead. That can double-count a name that * changed on both sides, but undercounting (keeping only one side's * count) would silently lose changes, which is worse. * * @since 5.34.0 * * @param array<string, mixed> $context Context this request wants to write. * @param array<string, string> $existing_values Existing context values for the * matched event, from get_existing_context_values(). * @return array<string, mixed> $context with the post_meta_* buckets merged in. */ protected function merge_post_meta_buckets( array $context, array $existing_values ) { foreach ( [ 'post_meta_added', 'post_meta_changed', 'post_meta_removed' ] as $count_key ) { $keys_key = "{$count_key}_keys"; if ( ! isset( $context[ $count_key ] ) || ! isset( $existing_values[ $count_key ] ) ) { continue; } $existing_count = (int) $existing_values[ $count_key ]; $existing_names = json_decode( (string) ( $existing_values[ $keys_key ] ?? '' ), true ); $existing_names = is_array( $existing_names ) ? $existing_names : []; $new_count = (int) $context[ $count_key ]; $new_names = json_decode( (string) ( $context[ $keys_key ] ?? '' ), true ); $new_names = is_array( $new_names ) ? $new_names : []; $either_side_was_capped = count( $existing_names ) < $existing_count || count( $new_names ) < $new_count; $merged_names = array_slice( array_values( array_unique( array_merge( $existing_names, $new_names ) ) ), 0, self::MAX_META_KEYS_IN_CONTEXT ); $context[ $count_key ] = $either_side_was_capped ? $existing_count + $new_count : count( array_unique( array_merge( $existing_names, $new_names ) ) ); $context[ $keys_key ] = (string) wp_json_encode( $merged_names ); } return $context; } /** * Write context values for an already logged event, updating any row * that already exists for a key instead of appending a duplicate one. * * append_context() is insert-only, so calling it twice for the same key * on the same event produces two rows for that key, and only one of them * survives on read (see Log_Query::add_contexts_to_log_rows()). This is * for the meta-box-loader path, where the matched event's context may * already contain some of the keys being written — both the merged * post_meta_* buckets (see merge_post_meta_buckets()) and, e.g., an ACF * context key from a previous save of the same field on the same event. * * Plain UPDATE/INSERT, no upsert syntax, so this works against both * MySQL/MariaDB and SQLite. There's no dedicated update-context helper * on the base Logger class to reuse — append_context() is the only * existing one, and it's insert-only by design. * * @since 5.34.0 * * @param int $event_id Event to update. * @param array<string, mixed> $context Context to write. * @param array<string> $existing_keys Keys that already have a row for * this event (from get_existing_context_values()). */ protected function replace_context( $event_id, array $context, array $existing_keys ) { if ( empty( $context ) ) { return; } global $wpdb; $contexts_table = $this->simple_history->get_contexts_table_name(); $context_to_append = []; foreach ( $context as $key => $value ) { if ( ! in_array( $key, $existing_keys, true ) ) { $context_to_append[ $key ] = $value; continue; } // Match append_context_batched()'s value handling so a row // updated here looks the same as one it would have inserted. $db_value = is_string( $value ) ? $value : Helpers::json_encode( $value ); $db_value = Helpers::strip_4_byte_chars( $db_value ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name, not user input. "UPDATE {$contexts_table} SET value = %s WHERE history_id = %d AND `key` = %s", $db_value, $event_id, $key ) ); } if ( ! empty( $context_to_append ) ) { $this->append_context( $event_id, $context_to_append ); } Helpers::clear_cache(); } /** * Fires after a post has been successfully deleted via the XML-RPC Blogger API. * * @since 2.0.21 * * @param int $post_ID ID of the deleted post. * @param array $args An array of arguments to delete the post. */ public function on_xmlrpc_deletePost( $post_ID, $args ) { $post = get_post( $post_ID ); $context = array( 'post_id' => $post->ID, 'post_type' => get_post_type( $post ), 'post_title' => get_the_title( $post ), ); $this->info_message( 'post_deleted', $context ); } /** * Fires after a post has been successfully updated via the XML-RPC API. * * @since 2.0.21 * * @param int $post_ID ID of the updated post. * @param array $args An array of arguments for the post to edit. */ public function on_xmlrpc_editPost( $post_ID, $args ) { $post = get_post( $post_ID ); $context = array( 'post_id' => $post->ID, 'post_type' => get_post_type( $post ), 'post_title' => get_the_title( $post ), ); $this->info_message( 'post_updated', $context ); } /** * Fires after a new post has been successfully created via the XML-RPC API. * * @since 2.0.21 * * @param int $post_ID ID of the new post. * @param array $args An array of new post arguments. */ public function on_xmlrpc_newPost( $post_ID, $args ) { $post = get_post( $post_ID ); $context = array( 'post_id' => $post->ID, 'post_type' => get_post_type( $post ), 'post_title' => get_the_title( $post ), ); $this->info_message( 'post_created', $context ); } /** * Called when a post is restored from the trash * @param int $post_id Post ID. */ public function on_untrash_post( $post_id ) { $post = get_post( $post_id ); if ( ! $this->ok_to_log_post_posttype( $post ) ) { return; } $this->info_message( 'post_restored', array( 'post_id' => $post_id, 'post_type' => get_post_type( $post ), 'post_title' => get_the_title( $post ), ) ); } /** * Fired immediately before a post is deleted from the database. * * @param int $post_id Post ID. */ public function on_delete_post( $post_id ) { $post = get_post( $post_id ); if ( wp_is_post_revision( $post_id ) ) { return; } if ( $post->post_status === 'auto-draft' || $post->post_status === 'inherit' ) { return; } $ok_to_log = true; if ( ! $this->ok_to_log_post_posttype( $post ) ) { $ok_to_log = false; } /** * Filter to control logging. * * @param bool $ok_to_log If this post deletion should be logged. * @param int $post_id * * @return bool True to log, false to not log. * * @since 2.21 */ $ok_to_log = apply_filters( 'simple_history/post_logger/post_deleted/ok_to_log', $ok_to_log, $post_id ); if ( ! $ok_to_log ) { return; } /* Posts that have been in the trash for 30 days (default) are deleted using a cron job that is called with action hook "wp_scheduled_delete". We skip logging these because users are confused and think that the real post has been deleted. We detect this by checking $wp_current_filter for 'wp_scheduled_delete' [ "wp_scheduled_delete", "delete_post", "simple_history\/log_argument\/context" ] */ global $wp_current_filter; if ( isset( $wp_current_filter ) && is_array( $wp_current_filter ) && in_array( 'wp_scheduled_delete', $wp_current_filter, true ) ) { return; } $this->info_message( 'post_deleted', array( 'post_id' => $post_id, 'post_type' => get_post_type( $post ), 'post_title' => get_the_title( $post ), ) ); } /** * Get an array of post types that should not be logged by this logger. * * @return Array with post type slugs to skip. */ public function get_skip_posttypes() { $skip_posttypes = array( // Don't log nav_menu_updates. 'nav_menu_item', // Don't log jetpack migration-things. // https://wordpress.org/support/topic/updated-jetpack_migration-sidebars_widgets/. 'jetpack_migration', 'jp_sitemap', 'jp_img_sitemap', 'jp_sitemap_master', 'attachment', // SecuPress logs. 'secupress_log_action', ); /** * Filter to log what post types not to log * * @since 2.18 */ $skip_posttypes = apply_filters( 'simple_history/post_logger/skip_posttypes', $skip_posttypes ); return $skip_posttypes; } /** * Check if post type is ok to log by logger * * @param \WP_Post|int $post Post the check. * * @return bool */ public function ok_to_log_post_posttype( $post ) { $ok_to_log = true; $skip_posttypes = $this->get_skip_posttypes(); if ( in_array( get_post_type( $post ), $skip_posttypes, true ) ) { $ok_to_log = false; } return $ok_to_log; } /** * Maybe log a post creation, modification or deletion. * * Called from: * - on_transition_post_status * - on_rest_after_insert * * Todo: * - support password protect. * - post_password is set * * @param array $args Array with old and new post data. */ public function maybe_log_post_change( $args ) { $default_args = array( 'new_post', 'new_post_meta', 'old_post', 'old_post_meta', // Old status is included because that's the value we get in filter // "transition_post_status", when a previous post may not exist. 'old_status', ); $args = wp_parse_args( $args, $default_args ); // Bail if needed args not set. if ( ! isset( $args['new_post'] ) || ! isset( $args['new_post_meta'] ) ) { return; } $new_status = $args['new_post']->post_status ?? null; $post = $args['new_post']; $new_post_data = array( 'post_data' => $post, 'post_meta' => $args['new_post_meta'], 'post_terms' => $args['new_post_terms'], ); // Set old status to status from old post with fallback to old_status variable. $old_status = $args['old_post']->post_status ?? null; $old_status = ! isset( $old_status ) && isset( $args['old_status'] ) ? $args['old_status'] : $old_status; $old_post = $args['old_post'] ?? null; $old_post_meta = $args['old_post_meta'] ?? null; $old_post_data = array( 'post_data' => $old_post, 'post_meta' => $old_post_meta, 'post_terms' => $args['old_post_terms'] ?? null, ); // Default to log. $ok_to_log = true; // Calls from the WordPress ios app/jetpack comes from non-admin-area // i.e. is_admin() is false // so don't log when outside admin area. if ( ! is_admin() ) { $ok_to_log = false; } $is_autosave = defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE; $isXmlRpcRequest = defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST; $isRestApiRequest = Helpers::is_rest_request(); // Except when calls are from/for Jetpack/WordPress apps. // seems to be jetpack/app request when $_GET["for"] == "jetpack. // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( $isXmlRpcRequest && isset( $_GET['for'] ) && $_GET['for'] === 'jetpack' ) { $ok_to_log = true; } // Also accept calls from REST API. // "REST_API_REQUEST" is used by Jetpack I believe. if ( $isRestApiRequest ) { $ok_to_log = true; } // Accept calls from WP-CLI. if ( Helpers::is_wp_cli() ) { $ok_to_log = true; } // When a post is transitioned from future to publish, it's done by a cron job, // and is_admin() is false. It's called from filter "publish_future_post". // Logging is done from another function, we just make double sure to not log it here. if ( did_action( 'publish_future_post' ) ) { $ok_to_log = false; } // Don't log revisions. if ( wp_is_post_revision( $post ) ) { $ok_to_log = false; } // Don't log Gutenberg saving meta boxes. // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET['meta-box-loader'] ) && sanitize_text_field( wp_unslash( $_GET['meta-box-loader'] ) ) ) { $ok_to_log = false; } if ( ! $this->ok_to_log_post_posttype( $post ) ) { $ok_to_log = false; } /** * Filter to control logging. * * @param bool $ok_to_log * @param string|null $new_status * @param string|null $old_status * @param \WP_Post $post * * @return bool True to log, false to not log. * * @since 2.21 */ $ok_to_log = apply_filters( 'simple_history/post_logger/post_updated/ok_to_log', $ok_to_log, $new_status, $old_status, $post ); if ( ! $ok_to_log ) { return; } /* From new to auto-draft <- ignore From new to inherit <- ignore From auto-draft to draft <- page/post created From draft to draft From draft to pending From pending to publish From pending to trash From something to publish = post published if not from & to = same, then user has changed something From draft to publish in future: status = "future" */ $context = array( 'post_id' => $post->ID, 'post_type' => get_post_type( $post ), 'post_title' => get_the_title( $post ), ); // A revision saved earlier in this request belongs to this event. Consumed // rather than only read, so a second change to the same post within one // request cannot inherit the first one's revision. if ( isset( $this->post_revision_ids[ $post->ID ] ) ) { $context['post_revision_id'] = $this->post_revision_ids[ $post->ID ]; unset( $this->post_revision_ids[ $post->ID ] ); } // Check if this is a post being created. // This includes manual creation (auto-draft -> draft/publish), auto-save // creation (auto-draft -> draft), and WP-CLI / direct wp_insert_post() // creation which transitions from 'new' to the final status. $is_post_created = ( $old_status === 'auto-draft' || $old_status === 'new' ) && ( $new_status !== 'auto-draft' && $new_status !== 'inherit' ); if ( $is_post_created ) { // Post created. // Add context to indicate if this was auto-created by WordPress (auto-save) // vs manually created by user clicking Save/Publish. if ( $new_status === 'draft' && $is_autosave ) { $context['post_auto_created'] = true; } // Capture initial post content so there's no information gap in the audit trail. // This is especially important for autosaved posts where the initial content // would otherwise be lost (first update would only show diff from autosave state). $context['post_new_post_content'] = $post->post_content; $context['post_new_post_excerpt'] = $post->post_excerpt; $context['post_prev_status'] = $old_status; $context['post_new_status'] = $new_status; $referer_post = $this->get_referer_post(); if ( $referer_post ) { $context['post_created_from_post_id'] = $referer_post->ID; $context['post_created_from_post_title'] = get_the_title( $referer_post ); } $this->info_message( 'post_created', $context ); } elseif ( $new_status === 'auto-draft' || ( $old_status === 'new' && $new_status === 'inherit' ) ) { // Post was automagically saved by WordPress but not yet created (still auto-draft). return; } elseif ( $new_status === 'trash' ) { // Post trashed. $this->info_message( 'post_trashed', $context ); } else { // Existing post was updated. // Store the post's modified date right after this update, so a // later request — the block editor's meta-box-loader save, which // never reaches this method (see the meta-box-loader bail above) — // can find this exact event again and attach its own changes to // it. See on_wp_after_insert_post_meta_box_loader(). $context['post_modified_gmt'] = $post->post_modified_gmt; // Also add diff between previous saved data and new data. // Now we have both old and new post data, including custom fields, in the same format // So let's compare! $context = $this->add_post_data_diff_to_context( $context, $old_post_data, $new_post_data ); $context['_occasionsID'] = self::class . '/' . __FUNCTION__ . "/post_updated/{$post->ID}"; /** * Modify the context saved. * * @param array $context * @param \WP_Post $post */ $context = apply_filters( 'simple_history/post_logger/post_updated/context', $context, $post ); $this->info_message( 'post_updated', $context ); } } /** * When a post is transitioned from future to publish, it's done by a cron job, * and is_admin() is false. It's called from filter "publish_future_post" however, so we can check for that. * * @param string $new_status New status. * @param string $old_status Old status. * @param \WP_Post $post Post object. */ public function on_transition_post_status_future( $new_status, $old_status, $post ) { if ( ! did_action( 'publish_future_post' ) ) { return; } $this->info_message( 'post_updated', [ 'post_id' => $post->ID, 'post_type' => get_post_type( $post ), 'post_title' => get_the_title( $post ), 'post_prev_status' => $old_status, 'post_new_status' => $new_status, ] ); } /** * Fired when a post has changed status in the classical editor. * * It is also fired when saving from the Gutenberg editor, * but it seems something is different because * we can't get previously custom fields here (we only get latest values instead). * * Only run in certain cases, * because when always enabled it catches a lots of edits made by plugins during cron jobs etc, * which by definition is not wrong, but perhaps not wanted/annoying. * * @param string $new_status One of auto-draft, inherit, draft, pending, publish, future. * @param string $old_status Same as above. * @param \WP_Post $post New updated post. */ public function on_transition_post_status( $new_status, $old_status, $post ) { $isRestApiRequest = Helpers::is_rest_request(); $isAutosave = defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE; $isAutosaveCreatingPost = $isAutosave && $old_status === 'auto-draft' && $new_status === 'draft'; // Bail if this is a REST API request, EXCEPT for autosaves that create posts. // Autosaves from Gutenberg use the REST API but we want to log when they // transition from auto-draft to draft (which represents post creation). if ( $isRestApiRequest && ! $isAutosaveCreatingPost ) { return; } // Bail for WP-CLI — handled by on_wp_after_insert_post to avoid double-logging. if ( Helpers::is_wp_cli() ) { return; } // Bail if post is not a post. if ( ! is_a( $post, 'WP_Post' ) ) { return; } $old_post = $this->old_post_data[ $post->ID ]['post_data'] ?? null; $old_post_meta = $this->old_post_data[ $post->ID ]['post_meta'] ?? null; $old_post_terms = $this->old_post_data[ $post->ID ]['post_terms'] ?? null; $args = array( 'new_post' => $post, 'new_post_meta' => get_post_custom( $post->ID ), 'new_post_terms' => wp_get_object_terms( $post->ID, get_object_taxonomies( $post->post_type ) ), 'old_post' => $old_post, 'old_post_meta' => $old_post_meta, 'old_post_terms' => $old_post_terms, 'old_status' => $old_status, ); $this->maybe_log_post_change( $args ); } /** * Adds diff data to the context array. Is called just before the event is logged. * * Since 2.0.29 * * To detect * - categories * - tags * * @param array $context Array with context. * @param array $old_post_data Old/prev post data. * @param array $new_post_data New post data. * @return array $context with diff data added. */ public function add_post_data_diff_to_context( $context, $old_post_data, $new_post_data ) { $old_data = $old_post_data['post_data']; $new_data = $new_post_data['post_data']; // Will contain the differences. $post_data_diff = array(); $arr_keys_to_diff = array( 'post_title', 'post_name', 'post_content', 'post_status', 'menu_order', 'post_date', 'post_excerpt', 'comment_status', 'ping_status', 'post_parent', // only id, need to get context for that, like name of parent at least? 'post_author', // only id, need to get more info for user. ); $arr_keys_to_diff = $this->add_keys_to_diff( $arr_keys_to_diff ); foreach ( $arr_keys_to_diff as $key ) { if ( ! isset( $old_data->$key ) || ! isset( $new_data->$key ) ) { continue; } $post_data_diff = $this->add_diff( $post_data_diff, $key, $old_data->$key, $new_data->$key ); } // If changes where detected. // Save at least 2 values for each detected value change, i.e. the old value and the new value. foreach ( $post_data_diff as $diff_key => $diff_values ) { // For post_content, try compact JSON diff storage if the library is available. if ( $diff_key === 'post_content' && class_exists( DiffHelper::class ) ) { try { // Normalize whitespace to match WP's text_diff behavior. $old_normalized = normalize_whitespace( $diff_values['old'] ); $new_normalized = normalize_whitespace( $diff_values['new'] ); $json_diff = DiffHelper::calculate( $old_normalized, $new_normalized, 'JsonHtml', [ 'context' => 1, 'ignoreLineEndings' => true, 'ignoreWhitespace' => true, ], [ 'detailLevel' => 'word', 'outputTagAsString' => true, ] ); $full_size = strlen( $old_normalized ) + strlen( $new_normalized ); $diff_size = strlen( $json_diff ); // Use compact diff only if it's actually smaller than storing full content. if ( $diff_size < $full_size ) { $context['post_content_diff'] = $json_diff; $context['post_content_diff_format'] = 'jfcherng_json_html_v1'; } else { $context[ "post_prev_{$diff_key}" ] = $diff_values['old']; $context[ "post_new_{$diff_key}" ] = $diff_values['new']; $context['post_content_diff_format'] = 'full_content_v1'; } } catch ( \Exception $e ) { // Fallback to full content storage on any error. $context[ "post_prev_{$diff_key}" ] = $diff_values['old']; $context[ "post_new_{$diff_key}" ] = $diff_values['new']; $context['post_content_diff_format'] = 'full_content_v1'; } continue; } $context[ "post_prev_{$diff_key}" ] = $diff_values['old']; $context[ "post_new_{$diff_key}" ] = $diff_values['new']; // If post_author then get more author info, // because just a user ID does not get us far. if ( $diff_key !== 'post_author' ) { continue; } $old_author_user = get_userdata( (int) $diff_values['old'] ); $new_author_user = get_userdata( (int) $diff_values['new'] ); if ( ! is_a( $old_author_user, 'WP_User' ) || ! is_a( $new_author_user, 'WP_User' ) ) { continue; } $context[ "post_prev_{$diff_key}/user_login" ] = $old_author_user->user_login; $context[ "post_prev_{$diff_key}/user_email" ] = $old_author_user->user_email; $context[ "post_prev_{$diff_key}/display_name" ] = $old_author_user->display_name; $context[ "post_new_{$diff_key}/user_login" ] = $new_author_user->user_login; $context[ "post_new_{$diff_key}/user_email" ] = $new_author_user->user_email; $context[ "post_new_{$diff_key}/display_name" ] = $new_author_user->display_name; } // Compare custom fields. $old_meta = isset( $old_post_data['post_meta'] ) ? (array) $old_post_data['post_meta'] : array(); $new_meta = isset( $new_post_data['post_meta'] ) ? (array) $new_post_data['post_meta'] : array(); // Add post featured thumb data. $context = $this->add_post_thumb_diff( $context, $old_meta, $new_meta ); // Detect page template changes. // Page template is stored in _wp_page_template. if ( isset( $old_meta['_wp_page_template'][0] ) && isset( $new_meta['_wp_page_template'][0] ) && $old_meta['_wp_page_template'][0] !== $new_meta['_wp_page_template'][0] ) { // Prev page template is different from new page template, // store template php file name. $context['post_prev_page_template'] = $old_meta['_wp_page_template'][0]; $context['post_new_page_template'] = $new_meta['_wp_page_template'][0]; $theme_templates = (array) $this->get_theme_templates(); if ( isset( $theme_templates[ $context['post_prev_page_template'] ] ) ) { $context['post_prev_page_template_name'] = $theme_templates[ $context['post_prev_page_template'] ]; } if ( isset( $theme_templates[ $context['post_new_page_template'] ] ) ) { $context['post_new_page_template_name'] = $theme_templates[ $context['post_new_page_template'] ]; } } // Added/changed/removed custom field buckets. Extracted to its own // method so the meta-box-loader request — which never reaches this // method because maybe_log_post_change() bails for it — can run the // exact same diff. See add_post_meta_diff_to_context(). $context = $this->add_post_meta_diff_to_context( $context, $old_meta, $new_meta ); // Check for changes in post visibility and post password usage and store in context. // publish = public // publish + post_password = password protected // private = post private. $old_post_has_password = ! empty( $old_data->post_password ); $old_post_password = $old_post_has_password ? $old_data->post_password : null; $old_post_status = $old_data->post_status ?? null; $new_post_has_password = ! empty( $new_data->post_password ); $new_post_password = $new_post_has_password ? $new_data->post_password : null; $new_post_status = $new_data->post_status ?? null; if ( $old_post_has_password === false && $new_post_status === 'publish' && $new_post_has_password ) { // If updated post is published and password is set and old post did not have password set // = post changed to be password protected. $context['post_password_protected'] = true; } elseif ( $old_post_has_password && $old_post_status === 'publish' && $new_post_has_password === false && $new_post_status === 'publish' ) { // Old post is publish and had password protection and new post is publish but no password // = post changed to be un-password protected. $context['post_password_unprotected'] = true; } elseif ( $old_post_has_password && $new_post_has_password && $old_post_password !== $new_post_password ) { // If old post had password and new post has password, but passwords are note same // = post has changed password. $context['post_password_changed'] = true; } elseif ( $new_post_status === 'private' && $old_post_status !== 'private' ) { // If new status is private and old is not // = post is changed to be private. $context['post_private'] = true; // Also check if password was set before. if ( $old_post_has_password ) { $context['post_password_unprotected'] = true; } } // Todo: detect sticky. // Sticky is stored in option: // $sticky_posts = get_option('sticky_posts');. // Check for changes in post terms. $old_post_terms = $old_post_data['post_terms'] ?? []; $new_post_terms = $new_post_data['post_terms'] ?? []; // Keys to keep for each term: term_id, name, slug, term_taxonomy_id, taxonomy. $term_keys_to_keep = [ 'term_id', 'name', 'slug', 'term_taxonomy_id', 'taxonomy', ]; $old_post_terms = array_map( function ( $term ) use ( $term_keys_to_keep ) { return array_intersect_key( (array) $term, array_flip( $term_keys_to_keep ) ); }, $old_post_terms ); $new_post_terms = array_map( function ( $term ) use ( $term_keys_to_keep ) { return array_intersect_key( (array) $term, array_flip( $term_keys_to_keep ) ); }, $new_post_terms ); // Detect added and removed terms. $term_changes = [ // Added = exists in new but not in old. 'added' => [], // Removed = exists in old but not in new. 'removed' => [], ]; $term_changes['added'] = array_values( array_udiff( $new_post_terms, $old_post_terms, [ $this, 'compare_terms' ] ) ); $term_changes['removed'] = array_values( array_udiff( $old_post_terms, $new_post_terms, [ $this, 'compare_terms' ] ) ); // Add old and new terms to context. $context['post_terms_added'] = $term_changes['added']; $context['post_terms_removed'] = $term_changes['removed']; /** * Filter to control context sent to the diff output. * * @param array $context Array with context. * @param array $old_data Old/prev post data. * @param array $new_data New post data. * @param array $old_meta Old/prev post meta data. * @param array $new_meta New post meta data. * * @return array $context Array with diff data added. * * @since 2.36.0 */ return apply_filters( 'simple_history/post_logger/context', $context, $old_data, $new_data, $old_meta, $new_meta ); } /** * Diff two post meta snapshots and add the added/changed/removed custom * field buckets to $context — same shape used elsewhere in this logger's * context: post_meta_added/post_meta_added_keys, post_meta_changed/ * post_meta_changed_keys, post_meta_removed/post_meta_removed_keys. * * Extracted out of add_post_data_diff_to_context() so the meta-box-loader * request (see on_wp_after_insert_post_meta_box_loader()) can run the * exact same diff — that request never reaches * add_post_data_diff_to_context() because maybe_log_post_change() * deliberately bails for it. * * @since 5.34.0 * * @param array $context Context to add the buckets to. * @param array $old_meta Post meta before the save, in get_post_custom() shape (meta_key => array of values). * @param array $new_meta Post meta after the save, in get_post_custom() shape. * @return array $context with post_meta_* keys added, if there were changes. */ protected function add_post_meta_diff_to_context( $context, $old_meta, $new_meta ) { // Array with custom field keys to ignore because changed every time or very internal. $arr_meta_keys_to_ignore = array( '_edit_lock', '_edit_last', '_post_restored_from', '_wp_page_template', '_thumbnail_id', // _encloseme is added to a post when it's published. The wp-cron process should get scheduled shortly thereafter to process the post to look for enclosures. // https://wordpress.stackexchange.com/questions/20904/the-encloseme-meta-key-conundrum '_encloseme', ); $meta_changes = array( 'added' => array(), 'removed' => array(), 'changed' => array(), ); /** * Filters the array with custom field keys to ignore. * * An entry can be an exact meta key, or a simple prefix wildcard ending * in "*", e.g. "_seopress_social_*" ignores every key starting with * that prefix. * * Old and new meta added in 5.34.0, so a callback can decide what to * ignore based on what is actually there, e.g. a plugin's own * bookkeeping keys. * * @param array $arr_meta_keys_to_ignore Array with custom field keys (or "prefix*" wildcards) to ignore. * @param array $context Array with context. * @param array $old_meta Post meta before the save, keyed by meta key. * @param array $new_meta Post meta after the save, keyed by meta key. * @return array Filtered array with custom field keys to ignore. * * @since 5.8.2 */ $arr_meta_keys_to_ignore = apply_filters( 'simple_history/post_logger/meta_keys_to_ignore', $arr_meta_keys_to_ignore, $context, $old_meta, $new_meta ); // Remove fields that we have checked already and other that should be ignored. $old_meta = $this->remove_ignored_meta_keys( $old_meta, $arr_meta_keys_to_ignore ); $new_meta = $this->remove_ignored_meta_keys( $new_meta, $arr_meta_keys_to_ignore ); // Look for added custom fields/meta. foreach ( $new_meta as $meta_key => $meta_value ) { if ( isset( $old_meta[ $meta_key ] ) ) { continue; } // A key that appears with an empty value is not an addition a user // would recognise. SEOPress, for example, creates ~30 meta keys with // empty string values on a post's first save. if ( $this->is_meta_value_empty( $meta_value ) ) { continue; } $meta_changes['added'][ $meta_key ] = true; } // Look for changed custom fields/meta. foreach ( $old_meta as $meta_key => $meta_value ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode if ( ! isset( $new_meta[ $meta_key ] ) || json_encode( $old_meta[ $meta_key ] ) === json_encode( $new_meta[ $meta_key ] ) ) { continue; } $meta_changes['changed'][ $meta_key ] = true; } // Look for removed custom fields/meta. foreach ( $old_meta as $meta_key => $meta_value ) { if ( isset( $new_meta[ $meta_key ] ) ) { continue; } // A key that only ever held an empty value is not a removal a user // would recognise. if ( $this->is_meta_value_empty( $meta_value ) ) { continue; } $meta_changes['removed'][ $meta_key ] = true; } if ( $meta_changes['added'] ) { $context['post_meta_added'] = count( $meta_changes['added'] ); $context['post_meta_added_keys'] = $this->get_meta_keys_sample( $meta_changes['added'] ); } if ( $meta_changes['removed'] ) { $context['post_meta_removed'] = count( $meta_changes['removed'] ); $context['post_meta_removed_keys'] = $this->get_meta_keys_sample( $meta_changes['removed'] ); } if ( $meta_changes['changed'] ) { $context['post_meta_changed'] = count( $meta_changes['changed'] ); $context['post_meta_changed_keys'] = $this->get_meta_keys_sample( $meta_changes['changed'] ); } return $context; } /** * Compare function for terms to check terms by id. * * @param array $a Term A. * @param array $b Term B. */ private function compare_terms( $a, $b ) { return $a['term_id'] <=> $b['term_id']; } /** * Return the current theme templates. * Template will return untranslated. * Uses the same approach as in class-wp-theme.php to get templates. * * @since 2.0.29 */ public function get_theme_templates() { $theme = wp_get_theme(); $page_templates = array(); $files = (array) $theme->get_files( 'php', 1 ); foreach ( $files as $file => $full_path ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown if ( ! preg_match( '|Template Name:(.*)$|mi', file_get_contents( $full_path ), $header ) ) { continue; } $page_templates[ $file ] = _cleanup_header_comment( $header[1] ); } return $page_templates; } /** * Add diff to array if old and new values are different * * Since 2.0.29 * * @param array $post_data_diff Post data diff. * @param string $key Key. * @param mixed $old_value Old value. * @param mixed $new_value New value. * @return array */ public function add_diff( $post_data_diff, $key, $old_value, $new_value ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual -- Loose comparison intentional to avoid false diffs when types differ. if ( $old_value != $new_value ) { $post_data_diff[ $key ] = array( 'old' => $old_value, 'new' => $new_value, ); } return $post_data_diff; } /** * Modify plain output to include link to post. * * @param object $row Row data. */ public function get_log_row_plain_text_output( $row ) { $context = $row->context; $post_id = $context['post_id'] ?? 0; // Default to original log message. $message = $row->message; // Check if post still is available. // It will return a WP_Post Object if post still is in system. // If post is deleted from trash (not just moved there), then null is returned. $post = get_post( $post_id ); $post_is_available = is_a( $post, 'WP_Post' ); $message_key = $context['_message_key'] ?? null; // Try to get singular name. $post_type = $context['post_type'] ?? ''; $post_type_obj = get_post_type_object( $post_type ); if ( ! is_null( $post_type_obj ) && ! empty( $post_type_obj->labels->singular_name ) ) { $context['post_type'] = strtolower( $post_type_obj->labels->singular_name ); } // Only try to get edit link if post is available. This _may_ fix some issues with edit links in // for example old versions of WPML. $context['edit_link'] = $post_is_available ? get_edit_post_link( $post_id ) : null; // If post is not available any longer then we can't link to it, so keep plain message then. // Also keep plain format if user is not allowed to edit post (edit link is empty). if ( $post_is_available && $context['edit_link'] ) { if ( $message_key === 'post_updated' ) { $message = __( 'Updated {post_type} <a href="{edit_link}">"{post_title}"</a>', 'simple-history' ); } elseif ( $message_key === 'post_deleted' ) { $message = __( 'Deleted {post_type} "{post_title}"', 'simple-history' ); } elseif ( $message_key === 'post_created' ) { $message = __( 'Created {post_type} <a href="{edit_link}">"{post_title}"</a>', 'simple-history' ); } elseif ( $message_key === 'post_trashed' ) { // While in trash we can still get actions to delete or restore if we follow the edit link. $message = __( 'Moved {post_type} <a href="{edit_link}">"{post_title}"</a> to the trash', 'simple-history' ); } elseif ( $message_key === 'page_set_as_homepage' ) { if ( ! empty( $context['old_post_title'] ) ) { $message = __( 'Set {post_type} <a href="{edit_link}">"{post_title}"</a> as the homepage, replacing "{old_post_title}"', 'simple-history' ); } else { $message = __( 'Set {post_type} <a href="{edit_link}">"{post_title}"</a> as the homepage', 'simple-history' ); } } elseif ( $message_key === 'page_removed_as_homepage' ) { $message = __( 'Removed {post_type} <a href="{edit_link}">"{post_title}"</a> as the homepage', 'simple-history' ); } elseif ( $message_key === 'page_set_as_posts_page' ) { if ( ! empty( $context['old_post_title'] ) ) { $message = __( 'Set {post_type} <a href="{edit_link}">"{post_title}"</a> as the posts page, replacing "{old_post_title}"', 'simple-history' ); } else { $message = __( 'Set {post_type} <a href="{edit_link}">"{post_title}"</a> as the posts page', 'simple-history' ); } } elseif ( $message_key === 'page_removed_as_posts_page' ) { $message = __( 'Removed {post_type} <a href="{edit_link}">"{post_title}"</a> as the posts page', 'simple-history' ); } } // For page role messages without edit link, add "replacing" info to the plain message. if ( ! empty( $context['old_post_title'] ) ) { if ( $message_key === 'page_set_as_homepage' && strpos( $message, 'old_post_title' ) === false ) { $message = __( 'Set {post_type} "{post_title}" as the homepage, replacing "{old_post_title}"', 'simple-history' ); } elseif ( $message_key === 'page_set_as_posts_page' && strpos( $message, 'old_post_title' ) === false ) { $message = __( 'Set {post_type} "{post_title}" as the posts page, replacing "{old_post_title}"', 'simple-history' ); } } $context['post_type'] = isset( $context['post_type'] ) ? esc_html( $context['post_type'] ) : ''; $context['post_title'] = isset( $context['post_title'] ) ? esc_html( $context['post_title'] ) : ''; $context['old_post_title'] = isset( $context['old_post_title'] ) ? esc_html( $context['old_post_title'] ) : ''; return helpers::interpolate( $message, $context, $row ); } /** * Get structured action links for a post event. * * Returns View, Edit, Preview, and Revisions links based on * message key, post availability, status, and user capabilities. * * @since 5.24.0 * * @param object $row Log row object. * @return array Array of action link arrays. */ public function get_action_links( $row ) { $context = $row->context; $post_id = (int) ( $context['post_id'] ?? 0 ); $post_type = $context['post_type'] ?? ''; $message_key = $context['_message_key'] ?? null; $action_links = []; // Skip the DB lookup for post_deleted (the post is gone) and for // events without a post id. The overview block below still runs // from $context['post_type']. $post = $post_id && $message_key !== 'post_deleted' ? get_post( $post_id ) : null; $effective_post_type_obj = null; if ( $post instanceof \WP_Post ) { $effective_post_type_obj = get_post_type_object( $post->post_type ); $type_label = $effective_post_type_obj ? strtolower( $effective_post_type_obj->labels->singular_name ) : $post->post_type; $post_status = get_post_status( $post ); $is_published = $post_status === 'publish'; $is_viewable = in_array( $post_status, [ 'draft', 'pending', 'future' ], true ); $has_edit_cap = current_user_can( 'edit_post', $post_id ); if ( $has_edit_cap ) { $edit_link = get_edit_post_link( $post_id, 'raw' ); if ( $edit_link ) { $action_links[] = [ 'url' => $edit_link, /* translators: %s: post type label, e.g. "page" or "post". */ 'label' => sprintf( __( 'Edit %s', 'simple-history' ), $type_label ), 'action' => 'edit', ]; } } if ( $is_published ) { $permalink = get_permalink( $post_id ); if ( $permalink ) { $action_links[] = [ 'url' => $permalink, /* translators: %s: post type label, e.g. "page" or "post". */ 'label' => sprintf( __( 'View %s', 'simple-history' ), $type_label ), 'action' => 'view', ]; } } if ( $is_viewable && $has_edit_cap ) { $preview_link = get_preview_post_link( $post_id ); if ( $preview_link ) { $action_links[] = [ 'url' => $preview_link, /* translators: %s: post type label, e.g. "page" or "post". */ 'label' => sprintf( __( 'Preview %s', 'simple-history' ), $type_label ), 'action' => 'preview', ]; } } if ( $message_key === 'post_updated' && $has_edit_cap ) { $revision_link = $this->get_revision_action_link( $context, $post_id ); if ( $revision_link ) { $action_links[] = $revision_link; } } } // Overview link still renders for post_deleted (per-post block is // gone there); fall back to $context['post_type'] when $post is null. $overview_obj = $effective_post_type_obj; if ( ! $overview_obj && $post_type ) { $overview_obj = get_post_type_object( $post_type ); } // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability comes from registered post type's cap mapping. if ( $overview_obj && current_user_can( $overview_obj->cap->edit_posts ) ) { $action_links[] = [ 'url' => admin_url( 'edit.php?post_type=' . rawurlencode( $overview_obj->name ) ), 'label' => sprintf( /* translators: %s: post type plural label, e.g. "posts" or "pages". */ __( 'All %s', 'simple-history' ), strtolower( $overview_obj->labels->name ) ), 'action' => 'view', ]; } return $action_links; } /** * Get the revision action link for an event. * * Two shapes, and the label distinguishes them so neither claims more than * it can deliver: * * - **"View revision"** when we know which revision the event produced and * it still exists. Phrased per event, so its absence reads as a fact * about that event rather than as a broken capability. * - **"Revisions"** for events logged before we recorded the revision id, * which cannot say which revision they made. These keep the original * behaviour — the post's newest revision — under the original generic * label, so nothing is taken away from older history. * * Where we know the specific revision and it has been pruned, there is * still no link: falling back to the newest one there would be exactly the * silent mislead this replaced, and the details panel says it is gone. * * @param array $context Event context. * @param int $post_id ID of the post the event belongs to. * @return array|null Action link array, or null when there is nothing to link to. */ protected function get_revision_action_link( $context, $post_id ) { $state = $this->get_event_revision_state( $context, $post_id ); if ( $state['status'] === 'available' ) { return [ 'url' => $this->get_revision_admin_url( $state['revision']->ID, $post_id ), 'label' => __( 'View revision', 'simple-history' ), 'action' => 'revisions', ]; } if ( $state['status'] === 'unknown' ) { return $this->get_latest_revision_action_link( $post_id ); } return null; } /** * Get a link to the post's newest revision, for events that never recorded * which revision they created. * * `wp_revisions_enabled()` is a constant-time check (post-type support plus * WP_POST_REVISIONS); running it first keeps sites with revisions disabled * from issuing a WP_Query per event. * * @param int $post_id ID of the post the event belongs to. * @return array|null Action link array, or null when the post has no revisions. */ protected function get_latest_revision_action_link( $post_id ) { $post = $this->get_cached_post( $post_id ); if ( ! $post instanceof \WP_Post || ! wp_revisions_enabled( $post ) ) { return null; } $revisions = wp_get_post_revisions( $post_id, [ 'numberposts' => 1 ] ); if ( empty( $revisions ) ) { return null; } $latest_revision = reset( $revisions ); return [ 'url' => $this->get_revision_admin_url( $latest_revision->ID, $post_id ), 'label' => __( 'Revisions', 'simple-history' ), 'action' => 'revisions', ]; } /** * Work out what became of the revision this event created. * * WordPress prunes revisions on its own schedule (WP_POST_REVISIONS) and * deletes them with their parent post, while our events outlive both. The * cases have to be told apart rather than lumped into "no link", because * only one of them can honestly be reported to the user as gone: * * - `unknown` no revision id stored — an old event, or none was made. * - `available` stored, still present, and WordPress will display it. * - `gone` stored, and the row no longer exists. Safe to report. * - `unverifiable` the id points at something that is not this post's * revision, which happens after a restore or migration. * The revision may well still exist, so claiming it is * gone would be a false statement. * - `disabled` the row exists but revisions were turned off since, and * revision.php now refuses to render it. * * @param array $context Event context. * @param int $post_id ID of the post the event belongs to. * @return array{status:string,revision:\WP_Post|null} */ protected function get_event_revision_state( $context, $post_id ) { $revision_id = (int) ( $context['post_revision_id'] ?? 0 ); if ( $revision_id === 0 ) { return [ 'status' => 'unknown', 'revision' => null, ]; } $revision = $this->get_cached_post( $revision_id ); // Nothing at that id at all: the revision really has been deleted, and // saying so is accurate. if ( ! $revision instanceof \WP_Post ) { return [ 'status' => 'gone', 'revision' => null, ]; } // Something is there, but it is not a revision. After a restore or // migration an id can just as easily land on an ordinary post as on // another post's revision, and the revision this event made may be // perfectly intact under a different id — so this cannot be reported // as deleted either. if ( $revision->post_type !== 'revision' ) { return [ 'status' => 'unverifiable', 'revision' => null, ]; } if ( (int) $revision->post_parent !== $post_id ) { return [ 'status' => 'unverifiable', 'revision' => null, ]; } // A site can turn revisions off after the fact, leaving rows that // WordPress now refuses to display: revision.php redirects to the post // list rather than render one. Mirror its check so we do not offer a // link into that dead end. Autosaves stay viewable, as they do there. $parent = $this->get_cached_post( $post_id ); if ( $parent instanceof \WP_Post && ! wp_revisions_enabled( $parent ) && ! wp_is_post_autosave( $revision ) ) { return [ 'status' => 'disabled', 'revision' => $revision, ]; } return [ 'status' => 'available', 'revision' => $revision, ]; } /** * get_post() wrapper that also remembers misses. * * Each rendered row asks about its revision twice — once for the action * link, once for the details panel. WP_Post::get_instance() caches hits but * not misses, and a miss is the common case here: an old event whose * revision WordPress has long since pruned. Without this, a page of such * events issues two uncached queries per row. * * @param int $post_id Post ID to look up. * @return \WP_Post|null */ private function get_cached_post( $post_id ) { if ( array_key_exists( $post_id, $this->looked_up_posts ) ) { return $this->looked_up_posts[ $post_id ]; } $post = get_post( $post_id ); $this->looked_up_posts[ $post_id ] = $post instanceof \WP_Post ? $post : null; return $this->looked_up_posts[ $post_id ]; } /** * Get the admin URL for viewing a single revision. * * WordPress 7.1 renders revisions visually in the editor, with added, * modified and removed blocks marked up in place, and accepts the revision * id as a query arg. Older versions get the classic comparison screen. * * The upgrade is deliberately silent — same label either way — because we * cannot tell from the server whether the visual view will actually open. * WordPress disables it whenever the post has active meta boxes, which any * SEO or custom fields plugin adds, and then redirects to the classic * screen. Both destinations show the correct revision, so the fallback is * harmless; a label promising a visual comparison would not be. * * @param int $revision_id ID of the revision to view. * @param int $post_id ID of the revision's parent post. * @return string Admin URL. */ protected function get_revision_admin_url( $revision_id, $post_id ) { global $wp_version; if ( version_compare( $wp_version, '7.1', '>=' ) ) { return admin_url( sprintf( 'post.php?post=%1$d&action=edit&revision=%2$d', $post_id, $revision_id ) ); } return admin_url( 'revision.php?revision=' . $revision_id ); } /** * Get details output for row. * * @param object $row Row data. */ public function get_log_row_details_output( $row ) { $context = $row->context; $message_key = $context['_message_key']; $out = ''; if ( $message_key === 'post_updated' ) { // Check for keys like "post_prev_post_title" and "post_new_post_title". $diff_table_output = ''; $has_diff_values = false; $inline_group = new Event_Details_Group(); foreach ( $context as $key => $val ) { // Skip some context keys. $keys_to_skip = []; // Skip post author and featured image keys because we output // those changes manually further down. $keys_to_skip = [ 'post_author/user_login', 'post_author/user_email', 'post_author/display_name', 'thumb_id', 'thumb_title', ]; if ( strpos( $key, 'post_prev_' ) === false ) { continue; } // Old value exists, new value must also exist for diff to be calculates. $key_to_diff = substr( $key, strlen( 'post_prev_' ) ); $key_for_new_val = "post_new_{$key_to_diff}"; // Skip some keys. if ( in_array( $key_to_diff, $keys_to_skip, true ) ) { continue; } if ( ! isset( $context[ $key_for_new_val ] ) ) { continue; } $post_old_value = $context[ $key ]; $post_new_value = $context[ $key_for_new_val ]; // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- Loose comparison intentional to avoid false diffs when types differ. if ( $post_old_value == $post_new_value ) { continue; } // Different diffs for different keys. if ( $key_to_diff === 'post_title' ) { $has_diff_values = true; $label = __( 'Title', 'simple-history' ); $diff_table_output .= sprintf( '<dt>%1$s</dt><dd>%2$s</dd>', $this->label_for( $key_to_diff, $label, $context ), helpers::text_diff( $post_old_value, $post_new_value ) ); } elseif ( $key_to_diff === 'post_content' ) { // Skip if compact JSON diff exists — it's rendered separately below. if ( isset( $context['post_content_diff'] ) ) { continue; } $has_diff_values = true; $label = __( 'Content', 'simple-history' ); $key_text_diff = helpers::text_diff( $post_old_value, $post_new_value ); if ( $key_text_diff ) { $diff_table_output .= sprintf( '<dt>%1$s</dt><dd>%2$s</dd>', $this->label_for( $key_to_diff, $label, $context ), $key_text_diff ); } } elseif ( $key_to_diff === 'post_status' ) { $inline_group->add_item( ( new Event_Details_Item( null, __( 'Status', 'simple-history' ) ) ) ->set_values( $post_new_value, $post_old_value ) ); } elseif ( $key_to_diff === 'post_date' ) { $inline_group->add_item( ( new Event_Details_Item( null, __( 'Publish date', 'simple-history' ) ) ) ->set_values( $post_new_value, $post_old_value ) ); } elseif ( $key_to_diff === 'post_name' ) { $has_diff_values = true; $label = __( 'Permalink', 'simple-history' ); $diff_table_output .= sprintf( '<dt>%1$s</dt> <dd>%2$s</dd>', $this->label_for( $key_to_diff, $label, $context ), helpers::text_diff( $post_old_value, $post_new_value ) ); } elseif ( $key_to_diff === 'comment_status' ) { $inline_group->add_item( ( new Event_Details_Item( null, __( 'Comment status', 'simple-history' ) ) ) ->set_values( $post_new_value, $post_old_value ) ); } elseif ( $key_to_diff === 'post_author' ) { // wp post edit screen uses display_name so we should use it too. if ( isset( $context['post_prev_post_author/display_name'] ) && isset( $context['post_new_post_author/display_name'] ) ) { $prev_display = sprintf( '%1$s (%2$s)', $context['post_prev_post_author/display_name'], $context['post_prev_post_author/user_email'] ?? '' ); $new_display = sprintf( '%1$s (%2$s)', $context['post_new_post_author/display_name'], $context['post_new_post_author/user_email'] ?? '' ); $inline_group->add_item( ( new Event_Details_Item( null, __( 'Author', 'simple-history' ) ) ) ->set_values( $new_display, $prev_display ) ); } } elseif ( $key_to_diff === 'page_template' ) { // page template filename. $prev_page_template = $context['post_prev_page_template']; $new_page_template = $context['post_new_page_template']; // page template name, should exist, but I guess someone could have deleted a template // and after that change the template for a post. $prev_page_template_name = $context['post_prev_page_template_name'] ?? ''; $new_page_template_name = $context['post_new_page_template_name'] ?? ''; // If prev or new template is "default" then use that as name. if ( $prev_page_template === 'default' && ! $prev_page_template_name ) { $prev_page_template_name = $prev_page_template; } elseif ( $new_page_template === 'default' && ! $new_page_template_name ) { $new_page_template_name = $new_page_template; } $prev_display = $prev_page_template_name ? sprintf( '%1$s (%2$s)', $prev_page_template_name, $prev_page_template ) : $prev_page_template; $new_display = $new_page_template_name ? sprintf( '%1$s (%2$s)', $new_page_template_name, $new_page_template ) : $new_page_template; $inline_group->add_item( ( new Event_Details_Item( null, __( 'Template', 'simple-history' ) ) ) ->set_values( $new_display, $prev_display ) ); } else { $has_diff_values = true; $diff_table_output .= $this->extra_diff_record( $this->label_for( $key_to_diff, $this->get_human_label_for_diff_key( $key_to_diff ), $context ), $post_old_value, $post_new_value ); } } // Render compact JSON diff for post_content if available. if ( isset( $context['post_content_diff'] ) ) { $json_diff_html = Helpers::render_json_diff_to_html( $context['post_content_diff'] ); if ( $json_diff_html !== '' ) { $has_diff_values = true; $diff_table_output .= sprintf( '<dt>%1$s</dt><dd>%2$s</dd>', esc_html( __( 'Content', 'simple-history' ) ), $json_diff_html ); } } $rows_before_filter = substr_count( strtolower( $diff_table_output ), '<tr' ); /** * Modify the formatted diff output of a saved/modified post. * * The output is a string of <dt>label</dt><dd>value</dd> pairs that is * wrapped in a <dl class="SimpleHistoryLogitem__keyValueTable">. * Before 5.33 the pairs were <tr><td></td><td></td></tr> rows. A * callback that still appends such rows is detected below and the * whole list is then rendered in the old table form instead. * * Since the next version, custom field changes and added/removed * taxonomy terms are no longer part of this string. They moved to * their own Event_Details_Group (see * get_details_group_for_post_meta_and_terms()) so REST, CLI, and * abilities consumers get structured data for them instead of HTML. * * @param string $diff_table_output * @param array $context * @return string */ $diff_table_output = apply_filters( 'simple_history/post_logger/post_updated/diff_table_output', $diff_table_output, $context ); if ( $has_diff_values || $diff_table_output ) { $legacy_rows_added = substr_count( strtolower( $diff_table_output ), '<tr' ) > $rows_before_filter; if ( $legacy_rows_added ) { // Browsers drop <tr>/<td> tags inside a <dl>, so the rows a // pre-5.33 callback appended would turn into loose text and // shift every pair after them. Fall back to the table form, // which the stylesheet lays out identically. Our own pairs // translate with plain replacement: <dt>/<dd> never nest // and never occur inside a value. $diff_table_output = str_replace( [ '<dt>', '</dt>', '<dd>', '</dd>' ], [ '<tr><td>', '</td>', '<td>', '</td></tr>' ], $diff_table_output ); $diff_table_output = '<table class="SimpleHistoryLogitem__keyValueTable"><tbody>' . $diff_table_output . '</tbody></table>'; } else { $diff_table_output = '<dl class="SimpleHistoryLogitem__keyValueTable">' . $diff_table_output . '</dl>'; } } // Explain a missing "View this revision" link, but only when we can // do so truthfully. Without this the link's absence invites the // wrong conclusion — that the edit did not produce a revision — // which is a false statement about the user's own history. // // Only the 'gone' state earns the note. 'unverifiable' means the id // no longer resolves to this post's revision after a restore or // migration, where the revision may well still exist; 'unknown' // means we never recorded one. Neither can be reported as deleted. // // Gated on the same capability as the link itself, so we never // explain the absence of something the reader could not have seen. $post_id_for_revision = (int) ( $context['post_id'] ?? 0 ); if ( $post_id_for_revision && current_user_can( 'edit_post', $post_id_for_revision ) ) { $revision_state = $this->get_event_revision_state( $context, $post_id_for_revision ); if ( $revision_state['status'] === 'gone' ) { $inline_group->add_item( ( new Event_Details_Item( null, __( 'Revision', 'simple-history' ) ) ) ->set_new_value( __( 'No longer stored by WordPress', 'simple-history' ) ) ); } } $groups = []; if ( ! empty( $inline_group->items ) ) { $groups[] = $inline_group; } if ( $diff_table_output !== '' ) { $groups[] = Event_Details_Group::create_raw( $diff_table_output ); } // Changed custom fields and taxonomy terms. Their own group rather // than rows in the raw table above, so the change also reaches // details_data (REST, CLI, abilities), which a raw HTML group // cannot describe. $meta_and_terms_group = $this->get_details_group_for_post_meta_and_terms( $context ); if ( $meta_and_terms_group ) { $groups[] = $meta_and_terms_group; } // Changed featured image. Its own group rather than a row in the raw // table above, so the change also reaches details_data (REST, CLI, // abilities), which a raw HTML group cannot describe. $thumb_group = $this->get_details_group_for_post_thumb( $context ); if ( $thumb_group ) { $groups[] = $thumb_group; } if ( empty( $groups ) ) { return ''; } return Event_Details_Container::create_from( $groups ); } elseif ( $message_key === 'post_created' ) { // Show initial post content for created posts using Event_Details classes. // The Event Details system will automatically read values from context. // Using diff table formatter for consistency with post_updated display. $event_details_group = new Event_Details_Group(); $event_details_group->set_formatter( new Event_Details_Group_Diff_Table_Formatter() ); $event_details_group->add_items( [ new Event_Details_Item( 'post_new_post_content', __( 'Content', 'simple-history' ) ), new Event_Details_Item( 'post_new_post_excerpt', __( 'Excerpt', 'simple-history' ) ), new Event_Details_Item( 'post_new_status', __( 'Status', 'simple-history' ) ), ] ); return $event_details_group; } return $out; } /** * Summarise one bucket of changed custom fields, by name where we have them. * * Events logged before the names were stored only carry a count, so those * fall back to "Added: 3". Where more fields changed than we stored names * for, the remainder is reported rather than silently dropped. * * @param string $bucket_label What happened to the fields, e.g. "Changed". * @param int $count How many fields it happened to. * @param string $keys_json JSON array of field names, or empty for older events. * @return string Summary to show after the "Custom fields" label. */ protected function get_custom_fields_summary( $bucket_label, $count, $keys_json ) { $keys = json_decode( $keys_json, true ); $keys = is_array( $keys ) ? array_filter( $keys, 'is_string' ) : []; if ( $keys === [] ) { return sprintf( /* translators: 1: what happened to the custom fields, e.g. "Added". 2: how many fields. */ __( '%1$s: %2$d', 'simple-history' ), $bucket_label, $count ); } $summary = sprintf( /* translators: 1: what happened to the custom fields, e.g. "Changed". 2: comma separated field names. */ __( '%1$s: %2$s', 'simple-history' ), $bucket_label, implode( ', ', $keys ) ); $not_named = $count - count( $keys ); if ( $not_named > 0 ) { $summary .= ' ' . sprintf( /* translators: %d: number of custom fields not listed by name. */ _n( 'and %d more', 'and %d more', $not_named, 'simple-history' ), $not_named ); } return $summary; } /** * Store a sample of custom field names for the context. * * Encoded as JSON rather than a comma separated string because a meta key may * itself contain a comma, which would split one name into two on output. * * @param array<string, bool> $meta_keys Changed keys, as key => true. * @return string JSON array of key names, capped at MAX_META_KEYS_IN_CONTEXT. */ protected function get_meta_keys_sample( $meta_keys ) { $keys = array_slice( array_keys( $meta_keys ), 0, self::MAX_META_KEYS_IN_CONTEXT ); return (string) wp_json_encode( $keys ); } /** * Check whether a meta value, as returned by get_post_meta() without * $single, is empty. * * A meta key with an empty value is not something a user added or * removed on purpose. SEOPress, for example, creates ~30 meta keys with * empty string values the first time a post is saved. * * @param mixed $meta_value Value for one meta key, normally an array of strings. * @return bool True if the array is empty or every value in it is ''. */ private function is_meta_value_empty( $meta_value ) { if ( empty( $meta_value ) ) { return true; } foreach ( (array) $meta_value as $single_value ) { if ( $single_value !== '' ) { return false; } } return true; } /** * Remove keys from a post meta array that match the ignore list. * * An entry in $keys_to_ignore can be an exact meta key, or a simple * prefix wildcard ending in "*", e.g. "_seopress_social_*" removes every * key starting with that prefix. * * @param array<string, mixed> $meta_array Meta array to filter, keyed by meta key. * @param array<string> $keys_to_ignore Keys and wildcard patterns to remove. * @return array<string, mixed> */ private function remove_ignored_meta_keys( $meta_array, $keys_to_ignore ) { foreach ( $keys_to_ignore as $key_to_ignore ) { if ( substr( $key_to_ignore, -1 ) === '*' ) { $prefix = substr( $key_to_ignore, 0, -1 ); foreach ( array_keys( $meta_array ) as $meta_key ) { if ( strpos( $meta_key, $prefix ) !== 0 ) { continue; } unset( $meta_array[ $meta_key ] ); } continue; } unset( $meta_array[ $key_to_ignore ] ); } return $meta_array; } /** * Get a human readable label for a diffed post field. * * Fields with their own branch in the diff loop get their label there. This * covers the rest, so a row does not show the raw database column name, for * example "post_excerpt" instead of "Excerpt". * * Unknown keys, like the ones added through the * `simple_history/post_logger/keys_to_diff` filter, keep the key as label. * * @param string $key Key that is diffed, without the "post_prev_" prefix. * @return string Label to show, or the key itself if we have no label for it. */ protected function get_human_label_for_diff_key( $key ) { $labels = [ 'post_excerpt' => __( 'Excerpt', 'simple-history' ), 'menu_order' => __( 'Menu order', 'simple-history' ), 'ping_status' => __( 'Ping status', 'simple-history' ), 'post_parent' => __( 'Parent', 'simple-history' ), ]; return $labels[ $key ] ?? $key; } /** * Modify the label for a key. * * @param string $key Key. * @param string $label Label. * @param array $context Context. * @return string */ protected function label_for( $key, $label, $context ) { /** * Filters the label for a key. * * @param string $label Label. * @param string $key Key. * @param array $context Context. * @return string */ return apply_filters( 'simple_history/post_logger/label_for_key', $label, $key, $context ); } /** * Get extra diff record. * * @param string $key Key. * @param string $old_value Old value. * @param string $new_value New value. * @return string */ public function extra_diff_record( $key, $old_value, $new_value ) { return sprintf( '<dt>%1$s</dt><dd>%2$s</dd>', $key, helpers::text_diff( $old_value, $new_value ) ); } /** * Modify RSS links to they go directly to the correct post in WP admin. * * @since 2.0.23 * @param string $link Link. * @param object $row Row. */ public function filter_rss_item_link( $link, $row ) { if ( $row->logger !== $this->get_slug() ) { return $link; } if ( isset( $row->context['post_id'] ) ) { $link = add_query_arg( array( 'action' => 'edit', 'post' => $row->context['post_id'], ), admin_url( 'post.php' ) ); } return $link; } /** * Add diff for post thumb/post featured image. * * @param array $context Context. * @param array $old_meta Old meta. * @param array $new_meta New meta. * @return array Maybe modified context. */ public function add_post_thumb_diff( $context, $old_meta, $new_meta ) { $prev_post_thumb_id = null; $new_post_thumb_id = null; // If it was changed from one image to another. if ( isset( $old_meta['_thumbnail_id'][0] ) && isset( $new_meta['_thumbnail_id'][0] ) ) { if ( $old_meta['_thumbnail_id'][0] !== $new_meta['_thumbnail_id'][0] ) { $prev_post_thumb_id = $old_meta['_thumbnail_id'][0]; $new_post_thumb_id = $new_meta['_thumbnail_id'][0]; } } elseif ( isset( $old_meta['_thumbnail_id'][0] ) ) { // Featured image id did not exist on both new and old data. But on any? $prev_post_thumb_id = $old_meta['_thumbnail_id'][0]; } elseif ( isset( $new_meta['_thumbnail_id'][0] ) ) { $new_post_thumb_id = $new_meta['_thumbnail_id'][0]; } if ( $prev_post_thumb_id ) { $context['post_prev_thumb_id'] = $prev_post_thumb_id; $context['post_prev_thumb_title'] = get_the_title( $prev_post_thumb_id ); } if ( $new_post_thumb_id ) { $context['post_new_thumb_id'] = $new_post_thumb_id; $context['post_new_thumb_title'] = get_the_title( $new_post_thumb_id ); } return $context; } /** * Add keys to diff. * * @param array $arr_keys_to_diff Array with keys to diff. * @return array */ protected function add_keys_to_diff( $arr_keys_to_diff ) { /** * Filters the keys to diff. * * @param array $arr_keys_to_diff Array with keys to diff. * @return array */ return apply_filters( 'simple_history/post_logger/keys_to_diff', $arr_keys_to_diff ); } /** * Get the details group for changed custom fields and taxonomy terms, or * null when the context holds none of those changes. * * @param array $context Context that may contain post meta and term changes. * @return Event_Details_Group|null */ private function get_details_group_for_post_meta_and_terms( $context ) { $group = new Event_Details_Group(); $custom_fields_item = $this->get_details_item_for_custom_fields( $context ); if ( $custom_fields_item ) { $group->add_item( $custom_fields_item ); } $added_terms_item = $this->get_details_item_for_post_terms( $context, 'added' ); if ( $added_terms_item ) { $group->add_item( $added_terms_item ); } $removed_terms_item = $this->get_details_item_for_post_terms( $context, 'removed' ); if ( $removed_terms_item ) { $group->add_item( $removed_terms_item ); } if ( empty( $group->items ) ) { return null; } return $group; } /** * Build the "Custom fields" details item summarising added/removed/changed * post meta, or null when the context holds no meta changes. * * HTML keeps the existing bullet separated "Added: a, b and 3 more" style * used before the migration to the Event Details API. JSON exposes, per * bucket, the count and the sample of field names so REST/CLI/abilities * consumers get something structured instead of prose. * * @param array $context Context that may contain post_meta_* keys. * @return Event_Details_Item|null */ private function get_details_item_for_custom_fields( $context ) { if ( ! isset( $context['post_meta_added'] ) && ! isset( $context['post_meta_removed'] ) && ! isset( $context['post_meta_changed'] ) ) { return null; } $meta_buckets = [ 'added' => __( 'Added', 'simple-history' ), 'removed' => __( 'Removed', 'simple-history' ), 'changed' => __( 'Changed', 'simple-history' ), ]; $html_output = ''; $json_output = [ 'name' => __( 'Custom fields', 'simple-history' ), ]; foreach ( $meta_buckets as $meta_bucket => $meta_bucket_label ) { $meta_count_key = 'post_meta_' . $meta_bucket; if ( ! isset( $context[ $meta_count_key ] ) ) { continue; } $count = (int) $context[ $meta_count_key ]; $keys_json = (string) ( $context[ $meta_count_key . '_keys' ] ?? '' ); // Events logged before the names were stored only carry a count. $keys = json_decode( $keys_json, true ); $keys = is_array( $keys ) ? array_values( array_filter( $keys, 'is_string' ) ) : []; $html_output .= "<span class='SimpleHistoryLogitem__inlineDivided SimpleHistoryLogitem__inlineDivided--wrap'>" . esc_html( $this->get_custom_fields_summary( $meta_bucket_label, $count, $keys_json ) ) . '</span> '; $json_output[ $meta_bucket ] = [ 'count' => $count, 'names' => $keys, ]; } $formatter = ( new Event_Details_Item_Table_Row_RAW_Formatter() ) ->set_html_output( $html_output ) ->set_json_output( $json_output ); return ( new Event_Details_Item( null, __( 'Custom fields', 'simple-history' ) ) ) ->set_formatter( $formatter ); } /** * Build the "Added terms" / "Removed terms" details item, or null when * the context holds no term changes of that type. * * @param array $context Context that may contain post_terms_added/post_terms_removed. * @param string $type Type of term change, "added" or "removed". * @return Event_Details_Item|null */ private function get_details_item_for_post_terms( $context, $type ) { // Bail if type is not added or removed. if ( ! in_array( $type, [ 'added', 'removed' ], true ) ) { return null; } $post_terms = json_decode( $context[ "post_terms_{$type}" ] ?? '' ) ?? null; // Bail if no terms. if ( $post_terms === null || sizeof( $post_terms ) === 0 ) { return null; } if ( $type === 'added' ) { $label = _n( 'Added term', 'Added terms', sizeof( $post_terms ), 'simple-history' ); } else { $label = _n( 'Removed term', 'Removed terms', sizeof( $post_terms ), 'simple-history' ); } $terms_values = []; $terms_json = []; foreach ( $post_terms as $term ) { $taxonomy_name = get_taxonomy( $term->taxonomy )->labels->singular_name ?? ''; $terms_values[] = sprintf( '%1$s (%2$s)', $term->name, $taxonomy_name, ); $terms_json[] = [ 'name' => $term->name, 'taxonomy' => $term->taxonomy, ]; } $term_values_as_comma_separated_list = wp_sprintf( '%l', $terms_values ); $formatter = ( new Event_Details_Item_Table_Row_RAW_Formatter() ) ->set_html_output( esc_html( $term_values_as_comma_separated_list ) ) ->set_json_output( [ 'name' => $label, 'terms' => $terms_json, ] ); return ( new Event_Details_Item( null, $label ) ) ->set_formatter( $formatter ); } /** * Get the details group for a changed featured image, or null when the * context holds no featured image change. * * @param array $context Context that may contain prev- and new thumb ids. * @return Event_Details_Group|null */ private function get_details_group_for_post_thumb( $context ) { $prev_thumb_id = empty( $context['post_prev_thumb_id'] ) ? 0 : (int) $context['post_prev_thumb_id']; $new_thumb_id = empty( $context['post_new_thumb_id'] ) ? 0 : (int) $context['post_new_thumb_id']; if ( ! $prev_thumb_id && ! $new_thumb_id ) { return null; } $prev = $this->get_post_thumb_value( $prev_thumb_id, $context['post_prev_thumb_title'] ?? '' ); $new = $this->get_post_thumb_value( $new_thumb_id, $context['post_new_thumb_title'] ?? '' ); $item = new Event_Details_Item( null, __( 'Featured image', 'simple-history' ) ); $item->set_values( $new['plain'], $prev['plain'] ); $formatter = new Event_Details_Item_Image_Diff_Table_Row_Formatter(); $formatter->set_prev_image( $prev['src'], $prev['caption'] ); $formatter->set_new_image( $new['src'], $new['caption'] ); $item->set_formatter( $formatter ); $group = new Event_Details_Group(); $group->add_item( $item ); return $group; } /** * Resolve one featured image to an image URL, a caption, and a plain * text representation for JSON. * * @param int $thumb_id Attachment ID, 0 when there was no image on this side. * @param string $title Attachment title captured when the event was logged. * @return array{src: string, caption: string, plain: string} */ private function get_post_thumb_value( $thumb_id, $title ) { // No image on this side. Empty src and caption make the formatter print "None". if ( ! $thumb_id ) { return [ 'src' => '', 'caption' => '', 'plain' => __( 'None', 'simple-history' ), ]; } $attached_file = get_attached_file( $thumb_id ); $thumb_src = wp_get_attachment_image_src( $thumb_id, 'thumbnail' ); if ( $attached_file && file_exists( $attached_file ) && $thumb_src ) { return [ 'src' => $thumb_src[0], 'caption' => $title, 'plain' => $thumb_src[0], ]; } // Attachment is gone. Show the title captured at log time. return [ 'src' => '', 'caption' => $title !== '' ? $title : (string) $thumb_id, 'plain' => $title !== '' ? $title : (string) $thumb_id, ]; } /** * Fired when the "page_on_front" option is updated. * * Only logs during REST API requests (block editor). * Traditional admin changes are handled by the Options Logger, * and Customizer changes by the Theme Logger. * * @param mixed $old_value Previous value. * @param mixed $new_value New value. */ public function on_update_option_page_on_front( $old_value, $new_value ) { $this->log_page_role_change( $old_value, $new_value, 'homepage' ); } /** * Fired when the "page_for_posts" option is updated. * * Only logs during REST API requests (block editor). * Traditional admin changes are handled by the Options Logger, * and Customizer changes by the Theme Logger. * * @param mixed $old_value Previous value. * @param mixed $new_value New value. */ public function on_update_option_page_for_posts( $old_value, $new_value ) { $this->log_page_role_change( $old_value, $new_value, 'posts_page' ); } /** * Log when a page is set as or removed as the homepage or posts page. * * @param mixed $old_value Previous option value (page ID or 0). * @param mixed $new_value New option value (page ID or 0). * @param string $role Either 'homepage' or 'posts_page'. */ private function log_page_role_change( $old_value, $new_value, $role ) { // Only log during REST API requests (block editor). if ( ! Helpers::is_rest_request() ) { return; } $old_value = (int) $old_value; $new_value = (int) $new_value; // No change. if ( $old_value === $new_value ) { return; } $set_message_key = $role === 'homepage' ? 'page_set_as_homepage' : 'page_set_as_posts_page'; $removed_message_key = $role === 'homepage' ? 'page_removed_as_homepage' : 'page_removed_as_posts_page'; // A page was set. if ( ! empty( $new_value ) ) { $new_post = get_post( $new_value ); if ( ! $new_post instanceof \WP_Post ) { return; } $context = array( 'post_id' => $new_post->ID, 'post_type' => get_post_type( $new_post ), 'post_title' => get_the_title( $new_post ), ); // If changing from one page to another, include the old page info. if ( ! empty( $old_value ) ) { $old_post = get_post( $old_value ); if ( $old_post instanceof \WP_Post ) { $context['old_post_id'] = $old_post->ID; $context['old_post_title'] = get_the_title( $old_post ); } } $this->info_message( $set_message_key, $context ); } elseif ( ! empty( $old_value ) ) { // Page was removed (set to 0). $old_post = get_post( $old_value ); if ( ! $old_post instanceof \WP_Post ) { return; } $context = array( 'post_id' => $old_post->ID, 'post_type' => get_post_type( $old_post ), 'post_title' => get_the_title( $old_post ), ); $this->info_message( $removed_message_key, $context ); } } /** * Get the post from the HTTP referer if it points to a post editor. * * Useful for detecting when a post is created from within another post's * editor, e.g. via the Gutenberg link component. * * @return \WP_Post|null Post object or null if referer doesn't point to a valid post editor. */ private function get_referer_post() { $http_referer = wp_get_referer(); if ( ! $http_referer ) { return null; } $referer_query = wp_parse_url( $http_referer, PHP_URL_QUERY ); if ( ! $referer_query ) { return null; } $referer_args = []; wp_parse_str( $referer_query, $referer_args ); if ( empty( $referer_args['post'] ) || empty( $referer_args['action'] ) || $referer_args['action'] !== 'edit' ) { return null; } $post = get_post( (int) $referer_args['post'] ); return $post instanceof \WP_Post ? $post : null; } }
💾 保存
取消
保存为 UTF-8,换行统一为 LF