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();
}
}
}