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

namespace Simple_History\Loggers;

use Simple_History\Event_Details\Event_Details_Group;
use Simple_History\Event_Details\Event_Details_Item;

/**
 * Logger for WordPress 6.9+ Notes feature (block comments).
 *
 * Logs collaborative notes that can be added to blocks in the WordPress editor.
 * Notes are stored as comments with comment_type='note' and linked to blocks
 * via the block's metadata.noteId attribute.
 *
 * @package SimpleHistory
 * @since 5.0.0
 */
class Notes_Logger extends Logger {
	/**
	 * Logger slug.
	 *
	 * @var string
	 */
	public $slug = 'NotesLogger';

	/**
	 * Return info about logger.
	 *
	 * @return array Array with logger info.
	 */
	public function get_info() {
		return [
			'name'        => _x( 'Notes Logger', 'Logger: Notes', 'simple-history' ),
			'description' => _x( 'Logs WordPress block notes (collaborative comments)', 'Logger: Notes', 'simple-history' ),
			'capability'  => 'edit_posts',
			'messages'    => [
				'note_added'       => _x( 'Added a note to {post_type} "{post_title}"', 'Logger: Notes', 'simple-history' ),
				'note_reply_added' => _x( 'Replied to a note in {post_type} "{post_title}"', 'Logger: Notes', 'simple-history' ),
				'note_edited'      => _x( 'Edited a note in {post_type} "{post_title}"', 'Logger: Notes', 'simple-history' ),
				'note_deleted'     => _x( 'Deleted a note from {post_type} "{post_title}"', 'Logger: Notes', 'simple-history' ),
				'note_resolved'    => _x( 'Marked a note as resolved in {post_type} "{post_title}"', 'Logger: Notes', 'simple-history' ),
				'note_reopened'    => _x( 'Reopened a resolved note in {post_type} "{post_title}"', 'Logger: Notes', 'simple-history' ),
			],
			'labels'      => [
				'search' => [
					'label'   => _x( 'Notes', 'Notes logger: search', 'simple-history' ),
					'options' => [
						_x( 'Added notes', 'Notes logger: search', 'simple-history' ) => [
							'note_added',
						],
						_x( 'Replied to notes', 'Notes logger: search', 'simple-history' ) => [
							'note_reply_added',
						],
						_x( 'Edited notes', 'Notes logger: search', 'simple-history' ) => [
							'note_edited',
						],
						_x( 'Deleted notes', 'Notes logger: search', 'simple-history' ) => [
							'note_deleted',
						],
						_x( 'Resolved notes', 'Notes logger: search', 'simple-history' ) => [
							'note_resolved',
						],
						_x( 'Reopened notes', 'Notes logger: search', 'simple-history' ) => [
							'note_reopened',
						],
					],
				],
			],
		];
	}

	/**
	 * Called when logger is loaded.
	 */
	public function loaded() {
		// Hook into comment actions to track notes.
		add_action( 'wp_insert_comment', [ $this, 'on_wp_insert_comment' ], 10, 2 );
		add_action( 'edit_comment', [ $this, 'on_edit_comment' ], 10, 1 );
		add_action( 'updated_comment_meta', [ $this, 'on_updated_comment_meta' ], 10, 4 );
		add_action( 'added_comment_meta', [ $this, 'on_updated_comment_meta' ], 10, 4 );
		add_action( 'delete_comment', [ $this, 'on_delete_comment' ], 10, 2 );
		add_action( 'trash_comment', [ $this, 'on_delete_comment' ], 10, 2 );
	}

	/**
	 * Get output for detailed log section.
	 *
	 * @param object $row Log row.
	 * @return Event_Details_Group
	 */
	public function get_log_row_details_output( $row ) {
		return ( new Event_Details_Group() )
			->add_items(
				[
					new Event_Details_Item(
						'note_content',
						_x( 'Content', 'Notes logger - detailed output', 'simple-history' ),
					),
				]
			);
	}

	/**
	 * Log when a note is created.
	 *
	 * @param int              $comment_id The comment ID.
	 * @param \WP_Comment|null $comment    Comment object.
	 */
	public function on_wp_insert_comment( $comment_id, $comment = null ) {
		if ( empty( $comment_id ) ) {
			return;
		}

		if ( ! $comment ) {
			$comment = get_comment( $comment_id );
		}

		if ( ! $this->is_note_comment( $comment ) ) {
			return;
		}

		$comment_content = $this->normalize_note_content( $comment->comment_content );

		// Skip if this comment has no content.
		// Empty comments are status markers (resolved/reopened) that will be logged
		// separately by on_updated_comment_meta when the _wp_note_status meta is added.
		if ( empty( $comment_content ) ) {
			return;
		}

		$post = get_post( $comment->comment_post_ID );
		if ( ! $post ) {
			return;
		}

		$is_reply   = $comment->comment_parent > 0;
		$block_info = $this->get_block_info_for_note( $comment_id, $comment->comment_post_ID );

		$context = [
			'note_id'      => $comment_id,
			'post_id'      => $comment->comment_post_ID,
			'post_type'    => get_post_type( $post ),
			'post_title'   => $post->post_title,
			'note_content' => $comment_content,
			'is_reply'     => $is_reply,
		];

		// Add block information if available.
		if ( $block_info ) {
			$context['block_type']            = $block_info['block_type'];
			$context['block_content_preview'] = $block_info['content_preview'];
			$context['block_count']           = $block_info['block_count'];
		}

		// Choose appropriate message key.
		$message = $is_reply ? 'note_reply_added' : 'note_added';

		$this->info_message( $message, $context );
	}

	/**
	 * Log when a note is edited.
	 *
	 * @param int $comment_id The comment ID.
	 */
	public function on_edit_comment( $comment_id ) {
		if ( empty( $comment_id ) ) {
			return;
		}

		$comment = get_comment( $comment_id );

		if ( ! $this->is_note_comment( $comment ) ) {
			return;
		}

		$post = get_post( $comment->comment_post_ID );
		if ( ! $post ) {
			return;
		}

		$context = [
			'note_id'      => $comment_id,
			'post_id'      => $comment->comment_post_ID,
			'post_type'    => get_post_type( $post ),
			'post_title'   => $post->post_title,
			'note_content' => $this->normalize_note_content( $comment->comment_content ),
		];

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

	/**
	 * Action links for a note event: the same links as the post or page the
	 * note sits on, since acting on a note means opening that post.
	 *
	 * Delegates to the post logger so the two never drift. The revision link
	 * only appears for post_updated events there, and a note event has a
	 * note_* message key, so it stays out here by construction.
	 *
	 * @param object $row Log row.
	 * @return array Action links.
	 */
	public function get_action_links( $row ) {
		$post_logger = $this->simple_history->get_instantiated_logger_by_slug( 'SimplePostLogger' );

		if ( ! $post_logger instanceof Post_Logger ) {
			return [];
		}

		return $post_logger->get_action_links( $row );
	}

	/**
	 * Normalize note content before it goes into the event context.
	 *
	 * WordPress stores a trailing <br> when a note is anchored to inline text
	 * rather than a whole block. Since context values are escaped on output,
	 * any stored markup would show up as literal text in the event details.
	 *
	 * @param string $content Raw comment content.
	 * @return string Plain-text content with line breaks preserved.
	 */
	private function normalize_note_content( $content ) {
		// Turn <br> into a newline before stripping tags. Stripping alone would
		// glue the surrounding words together ("First line<br>Second" becomes
		// "First lineSecond"). The trim below drops the trailing newline that
		// inline notes carry.
		$content = preg_replace( '#<br\s*/?>#i', "\n", (string) $content );

		// A mention is stored as a span with the next word right after it,
		// "<span class=\"wp-note-mention user-2\">@name</span>nice!", and the
		// editor draws the gap with CSS. Put a real space there so stripping
		// the span does not glue "@name" to "nice!".
		//
		// Match the chip the way core's wp_get_note_mentioned_user_ids() does:
		// a span whose class list contains the wp-note-mention token, whatever
		// the token order, attribute order, quote style or tag case.
		$content = preg_replace(
			'#(<span\b[^>]*\bclass\s*=\s*(["\'])(?:[^"\']*\s)?wp-note-mention(?:\s[^"\']*)?\2[^>]*>[^<]*</span>)(?=\S)#i',
			'$1 ',
			$content
		);

		// Entities such as &lt; stay encoded, as WordPress stored them. Decoding
		// here would turn a kses-neutralised payload from a Contributor back
		// into live markup that every sink must then escape; the text sinks
		// (export, CLI, REST message) decode at output time instead.
		$content = wp_strip_all_tags( $content );

		return trim( $content );
	}

	/**
	 * Log when a note is resolved or reopened.
	 *
	 * Fires when the _wp_note_status comment meta is added or updated.
	 * The status can be 'resolved' (note marked as done) or 'reopen' (note reopened).
	 *
	 * @param int    $meta_id    ID of updated metadata entry.
	 * @param int    $comment_id Comment ID.
	 * @param string $meta_key   Meta key.
	 * @param mixed  $meta_value Meta value (either 'resolved' or 'reopen').
	 */
	public function on_updated_comment_meta( $meta_id, $comment_id, $meta_key, $meta_value ) {
		if ( empty( $comment_id ) ) {
			return;
		}

		if ( $meta_key !== '_wp_note_status' ) {
			return;
		}

		$comment = get_comment( $comment_id );

		if ( ! $this->is_note_comment( $comment ) ) {
			return;
		}

		$post = get_post( $comment->comment_post_ID );
		if ( ! $post ) {
			return;
		}

		$context = [
			'note_id'    => $comment_id,
			'post_id'    => $comment->comment_post_ID,
			'post_type'  => get_post_type( $post ),
			'post_title' => $post->post_title,
		];

		// Determine message based on new status.
		if ( $meta_value === 'resolved' ) {
			$this->info_message( 'note_resolved', $context );
		} elseif ( $meta_value === 'reopen' ) {
			$this->info_message( 'note_reopened', $context );
		}
	}

	/**
	 * Log when a note is deleted or trashed.
	 *
	 * Handles both permanent deletion and trashing (e.g., via REST API).
	 *
	 * @param int              $comment_id The comment ID.
	 * @param \WP_Comment|null $comment    Comment object (optional, added in WP 6.2).
	 */
	public function on_delete_comment( $comment_id, $comment = null ) {
		if ( empty( $comment_id ) ) {
			return;
		}

		// The $comment parameter was added in WordPress 6.2.
		// For backwards compatibility, fetch it if not provided.
		if ( ! $comment ) {
			$comment = get_comment( $comment_id );
		}

		if ( ! $this->is_note_comment( $comment ) ) {
			return;
		}

		$post = get_post( $comment->comment_post_ID );
		if ( ! $post ) {
			return;
		}

		$block_info = $this->get_block_info_for_note( $comment_id, $comment->comment_post_ID );

		$context = [
			'note_id'    => $comment_id,
			'post_id'    => $comment->comment_post_ID,
			'post_type'  => get_post_type( $post ),
			'post_title' => $post->post_title,
		];

		// Add block information if available.
		if ( $block_info ) {
			$context['block_type']            = $block_info['block_type'];
			$context['block_content_preview'] = $block_info['content_preview'];
		}

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

	/**
	 * Check if a comment is a note.
	 *
	 * @param \WP_Comment|null $comment The comment object.
	 * @return bool True if this is a note, false otherwise.
	 */
	private function is_note_comment( $comment ) {
		return $comment && $comment->comment_type === 'note';
	}

	/**
	 * Get the root note ID by walking up the parent chain.
	 *
	 * WordPress stores only the root note ID in block metadata.noteId.
	 * For threaded notes (replies), we need to find the root.
	 *
	 * @param int $note_id The note (comment) ID.
	 * @return int The root note ID.
	 */
	private function get_root_note_id( $note_id ) {
		$comment = get_comment( $note_id );
		if ( ! $comment ) {
			return $note_id;
		}

		// Walk up the parent chain until we find the root.
		while ( $comment->comment_parent > 0 ) {
			$parent = get_comment( $comment->comment_parent );
			if ( ! $parent ) {
				break;
			}
			$comment = $parent;
		}

		return (int) $comment->comment_ID;
	}

	/**
	 * Get block information for a note.
	 *
	 * Parses the post content to find the block(s) that reference this note
	 * via their metadata.noteId attribute. For threaded notes (replies), walks
	 * up the parent chain to find the root note ID, since only the root note
	 * ID is stored in the block metadata.
	 *
	 * @param int $note_id The note (comment) ID.
	 * @param int $post_id The post ID.
	 * @return array|null Array with block info, or null if block not found.
	 */
	private function get_block_info_for_note( $note_id, $post_id ) {
		$post = get_post( $post_id );
		if ( ! $post ) {
			return null;
		}

		// Find the root note ID by walking up the parent chain.
		// Only the root note ID is stored in block metadata.
		$root_note_id = $this->get_root_note_id( $note_id );

		$blocks       = parse_blocks( $post->post_content );
		$found_blocks = $this->find_blocks_by_note_id( $blocks, $root_note_id );

		if ( empty( $found_blocks ) ) {
			return null; // Block might have been deleted.
		}

		$block = $found_blocks[0];

		// Extract block type (remove 'core/' prefix if present).
		$block_type = $block['blockName'];
		if ( strpos( $block_type, 'core/' ) === 0 ) {
			$block_type = substr( $block_type, 5 );
		}

		// Get content preview.
		$content         = wp_strip_all_tags( $block['innerHTML'] );
		$content         = trim( $content );
		$content_preview = $content;

		// Truncate to 100 characters.
		if ( strlen( $content ) > 100 ) {
			$content_preview = substr( $content, 0, 100 ) . '...';
		}

		return [
			'block_type'      => $block_type,
			'block_name'      => $block['blockName'],
			'content_preview' => $content_preview,
			'full_content'    => $content,
			'block_count'     => count( $found_blocks ),
			'attrs'           => $block['attrs'],
		];
	}

	/**
	 * Recursively find blocks with a specific noteId.
	 *
	 * @param array $blocks  Array of parsed blocks.
	 * @param int   $note_id The note ID to search for.
	 * @return array Array of blocks that reference this note.
	 */
	private function find_blocks_by_note_id( $blocks, $note_id ) {
		$found = [];

		foreach ( $blocks as $block ) {
			// Check if this block has the noteId in its metadata.
			if ( isset( $block['attrs']['metadata']['noteId'] ) &&
				$block['attrs']['metadata']['noteId'] === $note_id ) {
				$found[] = $block;
			}

			// Recursively search inner blocks.
			if ( empty( $block['innerBlocks'] ) ) {
				continue;
			}

			$found = array_merge(
				$found,
				$this->find_blocks_by_note_id( $block['innerBlocks'], $note_id )
			);
		}

		return $found;
	}
}