links = $links; $this->scanner = $scanner; } /** * Replaces a link's URL in every place it appears. * * @param int $link_id Link ID. * @param string $new_url New URL. * @return array{changed:int, skipped:int} */ public function replace_url( $link_id, $new_url ) { return $this->apply( $link_id, function ( $value, $instance ) use ( $new_url ) { return $this->replace_in( $value, $instance->raw_url, $new_url ); }, false ); } /** * Removes the link (keeps the anchor text) everywhere it appears as an . * * @param int $link_id Link ID. * @return array{changed:int, skipped:int} */ public function unlink( $link_id ) { return $this->apply( $link_id, function ( $value, $instance ) { return $this->unlink_in( $value, $instance->raw_url ); }, true ); } /** * Runs a transformation on every instance of a link. * * @param int $link_id Link ID. * @param callable $transform Callback( $value, $instance ) => new value. * @param bool $anchors_only Only touch instances. * @return array{changed:int, skipped:int} */ private function apply( $link_id, callable $transform, $anchors_only ) { $changed = 0; $skipped = 0; $done = array(); foreach ( $this->links->instances( $link_id ) as $instance ) { if ( $anchors_only && ! in_array( $instance->element, array( 'a' ), true ) ) { ++$skipped; continue; } $key = $instance->source_type . '|' . $instance->source_id . '|' . $instance->source_field . '|' . $instance->raw_url; if ( isset( $done[ $key ] ) ) { continue; } $done[ $key ] = true; $ok = $this->edit_source( $instance, $transform ); if ( $ok ) { ++$changed; } else { ++$skipped; } } // Re-parse touched sources now so the list is up to date on the next screen. $this->scanner->flush_dirty(); $this->links->purge_orphans( 50 ); return array( 'changed' => $changed, 'skipped' => $skipped, ); } /** * Applies a transformation to one source field. * * @param object $instance Instance row. * @param callable $transform Transformation. * @return bool Whether something changed. */ private function edit_source( $instance, callable $transform ) { $id = (int) $instance->source_id; $field = (string) $instance->source_field; switch ( $instance->source_type ) { case 'post': if ( ! current_user_can( 'edit_post', $id ) ) { return false; } if ( 'post_content' === $field || 'post_excerpt' === $field ) { $post = get_post( $id ); if ( ! $post ) { return false; } $new = $transform( $post->$field, $instance ); if ( $new === $post->$field ) { return false; } $result = wp_update_post( wp_slash( array( 'ID' => $id, $field => $new, ) ), true ); return ! is_wp_error( $result ); } if ( 0 === strpos( $field, 'meta:' ) ) { return $this->edit_meta( $id, substr( $field, 5 ), $instance, $transform ); } return false; case 'menu': if ( ! current_user_can( 'edit_theme_options' ) ) { return false; } $url = get_post_meta( $id, '_menu_item_url', true ); // Menus only support URL replacement, not unlinking. $new = $transform( $url, $instance ); if ( $new === $url || '' === trim( (string) $new ) ) { return false; } update_post_meta( $id, '_menu_item_url', esc_url_raw( $new ) ); $this->scanner->mark_post( $id ); return true; case 'comment': return $this->edit_comment( $id, $field, $instance, $transform ); case 'widget': if ( ! current_user_can( 'edit_theme_options' ) || ! preg_match( '/^widget:(.+)-(\d+)$/', $field, $m ) ) { return false; } $option = get_option( 'widget_' . $m[1] ); $number = (int) $m[2]; if ( ! is_array( $option ) || ! isset( $option[ $number ] ) ) { return false; } $new = $this->walk( $option[ $number ], $instance, $transform ); if ( $new === $option[ $number ] ) { return false; } $option[ $number ] = $new; update_option( 'widget_' . $m[1], $option ); return true; } /** * Lets add-ons edit their own source types. * * @param bool $changed Whether the source was changed. * @param object $instance Instance row. * @param callable $transform Callback( $value, $instance ) returning the new value. */ return (bool) apply_filters( 'linktender_edit_source', false, $instance, $transform ); } /** * Edits a comment's text or the commenter's website link. * * @param int $comment_id Comment ID. * @param string $field comment_content or comment_author_url. * @param object $instance Instance. * @param callable $transform Transformation. * @return bool */ private function edit_comment( $comment_id, $field, $instance, callable $transform ) { $comment = get_comment( $comment_id ); if ( ! $comment || ! current_user_can( 'edit_comment', $comment_id ) || ! in_array( $field, array( 'comment_content', 'comment_author_url' ), true ) ) { return false; } $old = (string) $comment->$field; if ( 'comment_author_url' === $field ) { $new = $transform( $old, $instance ); // Unlinking a website field means clearing it. $new = ( false !== strpos( $new, '<' ) ) ? '' : $new; if ( trim( $old ) !== $instance->raw_url ) { return false; } $new = '' === $new ? '' : esc_url_raw( $new ); } else { $new = $transform( $old, $instance ); } if ( $new === $old ) { return false; } $result = wp_update_comment( wp_slash( array( 'comment_ID' => $comment_id, $field => $new, ) ), true ); $this->scanner->mark_comment( $comment_id ); return ! is_wp_error( $result ) && false !== $result; } /** * Edits a custom field. * * @param int $post_id Post ID. * @param string $key Meta key. * @param object $instance Instance. * @param callable $transform Transformation. * @return bool */ private function edit_meta( $post_id, $key, $instance, callable $transform ) { $changed = false; foreach ( get_post_meta( $post_id, $key, false ) as $value ) { if ( '_elementor_data' === $key && is_string( $value ) ) { $data = json_decode( $value, true ); if ( ! is_array( $data ) ) { continue; } $new_data = $this->walk( $data, $instance, $transform ); if ( $new_data === $data ) { continue; } update_post_meta( $post_id, $key, wp_slash( wp_json_encode( $new_data ) ), $value ); $this->clear_elementor_cache( $post_id ); $changed = true; continue; } $new = $this->walk( $value, $instance, $transform ); if ( $new !== $value ) { update_post_meta( $post_id, $key, wp_slash( $new ), $value ); $changed = true; } } return $changed; } /** * Applies a transformation to every string in a nested value. * * @param mixed $value Value. * @param object $instance Instance. * @param callable $transform Transformation. * @return mixed */ private function walk( $value, $instance, callable $transform ) { if ( is_array( $value ) ) { foreach ( $value as $k => $child ) { $value[ $k ] = $this->walk( $child, $instance, $transform ); } return $value; } if ( is_string( $value ) ) { // A field that only holds the URL (Elementor link controls, URL custom fields). if ( trim( $value ) === $instance->raw_url ) { $new = $transform( $value, $instance ); // Unlinking a bare URL field would just empty it, which is not what users expect. return ( false !== strpos( $new, '<' ) || '' === $new ) ? $value : $new; } return $transform( $value, $instance ); } return $value; } /** * Replaces a URL in text, in plain, HTML-escaped and JSON-escaped forms. * * @param string $text Text. * @param string $old_url URL as written. * @param string $new_url Replacement. * @return string */ public function replace_in( $text, $old_url, $new_url ) { $text = (string) $text; if ( '' === $old_url || '' === $new_url ) { return $text; } $pairs = array( $old_url => $new_url, str_replace( '&', '&', $old_url ) => str_replace( '&', '&', $new_url ), str_replace( '/', '\/', $old_url ) => str_replace( '/', '\/', $new_url ), str_replace( '&', '\u0026', $old_url ) => str_replace( '&', '\u0026', $new_url ), // WPBakery stores link fields URL-encoded: link="url:https%3A%2F%2F...|title:...". rawurlencode( $old_url ) => rawurlencode( $new_url ), ); foreach ( $pairs as $from => $to ) { if ( false === strpos( $text, $from ) ) { continue; } // Only whole URLs: the next character must end the URL (quote, space, tag, backslash, end). $text = preg_replace_callback( '~(?|\\\\\])]|$)~', function () use ( $to ) { return $to; }, $text ); } return $text; } /** * Removes tags with the given href, keeping their content. * * @param string $text HTML. * @param string $old_url URL as written. * @return string */ public function unlink_in( $text, $old_url ) { $text = (string) $text; if ( false === stripos( $text, ']*>(.*?)#is', function ( $m ) use ( $old_url ) { $processor = new WP_HTML_Tag_Processor( $m[0] ); if ( $processor->next_tag( 'a' ) && $processor->get_attribute( 'href' ) === $old_url ) { return $m[1]; } return $m[0]; }, $text ); } /** * Clears Elementor's generated CSS after editing its data. * * @param int $post_id Post ID. * @return void */ private function clear_elementor_cache( $post_id ) { delete_post_meta( $post_id, '_elementor_css' ); if ( class_exists( '\Elementor\Plugin' ) && isset( \Elementor\Plugin::$instance->files_manager ) ) { \Elementor\Plugin::$instance->files_manager->clear_cache(); } } }