log = $log ?? new ChangeLog(); $this->indexer = $indexer ?? new Indexer(); } /** * Compute (dry run) or apply changes to a source. * * @param string $type Source type. * @param int $id Source ID. * @param string $key Source key (meta key / option name / comment field). * @param array $map Normalized old URL => new URL or null (unlink). * @param bool $apply Write changes (false = dry run). * @param string $batch Batch ID for the change log. * @param string $action Action name for the change log. * @return array{changed:int,note:string} */ public function process( string $type, int $id, string $key, array $map, bool $apply, string $batch = '', string $action = '' ): array { switch ( $type ) { case PostContentExtractor::SOURCE_TYPE: return $this->post_content( $id, $map, $apply, $batch, $action ); case ElementorExtractor::SOURCE_TYPE: return $this->elementor( $id, $map, $apply, $batch, $action ); case MenuExtractor::SOURCE_TYPE: return $this->menu( $id, $map, $apply, $batch, $action ); case WidgetExtractor::SOURCE_TYPE: return $this->widget( $id, $key, $map, $apply, $batch, $action ); case CommentExtractor::SOURCE_TYPE: return $this->comment( $id, $key, $map, $apply, $batch, $action ); } return self::result( 0, __( 'This source type cannot be edited automatically.', 'unbreak' ) ); } /** * Post content: rewrite and save with wp_update_post() so a revision is created. * * @param int $id Post ID. * @param array $map Map. * @param bool $apply Apply. * @param string $batch Batch. * @param string $action Action. * @return array{changed:int,note:string} */ private function post_content( int $id, array $map, bool $apply, string $batch, string $action ): array { $post = get_post( $id ); if ( ! $post instanceof \WP_Post ) { return self::result( 0, __( 'The post no longer exists.', 'unbreak' ) ); } $before = (string) $post->post_content; $result = HtmlRewriter::rewrite_content( $before, $map, self::base( $post ) ); if ( 0 === $result['count'] || ! $apply ) { return self::result( $result['count'], 0 === $result['count'] ? self::not_found_note( $map ) : '' ); } $this->save_post_content( $id, $result['html'] ); $this->log->record( $batch, $action, PostContentExtractor::SOURCE_TYPE, $id, '', $before, $result['html'], array_keys( $map ) ); $this->indexer->index_post( $id ); return self::result( $result['count'], '' ); } /** * Elementor: rewrite the JSON tree (and Elementor's rendered copy in post_content). * * @param int $id Post ID. * @param array $map Map. * @param bool $apply Apply. * @param string $batch Batch. * @param string $action Action. * @return array{changed:int,note:string} */ private function elementor( int $id, array $map, bool $apply, string $batch, string $action ): array { $post = get_post( $id ); $raw = ElementorExtractor::raw( $id ); $data = '' !== $raw ? json_decode( $raw, true ) : null; if ( ! $post instanceof \WP_Post || ! is_array( $data ) ) { return self::result( 0, __( 'The Elementor data could not be read.', 'unbreak' ) ); } $base = self::base( $post ); $count = 0; $data = self::rewrite_elementor_tree( $data, $map, $base, $count ); if ( 0 === $count || ! $apply ) { return self::result( $count, 0 === $count ? self::not_found_note( $map ) : '' ); } $json = (string) wp_json_encode( $data ); update_post_meta( $id, ElementorExtractor::META_KEY, wp_slash( $json ) ); $this->log->record( $batch, $action, ElementorExtractor::SOURCE_TYPE, $id, ElementorExtractor::META_KEY, $raw, $json, array_keys( $map ) ); // Keep Elementor's rendered fallback copy in post_content consistent. $copy = HtmlRewriter::rewrite_content( (string) $post->post_content, $map, $base ); if ( $copy['count'] > 0 ) { $this->save_post_content( $id, $copy['html'] ); $this->log->record( $batch, $action, PostContentExtractor::SOURCE_TYPE, $id, '', (string) $post->post_content, $copy['html'], array_keys( $map ) ); } self::clear_elementor_cache( $id ); $this->indexer->index_post( $id ); return self::result( $count, '' ); } /** * Walk Elementor data replacing exact `url` values and links inside HTML strings. * * @param array $node Node. * @param array $map Map. * @param string $base Base URL. * @param int $count Replacement counter. * @return array */ public static function rewrite_elementor_tree( array $node, array $map, string $base, int &$count ): array { foreach ( $node as $key => $value ) { if ( '__dynamic__' === $key ) { continue; } if ( is_array( $value ) ) { $node[ $key ] = self::rewrite_elementor_tree( $value, $map, $base, $count ); continue; } if ( ! is_string( $value ) || '' === $value ) { continue; } if ( 'url' === $key ) { $normalized = \Unbreak\Support\Url::normalize( $value, $base ); // Unlinking only clears link controls ({url, is_external, nofollow}); never image or background media. $is_link_control = array_key_exists( 'is_external', $node ) || array_key_exists( 'nofollow', $node ); if ( null !== $normalized && array_key_exists( $normalized, $map ) && ( null !== $map[ $normalized ] || $is_link_control ) ) { $node[ $key ] = null === $map[ $normalized ] ? '' : (string) $map[ $normalized ]; ++$count; } continue; } if ( false !== strpos( $value, '<' ) ) { $result = HtmlRewriter::rewrite( $value, $map, $base ); if ( $result['count'] > 0 ) { $node[ $key ] = $result['html']; $count += $result['count']; } } } return $node; } /** * Menu item: update `_menu_item_url`. Unlinking a menu item is not done automatically. * * @param int $id Menu item ID. * @param array $map Map. * @param bool $apply Apply. * @param string $batch Batch. * @param string $action Action. * @return array{changed:int,note:string} */ private function menu( int $id, array $map, bool $apply, string $batch, string $action ): array { $before = (string) get_post_meta( $id, MenuExtractor::META_KEY, true ); $normalized = \Unbreak\Support\Url::normalize( $before, home_url( '/' ) ); if ( null === $normalized || ! array_key_exists( $normalized, $map ) ) { return self::result( 0, self::not_found_note( $map ) ); } if ( null === $map[ $normalized ] ) { return self::result( 0, __( 'Menu items are not unlinked automatically. Edit or remove the item under Appearance → Menus.', 'unbreak' ) ); } if ( $apply ) { $after = esc_url_raw( (string) $map[ $normalized ] ); update_post_meta( $id, MenuExtractor::META_KEY, $after ); $this->log->record( $batch, $action, MenuExtractor::SOURCE_TYPE, $id, MenuExtractor::META_KEY, $before, $after, array_keys( $map ) ); $this->indexer->index_menus(); } return self::result( 1, '' ); } /** * Widget: rewrite the HTML field of one widget instance. * * @param int $number Widget instance number. * @param string $option Widget option name (widget_text, widget_custom_html, widget_block). * @param array $map Map. * @param bool $apply Apply. * @param string $batch Batch. * @param string $action Action. * @return array{changed:int,note:string} */ private function widget( int $number, string $option, array $map, bool $apply, string $batch, string $action ): array { if ( ! isset( WidgetExtractor::WIDGETS[ $option ] ) ) { return self::result( 0, __( 'Unknown widget type.', 'unbreak' ) ); } $field = WidgetExtractor::WIDGETS[ $option ]; $instances = get_option( $option, array() ); if ( ! is_array( $instances ) || ! isset( $instances[ $number ][ $field ] ) || ! is_string( $instances[ $number ][ $field ] ) ) { return self::result( 0, __( 'The widget no longer exists.', 'unbreak' ) ); } $before = $instances[ $number ][ $field ]; $result = HtmlRewriter::rewrite_content( $before, $map, home_url( '/' ) ); if ( 0 === $result['count'] || ! $apply ) { return self::result( $result['count'], 0 === $result['count'] ? self::not_found_note( $map ) : '' ); } $instances[ $number ][ $field ] = $result['html']; update_option( $option, $instances ); $this->log->record( $batch, $action, WidgetExtractor::SOURCE_TYPE, $number, $option, $before, $result['html'], array_keys( $map ) ); $this->indexer->index_widgets(); return self::result( $result['count'], '' ); } /** * Comment: rewrite comment_content or comment_author_url. * * @param int $id Comment ID. * @param string $field comment_content or comment_author_url. * @param array $map Map. * @param bool $apply Apply. * @param string $batch Batch. * @param string $action Action. * @return array{changed:int,note:string} */ private function comment( int $id, string $field, array $map, bool $apply, string $batch, string $action ): array { $comment = get_comment( $id ); if ( ! $comment instanceof \WP_Comment ) { return self::result( 0, __( 'The comment no longer exists.', 'unbreak' ) ); } $base = (string) get_permalink( (int) $comment->comment_post_ID ); if ( 'comment_author_url' === $field ) { $before = (string) $comment->comment_author_url; $normalized = \Unbreak\Support\Url::normalize( $before, $base ); if ( null === $normalized || ! array_key_exists( $normalized, $map ) ) { return self::result( 0, self::not_found_note( $map ) ); } $after = null === $map[ $normalized ] ? '' : esc_url_raw( (string) $map[ $normalized ] ); } else { $field = 'comment_content'; $before = (string) $comment->comment_content; $result = HtmlRewriter::rewrite( $before, $map, $base ); if ( 0 === $result['count'] ) { return self::result( 0, self::not_found_note( $map ) ); } $after = $result['html']; } if ( $apply ) { wp_update_comment( array( 'comment_ID' => $id, $field => $after, ) ); $this->log->record( $batch, $action, CommentExtractor::SOURCE_TYPE, $id, $field, $before, $after, array_keys( $map ) ); $this->indexer->index_comment( $id ); } return self::result( 1, '' ); } /** * Restore a previously logged value (undo). Refuses when the source changed since. * * @param array $entry Change log entry. * @return bool Whether the value was restored. */ public function restore( array $entry ): bool { $type = (string) $entry['source_type']; $id = (int) $entry['source_id']; $key = (string) $entry['source_key']; $before = (string) $entry['before']; $hash = (string) $entry['after_hash']; switch ( $type ) { case PostContentExtractor::SOURCE_TYPE: $post = get_post( $id ); if ( ! $post instanceof \WP_Post || ChangeLog::hash( (string) $post->post_content ) !== $hash ) { return false; } $this->save_post_content( $id, $before ); $this->indexer->index_post( $id ); return true; case ElementorExtractor::SOURCE_TYPE: if ( ChangeLog::hash( ElementorExtractor::raw( $id ) ) !== $hash ) { return false; } update_post_meta( $id, ElementorExtractor::META_KEY, wp_slash( $before ) ); self::clear_elementor_cache( $id ); $this->indexer->index_post( $id ); return true; case MenuExtractor::SOURCE_TYPE: if ( ChangeLog::hash( (string) get_post_meta( $id, MenuExtractor::META_KEY, true ) ) !== $hash ) { return false; } update_post_meta( $id, MenuExtractor::META_KEY, $before ); $this->indexer->index_menus(); return true; case WidgetExtractor::SOURCE_TYPE: $field = WidgetExtractor::WIDGETS[ $key ] ?? ''; $instances = get_option( $key, array() ); if ( '' === $field || ! is_array( $instances ) || ! isset( $instances[ $id ][ $field ] ) || ChangeLog::hash( (string) $instances[ $id ][ $field ] ) !== $hash ) { return false; } $instances[ $id ][ $field ] = $before; update_option( $key, $instances ); $this->indexer->index_widgets(); return true; case CommentExtractor::SOURCE_TYPE: $comment = get_comment( $id ); $field = 'comment_author_url' === $key ? 'comment_author_url' : 'comment_content'; if ( ! $comment instanceof \WP_Comment || ChangeLog::hash( (string) $comment->$field ) !== $hash ) { return false; } wp_update_comment( array( 'comment_ID' => $id, $field => $before, ) ); $this->indexer->index_comment( $id ); return true; } return false; } /** * Save post content with wp_update_post() (creates a revision) without letting KSES reformat * markup the plugin did not touch. * * Why KSES is switched off for this one save: * - Bulk actions run in the background as the user who confirmed them. If that user lacks * `unfiltered_html` (e.g. a site admin on multisite), KSES would strip iframes, scripts, forms * or embed attributes that an administrator added earlier. Fixing one link must not delete * unrelated content. * - The only bytes that differ from the stored content are href/src values (or a removed * wrapper), edited in place with WP_HTML_Tag_Processor. Every new URL has passed * esc_url_raw() with http/https only, and the user must be allowed to edit_post this post * (BulkActions::can_edit_source()). * - The filters are restored right after the save, so nothing else on the request is affected. * * @param int $id Post ID. * @param string $content New content. */ private function save_post_content( int $id, string $content ): void { $kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); if ( $kses ) { // Temporarily remove KSES: see the docblock above (the plugin only changed URLs). kses_remove_filters(); } wp_update_post( array( 'ID' => $id, 'post_content' => wp_slash( $content ), ) ); if ( $kses ) { kses_init_filters(); } } /** * Clear Elementor's generated CSS when Elementor is active. * * @param int $post_id Post ID. */ private static function clear_elementor_cache( int $post_id ): void { delete_post_meta( $post_id, '_elementor_css' ); if ( ! class_exists( '\Elementor\Plugin' ) ) { return; } $elementor = \Elementor\Plugin::$instance; $files = is_object( $elementor ) && property_exists( $elementor, 'files_manager' ) ? $elementor->files_manager : null; if ( is_object( $files ) && method_exists( $files, 'clear_cache' ) ) { $files->clear_cache(); } } /** * Base URL for a post. * * @param \WP_Post $post Post. */ private static function base( \WP_Post $post ): string { $base = (string) get_permalink( $post ); return '' !== $base ? $base : home_url( '/' ); } /** * Note for sources where the URL was not found (already changed, or unlink on a non-anchor). * * @param array $map Map. */ private static function not_found_note( array $map ): string { $unlink_only = ! empty( $map ) && ! in_array( true, array_map( 'is_string', $map ), true ); return $unlink_only ? __( 'Nothing to unlink here: the URL is not used in a link () in this source, or it was already changed.', 'unbreak' ) : __( 'The URL was not found in this source any more (it may have been edited already).', 'unbreak' ); } /** * Result tuple. * * @param int $changed Changes. * @param string $note Note. * @return array{changed:int,note:string} */ private static function result( int $changed, string $note ): array { return array( 'changed' => $changed, 'note' => $note, ); } }