*/ private static function fields(): array { return array( 'post_title' => __( 'Title', 'handymcp' ), 'post_status' => __( 'Status', 'handymcp' ), 'post_name' => __( 'Slug', 'handymcp' ), 'post_date' => __( 'Publish date', 'handymcp' ), 'post_excerpt' => __( 'Excerpt', 'handymcp' ), 'post_parent' => __( 'Parent', 'handymcp' ), 'menu_order' => __( 'Order', 'handymcp' ), ); } /** * Meta keys with readable names. * * @return array */ private static function meta_names(): array { $seo = __( 'SEO title', 'handymcp' ); $desc = __( 'Meta description', 'handymcp' ); $kw = __( 'Focus keyword', 'handymcp' ); return array( '_wp_attachment_image_alt' => __( 'Alt text', 'handymcp' ), '_thumbnail_id' => __( 'Featured image', 'handymcp' ), '_wp_page_template' => __( 'Template', 'handymcp' ), 'rank_math_title' => $seo, 'rank_math_description' => $desc, 'rank_math_focus_keyword' => $kw, '_yoast_wpseo_title' => $seo, '_yoast_wpseo_metadesc' => $desc, '_yoast_wpseo_focuskw' => $kw, '_aioseo_title' => $seo, '_aioseo_description' => $desc, '_regular_price' => __( 'Regular price', 'handymcp' ), '_sale_price' => __( 'Sale price', 'handymcp' ), '_sku' => __( 'SKU', 'handymcp' ), '_stock' => __( 'Stock', 'handymcp' ), '_stock_status' => __( 'Stock status', 'handymcp' ), '_manage_stock' => __( 'Manage stock', 'handymcp' ), ); } /** Meta that never shows as a change of its own. */ const QUIET = array( '_edit_lock', '_edit_last', '_elementor_css', '_elementor_element_cache', '_elementor_page_assets', '_elementor_controls_usage', '_elementor_version', '_elementor_pro_version', '_elementor_screenshot', '_elementor_screenshot_failed', '_wp_old_slug', '_wp_old_date', '_encloseme', '_pingme', '_price', '_wp_attachment_metadata', '_wp_attached_file', '_wp_attachment_backup_sizes', '_handymcp_replace_backup' ); /** * Is this a valid operation id? * * @param string $op Operation id. */ public static function valid_op( string $op ): bool { return (bool) preg_match( '/^op_\d{12}_[a-f0-9]{6}$/', $op ); } /** * Everything one change did, item by item. * * @param string $op Operation id. * @param bool $files Include file contents (admin screen only). * @return array}> */ public static function build( string $op, bool $files = false ): array { $out = array(); $rows = History::op_rows( $op ); $created = array(); foreach ( $rows as $row ) { if ( 'created_post' === $row['kind'] ) { $created[ (int) ( $row['data']['id'] ?? $row['ref'] ) ] = true; } } foreach ( $rows as $row ) { // A post this change created has no "before": the creation line says it all. if ( 'post' === $row['kind'] && isset( $created[ (int) $row['ref'] ] ) ) { continue; } $item = self::item( $row, $files ); if ( $item ) { $out[] = $item; } } return $out; } /** * One recorded item. * * @param array $row Row. * @param bool $files Include file contents. * @return array|null */ private static function item( array $row, bool $files ): ?array { $d = (array) $row['data']; $base = array( 'label' => (string) $row['label'], 'note' => '', 'link' => '', 'fields' => array() ); switch ( (string) $row['kind'] ) { case 'post': return self::post_item( (int) $row['ref'], $d, $base ); case 'created_post': $id = (int) ( $d['id'] ?? $row['ref'] ); $p = get_post( $id ); if ( $p && current_user_can( 'read_post', $id ) ) { $base['label'] = self::type_name( $p->post_type ) . ' "' . $p->post_title . '"'; $base['link'] = (string) get_edit_post_link( $id, 'raw' ); } $base['note'] = $p ? __( 'Created by this change.', 'handymcp' ) : __( 'Created by this change, and deleted since.', 'handymcp' ); return $base; case 'created_term': $t = get_term( (int) ( $d['id'] ?? $row['ref'] ), (string) ( $d['taxonomy'] ?? '' ) ); $base['note'] = $t instanceof \WP_Term ? __( 'Created by this change.', 'handymcp' ) : __( 'Created by this change, and deleted since.', 'handymcp' ); return $base; case 'term': $before = (array) ( $d['term'] ?? array() ); $now = get_term( (int) $row['ref'] ); $now = $now instanceof \WP_Term ? (array) $now : array(); if ( ! $now ) { $base['note'] = __( 'Deleted since.', 'handymcp' ); } foreach ( array( 'name' => __( 'Name', 'handymcp' ), 'slug' => __( 'Slug', 'handymcp' ), 'description' => __( 'Description', 'handymcp' ), 'parent' => __( 'Parent', 'handymcp' ) ) as $k => $name ) { self::add( $base['fields'], $name, (string) ( $before[ $k ] ?? '' ), $now ? (string) ( $now[ $k ] ?? '' ) : '', 'description' === $k ); } return $base; case 'option': $name = (string) $row['ref']; $base['label'] = sprintf( /* translators: %s: setting name. */ __( 'Setting %s', 'handymcp' ), $name ); if ( Redactor::is_secret_key( $name ) ) { $base['note'] = __( 'Changed. The value is hidden because it looks like a key or password.', 'handymcp' ); return $base; } $missing = new \stdClass(); $now = get_option( $name, $missing ); self::add( $base['fields'], __( 'Value', 'handymcp' ), ! empty( $d['missing'] ) ? '' : self::text( $d['value'] ?? '' ), $now === $missing ? '' : self::text( $now ), true ); return $base; case 'theme_mods': $before = (array) ( $d['mods'] ?? array() ); if ( (string) ( $d['stylesheet'] ?? '' ) !== get_stylesheet() ) { $base['note'] = __( 'Another theme is active now, so these settings cannot be compared.', 'handymcp' ); return $base; } $now = (array) get_theme_mods(); foreach ( array_unique( array_merge( array_keys( $before ), array_keys( $now ) ) ) as $k ) { $k = (string) $k; $b = array_key_exists( $k, $before ) ? self::text( $before[ $k ] ) : ''; $a = array_key_exists( $k, $now ) ? self::text( $now[ $k ] ) : ''; if ( $b === $a ) { continue; } if ( Redactor::is_secret_key( $k ) ) { $base['fields'][] = array( 'name' => $k, 'before' => Redactor::MASK, 'after' => Redactor::MASK, 'long' => false ); continue; } self::add( $base['fields'], $k, $b, $a, false ); } return $base; case 'file': $rel = (string) ( $d['path'] ?? $row['ref'] ); if ( ! $files ) { $base['note'] = __( 'A code file was changed. Open the change in HandyMCP to compare it.', 'handymcp' ); return $base; } $path = wp_normalize_path( ABSPATH . ltrim( $rel, '/' ) ); $root = trailingslashit( wp_normalize_path( (string) realpath( ABSPATH ) ) ); $real = file_exists( $path ) ? wp_normalize_path( (string) realpath( $path ) ) : ''; if ( '' !== $real && 0 !== strpos( $real, $root ) ) { $base['note'] = __( 'The file is outside the site folder.', 'handymcp' ); return $base; } $before = ! empty( $d['missing'] ) ? '' : (string) base64_decode( (string) ( $d['content'] ?? '' ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode $now = '' !== $real && is_readable( $real ) && filesize( $real ) <= 1000000 ? (string) file_get_contents( $real ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if ( strlen( $before ) > 1000000 || false !== strpos( $before . $now, "\0" ) ) { $base['note'] = __( 'The file is too large or not text, so it cannot be compared here.', 'handymcp' ); return $base; } if ( '' === $real ) { $base['note'] = __( 'The file does not exist now.', 'handymcp' ); } self::add( $base['fields'], __( 'Code', 'handymcp' ), Redactor::scrub_string( $before ), Redactor::scrub_string( $now ), true ); return $base; case 'user': $u = get_userdata( (int) $row['ref'] ); foreach ( array( 'display_name' => __( 'Display name', 'handymcp' ), 'first_name' => __( 'First name', 'handymcp' ), 'last_name' => __( 'Last name', 'handymcp' ) ) as $k => $name ) { self::add( $base['fields'], $name, (string) ( $d[ $k ] ?? '' ), $u ? (string) $u->$k : '', false ); } self::add( $base['fields'], __( 'Role', 'handymcp' ), implode( ', ', array_map( 'strval', (array) ( $d['roles'] ?? array() ) ) ), $u ? implode( ', ', $u->roles ) : '', false ); if ( isset( $d['user_email'] ) && $u && (string) $d['user_email'] !== $u->user_email ) { $base['fields'][] = array( 'name' => __( 'Email', 'handymcp' ), 'before' => Redactor::mask_email( (string) $d['user_email'] ), 'after' => Redactor::mask_email( $u->user_email ), 'long' => false ); } return $base; case 'plugin_state': if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $file = (string) ( $d['file'] ?? $row['ref'] ); self::add( $base['fields'], __( 'Active', 'handymcp' ), ! empty( $d['active'] ) ? __( 'Yes', 'handymcp' ) : __( 'No', 'handymcp' ), is_plugin_active( $file ) ? __( 'Yes', 'handymcp' ) : __( 'No', 'handymcp' ), false ); return $base; case 'active_theme': self::add( $base['fields'], __( 'Active theme', 'handymcp' ), (string) ( $d['stylesheet'] ?? '' ), get_stylesheet(), false ); return $base; default: $base['note'] = __( 'This kind of change has no preview, but it can still be undone.', 'handymcp' ); return $base; } } /** * A post, page, product or media item. * * @param int $id Post ID. * @param array $d Snapshot. * @param array $base Item. * @return array */ private static function post_item( int $id, array $d, array $base ): array { $bp = (array) ( $d['post'] ?? array() ); $p = get_post( $id ); if ( ! $p && ! current_user_can( 'edit_others_posts' ) && (int) ( $bp['post_author'] ?? 0 ) !== get_current_user_id() ) { $base['note'] = __( 'Deleted since.', 'handymcp' ); return $base; } if ( $p && ! current_user_can( 'read_post', $id ) ) { $base['note'] = __( 'You cannot view this item.', 'handymcp' ); return $base; } $now = $p ? $p->to_array() : array(); if ( $p ) { $base['link'] = (string) get_edit_post_link( $id, 'raw' ); if ( isset( $d['_after'] ) && History::state( $id ) !== (string) $d['_after'] ) { $base['note'] = __( 'This item was edited again later, so "Now" also shows those later edits.', 'handymcp' ); } } else { $base['note'] = __( 'Deleted since.', 'handymcp' ); } if ( ! empty( $bp['post_password'] ) || ! empty( $now['post_password'] ) ) { if ( (string) ( $bp['post_password'] ?? '' ) !== (string) ( $now['post_password'] ?? '' ) ) { $base['fields'][] = array( 'name' => __( 'Password', 'handymcp' ), 'before' => '' !== (string) ( $bp['post_password'] ?? '' ) ? Redactor::MASK : '', 'after' => '' !== (string) ( $now['post_password'] ?? '' ) ? Redactor::MASK : '', 'long' => false ); } } $drafts = array( 'draft', 'auto-draft', 'pending' ); foreach ( self::fields() as $k => $name ) { // A draft's date moves with every save, so it is not a real change. if ( 'post_date' === $k && in_array( (string) ( $bp['post_status'] ?? '' ), $drafts, true ) && in_array( (string) ( $now['post_status'] ?? '' ), $drafts, true ) ) { continue; } $b = (string) ( $bp[ $k ] ?? '' ); $a = (string) ( $now[ $k ] ?? '' ); if ( 'post_parent' === $k ) { $b = $b ? get_the_title( (int) $b ) . ' (#' . (int) $b . ')' : ''; $a = $a ? get_the_title( (int) $a ) . ' (#' . (int) $a . ')' : ''; } self::add( $base['fields'], $name, $b, $a, 'post_excerpt' === $k ); } $bmeta = (array) ( $d['meta_raw'] ?? ( $d['meta'] ?? array() ) ); $nmeta = $p ? (array) get_post_meta( $id ) : array(); $bc = (string) ( $bp['post_content'] ?? '' ); $nc = (string) ( $now['post_content'] ?? '' ); $bt = self::readable( $bc, self::first( $bmeta, '_elementor_data' ) ); $nt = self::readable( $nc, self::first( $nmeta, '_elementor_data' ) ); if ( $bt !== $nt ) { self::add( $base['fields'], __( 'Content', 'handymcp' ), $bt, $nt, true ); } elseif ( $bc !== $nc || self::first( $bmeta, '_elementor_data' ) !== self::first( $nmeta, '_elementor_data' ) ) { $base['fields'][] = array( 'name' => __( 'Layout', 'handymcp' ), 'before' => '', 'after' => __( 'Design or layout changed, the text is the same.', 'handymcp' ), 'long' => false ); } $other = 0; $known = self::meta_names(); foreach ( array_unique( array_merge( array_keys( $bmeta ), array_keys( $nmeta ) ) ) as $k ) { $k = (string) $k; if ( in_array( $k, self::QUIET, true ) || '_elementor_data' === $k ) { continue; } $b = self::first( $bmeta, $k ); $a = self::first( $nmeta, $k ); if ( $b === $a ) { continue; } if ( isset( $known[ $k ] ) ) { if ( '_thumbnail_id' === $k ) { $b = $b ? get_the_title( (int) $b ) . ' (#' . (int) $b . ')' : ''; $a = $a ? get_the_title( (int) $a ) . ' (#' . (int) $a . ')' : ''; } self::add( $base['fields'], $known[ $k ], $b, $a, false ); continue; } // Custom fields made with ACF (they have a "_name" reference to the field); other plugin data is only counted. $acf = 0 === strpos( self::first( $nmeta, '_' . $k ) . self::first( $bmeta, '_' . $k ), 'field_' ); if ( $acf && '_' !== substr( $k, 0, 1 ) && ! Redactor::is_secret_key( $k ) && ! is_serialized( $b ) && ! is_serialized( $a ) && strlen( $b . $a ) < 2000 ) { self::add( $base['fields'], $k, $b, $a, false ); continue; } ++$other; } $bterms = (array) ( $d['terms'] ?? array() ); foreach ( array_keys( $bterms ) as $tax ) { $tax = (string) $tax; if ( ! taxonomy_exists( $tax ) || ! $p ) { continue; } $names = static function ( array $ids ) use ( $tax ): string { $n = array(); foreach ( array_map( 'intval', $ids ) as $tid ) { $t = get_term( $tid, $tax ); $n[] = $t instanceof \WP_Term ? $t->name : '#' . $tid; } sort( $n ); return implode( ', ', $n ); }; $now_ids = wp_get_object_terms( $id, $tax, array( 'fields' => 'ids' ) ); $tobj = get_taxonomy( $tax ); self::add( $base['fields'], $tobj ? (string) $tobj->labels->name : $tax, $names( (array) $bterms[ $tax ] ), is_wp_error( $now_ids ) ? '' : $names( (array) $now_ids ), false ); } if ( $other ) { /* translators: %d: number of settings. */ $base['fields'][] = array( 'name' => __( 'Other settings', 'handymcp' ), 'before' => '', 'after' => sprintf( _n( '%d hidden or technical setting changed.', '%d hidden or technical settings changed.', $other, 'handymcp' ), $other ), 'long' => false ); } if ( ! $base['fields'] && $p && 'attachment' === $p->post_type ) { $base['note'] = __( 'Only the image file or its thumbnails changed, so there is no text to compare.', 'handymcp' ); } elseif ( ! $base['fields'] && $p ) { $base['note'] = __( 'Nothing differs from now: this change was undone or reversed later.', 'handymcp' ); } return $base; } /** * First stored value of a meta key, unserialized to text. * * @param array $meta Meta. * @param string $key Key. */ private static function first( array $meta, string $key ): string { if ( ! isset( $meta[ $key ] ) ) { return ''; } $v = (array) $meta[ $key ]; return self::text( maybe_unserialize( reset( $v ) ) ); } /** * Any value as short readable text. * * @param mixed $v Value. */ private static function text( $v ): string { if ( is_bool( $v ) ) { return $v ? 'true' : 'false'; } if ( is_scalar( $v ) || null === $v ) { return (string) $v; } return (string) wp_json_encode( Redactor::redact( $v ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } /** * Add a field when it changed. * * @param array> $fields List. * @param string $name Name. * @param string $before Before. * @param string $after Now. * @param bool $long Show as a line by line comparison. */ private static function add( array &$fields, string $name, string $before, string $after, bool $long ): void { if ( $before === $after ) { return; } $fields[] = array( 'name' => $name, 'before' => Redactor::scrub_string( mb_substr( $before, 0, self::MAX_TEXT ) ), 'after' => Redactor::scrub_string( mb_substr( $after, 0, self::MAX_TEXT ) ), 'long' => $long || false !== strpos( $before . $after, "\n" ) || strlen( $before . $after ) > 300, ); } /** * Visible text of a page: post content plus Elementor texts. * * @param string $content Post content. * @param string $elementor Elementor JSON. */ private static function readable( string $content, string $elementor ): string { $text = (string) preg_replace( '//s', '', $content ); $text = (string) preg_replace( '#|#i', "\n", $text ); $lines = array_map( 'trim', explode( "\n", wp_strip_all_tags( $text ) ) ); $data = '' !== $elementor ? json_decode( $elementor, true ) : null; if ( is_array( $data ) ) { $walk = static function ( array $els ) use ( &$walk, &$lines ) { foreach ( $els as $e ) { if ( ! is_array( $e ) ) { continue; } foreach ( (array) ( $e['settings'] ?? array() ) as $k => $v ) { if ( is_string( $v ) && strlen( trim( $v ) ) > 1 && ! preg_match( '/^(_|css|html_tag|link|url|image|background|align|size|color|typography|icon|layout|width|height|gap|flex|position|z_index|animation|hover|motion)/', (string) $k ) && ! preg_match( '#^(https?:)?//#', $v ) && ! preg_match( '/^[\d.#%a-z_-]+$/i', $v ) ) { $lines[] = trim( wp_strip_all_tags( $v ) ); } } if ( ! empty( $e['elements'] ) && is_array( $e['elements'] ) ) { $walk( $e['elements'] ); } } }; $walk( $data ); } return implode( "\n", array_values( array_filter( $lines, 'strlen' ) ) ); } /** * Readable post type name. * * @param string $type Post type. */ private static function type_name( string $type ): string { $o = get_post_type_object( $type ); return $o && ! empty( $o->labels->singular_name ) ? (string) $o->labels->singular_name : ucfirst( $type ); } /** * Line by line comparison table, only changed lines with one line around them. * * @param string $before Before. * @param string $after Now. */ public static function diff_html( string $before, string $after ): string { if ( ! class_exists( 'WP_Text_Diff_Renderer_Table', false ) ) { require_once ABSPATH . WPINC . '/wp-diff.php'; } // Very long texts: compare the first 200 lines, so the page stays fast. $left = explode( "\n", normalize_whitespace( $before ) ); $right = explode( "\n", normalize_whitespace( $after ) ); $cut = count( $left ) > 200 || count( $right ) > 200; $d = new \Text_Diff( array_slice( $left, 0, 200 ), array_slice( $right, 0, 200 ) ); $renderer = new \WP_Text_Diff_Renderer_Table( array( 'show_split_view' => true ) ); $renderer->_leading_context_lines = 1; $renderer->_trailing_context_lines = 1; $rows = $renderer->render( $d ); if ( ! $rows ) { return ''; } $more = $cut ? '

' . esc_html__( 'Only the first 200 lines are compared here.', 'handymcp' ) . '

' : ''; return $more . '' . $rows . '
' . esc_html__( 'Before', 'handymcp' ) . '' . esc_html__( 'Now', 'handymcp' ) . '
'; } /** * Only the lines that differ between two texts. * * @param string $before Before. * @param string $after Now. * @return array{0: string, 1: string} */ private static function changed_lines( string $before, string $after ): array { if ( ! class_exists( 'Text_Diff', false ) ) { require_once ABSPATH . WPINC . '/wp-diff.php'; } $diff = new \Text_Diff( explode( "\n", normalize_whitespace( $before ) ), explode( "\n", normalize_whitespace( $after ) ) ); $old = array(); $new = array(); foreach ( $diff->getDiff() as $edit ) { if ( $edit instanceof \Text_Diff_Op_copy ) { continue; } $old = array_merge( $old, (array) $edit->orig ); $new = array_merge( $new, (array) $edit->final ); } $keep = static function ( $line ): bool { return is_string( $line ) && '' !== trim( $line ); }; return array( implode( "\n", array_filter( $old, $keep ) ), implode( "\n", array_filter( $new, $keep ) ) ); } /** * Changes in plain form for AI apps (shorter texts, no file contents). * * @param string $op Operation id. * @return array> */ public static function for_ai( string $op ): array { $out = array(); foreach ( self::build( $op, false ) as $item ) { $fields = array(); foreach ( $item['fields'] as $f ) { if ( ! empty( $f['long'] ) ) { list( $b, $a ) = self::changed_lines( $f['before'], $f['after'] ); $fields[] = array( 'field' => $f['name'], 'removed' => mb_substr( $b, 0, 1500 ), 'added' => mb_substr( $a, 0, 1500 ) ); continue; } $fields[] = array( 'field' => $f['name'], 'before' => mb_substr( $f['before'], 0, 1500 ), 'now' => mb_substr( $f['after'], 0, 1500 ) ); } $out[] = array_filter( array( 'item' => $item['label'], 'note' => $item['note'], 'changes' => $fields ) ); } return $out; } }