| 1 |
<?php |
| 2 |
/** |
| 3 |
* Model for Inbound Smart Link. |
| 4 |
* |
| 5 |
* @package Parsely |
| 6 |
* @since 3.16.0 |
| 7 |
*/ |
| 8 |
|
| 9 |
declare( strict_types = 1 ); |
| 10 |
|
| 11 |
namespace Parsely\Models; |
| 12 |
|
| 13 |
use Masterminds\HTML5; |
| 14 |
use Parsely\Parsely; |
| 15 |
use Parsely\Utils\Utils; |
| 16 |
use ReflectionClass; |
| 17 |
use WP_Post; |
| 18 |
use WP_Error; |
| 19 |
|
| 20 |
use const Parsely\PARSELY_CACHE_GROUP; |
| 21 |
|
| 22 |
/** |
| 23 |
* Model for Inbound Smart Link. |
| 24 |
* |
| 25 |
* @since 3.16.0 |
| 26 |
* |
| 27 |
* @phpstan-type ParagraphData array{ |
| 28 |
* paragraph: string, |
| 29 |
* is_first_paragraph: bool, |
| 30 |
* is_last_paragraph: bool, |
| 31 |
* paragraph_offset: int |
| 32 |
* } |
| 33 |
* |
| 34 |
* @phpstan-type SmartLinkPostData array{ |
| 35 |
* id: int, |
| 36 |
* title: string, |
| 37 |
* type: array{ |
| 38 |
* name: string, |
| 39 |
* label: string, |
| 40 |
* rest: string, |
| 41 |
* }, |
| 42 |
* paragraph: string, |
| 43 |
* is_first_paragraph: bool, |
| 44 |
* is_last_paragraph: bool, |
| 45 |
* paragraph_offset: int, |
| 46 |
* permalink: string, |
| 47 |
* parsely_canonical_url: string, |
| 48 |
* edit_link: string, |
| 49 |
* author: string, |
| 50 |
* date: string, |
| 51 |
* image: string, |
| 52 |
* } |
| 53 |
*/ |
| 54 |
class Inbound_Smart_Link extends Smart_Link { |
| 55 |
/** |
| 56 |
* Allowed HTML tags that can contain a smart link. |
| 57 |
* |
| 58 |
* @since 3.19.0 |
| 59 |
* |
| 60 |
* @var array<string> The allowed tags. |
| 61 |
*/ |
| 62 |
private const ALLOWED_TAGS = array( 'p', 'ul' ); |
| 63 |
|
| 64 |
/** |
| 65 |
* The paragraph data. |
| 66 |
* |
| 67 |
* @since 3.16.0 |
| 68 |
* |
| 69 |
* @var ParagraphData|null The paragraph data. |
| 70 |
*/ |
| 71 |
private $paragraph_data; |
| 72 |
|
| 73 |
/** |
| 74 |
* The post data. |
| 75 |
* |
| 76 |
* @since 3.16.0 |
| 77 |
* |
| 78 |
* @var SmartLinkPostData|null The post data. |
| 79 |
*/ |
| 80 |
private $post_data; |
| 81 |
|
| 82 |
/** |
| 83 |
* Serializes the model to a JSON string, and adds extra data. |
| 84 |
* |
| 85 |
* @since 3.16.0 |
| 86 |
* |
| 87 |
* @return array<mixed> The serialized model. |
| 88 |
*/ |
| 89 |
public function to_array(): array { |
| 90 |
$data = parent::to_array(); |
| 91 |
|
| 92 |
$data['post_data'] = $this->get_post_data(); |
| 93 |
|
| 94 |
// If the smart link is not applied, check if it has a valid placement. |
| 95 |
if ( ! $this->is_applied() ) { |
| 96 |
$has_valid_placement = $this->has_valid_placement( true ); |
| 97 |
|
| 98 |
if ( is_wp_error( $has_valid_placement ) ) { |
| 99 |
$data['validation'] = array( |
| 100 |
'valid' => false, |
| 101 |
'reason' => $has_valid_placement->get_error_message(), |
| 102 |
); |
| 103 |
} else { |
| 104 |
$data['validation'] = array( |
| 105 |
'valid' => true, |
| 106 |
); |
| 107 |
} |
| 108 |
} |
| 109 |
|
| 110 |
// If the smart link is applied, check if it is a link replacement. |
| 111 |
if ( $this->is_applied() ) { |
| 112 |
$previous_link_attributes = get_post_meta( $this->smart_link_id, '_traffic_boost_original_link_attributes', true ); |
| 113 |
if ( '' !== $previous_link_attributes ) { |
| 114 |
$data['is_link_replacement'] = true; |
| 115 |
} |
| 116 |
} |
| 117 |
|
| 118 |
return $data; |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Checks if the Smart Link is linked to a post. |
| 123 |
* |
| 124 |
* @since 3.16.0 |
| 125 |
* |
| 126 |
* @return bool True if the Smart Link is linked to a post, false otherwise. |
| 127 |
*/ |
| 128 |
public function is_linked(): bool { |
| 129 |
$object_exists = parent::exists(); |
| 130 |
if ( ! $object_exists ) { |
| 131 |
return false; |
| 132 |
} |
| 133 |
|
| 134 |
if ( ! $this->source_post instanceof WP_Post ) { |
| 135 |
return false; |
| 136 |
} |
| 137 |
|
| 138 |
$post_content = $this->source_post->post_content; |
| 139 |
|
| 140 |
// If the post content does not contain the Smart Link UID, it is not |
| 141 |
// linked to a post. |
| 142 |
if ( strpos( $post_content, $this->uid ) === false ) { |
| 143 |
return false; |
| 144 |
} |
| 145 |
|
| 146 |
return true; |
| 147 |
} |
| 148 |
|
| 149 |
/** |
| 150 |
* Checks if the Smart Link has a valid placement. |
| 151 |
* |
| 152 |
* @since 3.19.0 |
| 153 |
* |
| 154 |
* @param bool $wp_error Whether to return a WP_Error object if the Smart Link has an invalid placement. |
| 155 |
* @param bool $allow_duplicate_links Whether to allow duplicate links. |
| 156 |
* @return bool|\WP_Error True if the Smart Link has a valid placement, WP_Error on failure if |
| 157 |
* $wp_error is true, false otherwise. |
| 158 |
*/ |
| 159 |
public function has_valid_placement( bool $wp_error = false, bool $allow_duplicate_links = false ) { |
| 160 |
if ( null === $this->source_post ) { |
| 161 |
$this->source_post = get_post( $this->source_post_id ); |
| 162 |
} |
| 163 |
|
| 164 |
$post = $this->source_post; |
| 165 |
|
| 166 |
if ( ! $post instanceof WP_Post ) { |
| 167 |
if ( $wp_error ) { |
| 168 |
return new \WP_Error( 'traffic_boost_invalid_post', __( 'Invalid post', 'wp-parsely' ) ); |
| 169 |
} |
| 170 |
|
| 171 |
return false; |
| 172 |
} |
| 173 |
|
| 174 |
// If the post content contains the smart link href, it is not valid. |
| 175 |
if ( ! $allow_duplicate_links && strpos( $post->post_content, $this->href ) !== false ) { |
| 176 |
if ( $wp_error ) { |
| 177 |
return new \WP_Error( 'traffic_boost_invalid_post', __( 'The link is already linked to this post.', 'wp-parsely' ) ); |
| 178 |
} |
| 179 |
|
| 180 |
return false; |
| 181 |
} |
| 182 |
|
| 183 |
$paragraph = $this->get_paragraph( $post ); |
| 184 |
|
| 185 |
if ( is_wp_error( $paragraph ) ) { |
| 186 |
if ( $wp_error ) { |
| 187 |
return $paragraph; |
| 188 |
} |
| 189 |
|
| 190 |
return false; |
| 191 |
} |
| 192 |
|
| 193 |
return true; |
| 194 |
} |
| 195 |
|
| 196 |
/** |
| 197 |
* Checks if the smart link is a link replacement. |
| 198 |
* |
| 199 |
* @since 3.19.0 |
| 200 |
* |
| 201 |
* @return bool True if the smart link is a link replacement, false otherwise. |
| 202 |
*/ |
| 203 |
public function did_replace_link(): bool { |
| 204 |
if ( ! $this->is_applied() ) { |
| 205 |
return false; |
| 206 |
} |
| 207 |
|
| 208 |
return metadata_exists( 'post', $this->smart_link_id, '_traffic_boost_original_link_attributes' ); |
| 209 |
} |
| 210 |
|
| 211 |
/** |
| 212 |
* Gets the post data for the smart link. |
| 213 |
* |
| 214 |
* @since 3.16.0 |
| 215 |
* |
| 216 |
* @return SmartLinkPostData The post data. |
| 217 |
*/ |
| 218 |
private function get_post_data(): array { |
| 219 |
/** |
| 220 |
* Empty post data. |
| 221 |
* |
| 222 |
* @var SmartLinkPostData |
| 223 |
*/ |
| 224 |
$empty_post_data = array( |
| 225 |
'id' => 0, |
| 226 |
'title' => '', |
| 227 |
'type' => array( |
| 228 |
'name' => '', |
| 229 |
'label' => '', |
| 230 |
'rest' => '', |
| 231 |
), |
| 232 |
'paragraph' => '', |
| 233 |
'is_first_paragraph' => false, |
| 234 |
'is_last_paragraph' => false, |
| 235 |
'paragraph_offset' => 0, |
| 236 |
'permalink' => '', |
| 237 |
'parsely_canonical_url' => '', |
| 238 |
'edit_link' => '', |
| 239 |
'author' => '', |
| 240 |
'date' => '', |
| 241 |
'image' => '', |
| 242 |
); |
| 243 |
|
| 244 |
if ( null !== $this->post_data ) { |
| 245 |
return $this->post_data; |
| 246 |
} |
| 247 |
|
| 248 |
if ( null === $this->source_post ) { |
| 249 |
$this->source_post = get_post( $this->source_post_id ); |
| 250 |
} |
| 251 |
|
| 252 |
$post = $this->source_post; |
| 253 |
if ( ! $post instanceof WP_Post ) { |
| 254 |
return $empty_post_data; |
| 255 |
} |
| 256 |
|
| 257 |
// Get the paragraph that has the smart link UID. |
| 258 |
$paragraph = $this->get_paragraph( $post ); |
| 259 |
|
| 260 |
if ( is_wp_error( $paragraph ) ) { |
| 261 |
/** @var ParagraphData */ |
| 262 |
$error_paragraph = array( |
| 263 |
'paragraph' => '<p>' . $paragraph->get_error_message() . '</p>', |
| 264 |
'is_first_paragraph' => false, |
| 265 |
'is_last_paragraph' => false, |
| 266 |
'paragraph_offset' => 0, |
| 267 |
); |
| 268 |
$paragraph = $error_paragraph; |
| 269 |
} |
| 270 |
|
| 271 |
$author_name = get_the_author(); |
| 272 |
if ( '' === $author_name ) { |
| 273 |
// If the author name is empty, use the author login name. |
| 274 |
$author_name = get_the_author_meta( 'user_login', intval( $post->post_author ) ); |
| 275 |
} |
| 276 |
|
| 277 |
$post_type = get_post_type_object( $post->post_type ); |
| 278 |
if ( null === $post_type ) { |
| 279 |
return $empty_post_data; |
| 280 |
} |
| 281 |
|
| 282 |
$post_type_label = $post_type->labels->singular_name; |
| 283 |
|
| 284 |
$rest_endpoint = $post_type->rest_namespace . '/'; |
| 285 |
$rest_endpoint .= false === $post_type->rest_base ? $post->post_type : $post_type->rest_base; |
| 286 |
|
| 287 |
$this->post_data = array( |
| 288 |
'id' => $post->ID, |
| 289 |
'title' => $post->post_title, |
| 290 |
'type' => array( |
| 291 |
'name' => $post->post_type, |
| 292 |
'label' => $post_type_label, |
| 293 |
'rest' => $rest_endpoint, |
| 294 |
), |
| 295 |
'paragraph' => $paragraph['paragraph'], |
| 296 |
'paragraph_offset' => $paragraph['paragraph_offset'], |
| 297 |
'permalink' => get_permalink( $post ), |
| 298 |
'parsely_canonical_url' => Parsely::get_canonical_url_from_post( $post ), |
| 299 |
'edit_link' => get_edit_post_link( $post, 'html' ) !== null ? get_edit_post_link( $post, 'html' ) : '', |
| 300 |
'is_first_paragraph' => $paragraph['is_first_paragraph'], |
| 301 |
'is_last_paragraph' => $paragraph['is_last_paragraph'], |
| 302 |
'author' => $author_name, |
| 303 |
'date' => (string) ( get_the_date( '', $post ) !== false ? get_the_date( '', $post ) : '' ), |
| 304 |
'image' => get_the_post_thumbnail_url( $post, 'medium' ) !== false ? get_the_post_thumbnail_url( $post, 'medium' ) : '', |
| 305 |
); |
| 306 |
|
| 307 |
return $this->post_data; |
| 308 |
} |
| 309 |
|
| 310 |
/** |
| 311 |
* Get the HTML paragraph that has the smart link UID. |
| 312 |
* |
| 313 |
* @since 3.16.0 |
| 314 |
* |
| 315 |
* @param \WP_Post $post The post. |
| 316 |
* @return ParagraphData|\WP_Error The paragraph that has the smart link UID, |
| 317 |
* and if it is the first or last paragraph. |
| 318 |
*/ |
| 319 |
private function get_paragraph( \WP_Post $post ) { |
| 320 |
/* phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase */ |
| 321 |
if ( null !== $this->paragraph_data ) { |
| 322 |
return $this->paragraph_data; |
| 323 |
} |
| 324 |
|
| 325 |
if ( ! class_exists( 'DOMDocument' ) ) { |
| 326 |
return new \WP_Error( 'traffic_boost_dom_not_available', __( 'DOMDocument is not available', 'wp-parsely' ) ); |
| 327 |
} |
| 328 |
|
| 329 |
// Initialize the HTML parser. |
| 330 |
libxml_use_internal_errors( true ); |
| 331 |
$temp_doc = new \DOMDocument(); |
| 332 |
$html_parser = new HTML5( |
| 333 |
array( |
| 334 |
'target_document' => $temp_doc, |
| 335 |
'disable_html_ns' => true, |
| 336 |
) |
| 337 |
); |
| 338 |
|
| 339 |
$source_post_has_blocks = has_blocks( $post ); |
| 340 |
$content = $post->post_content; |
| 341 |
|
| 342 |
// Post that are not using the block editor do not have paragraphs, |
| 343 |
// and use new lines to separate paragraphs. |
| 344 |
if ( ! $source_post_has_blocks ) { |
| 345 |
// Generate the array of paragraphs from the post content. |
| 346 |
$paragraphs = explode( "\n\n", $content ); |
| 347 |
|
| 348 |
// Convert the array of paragraphs to a DOMDocument. |
| 349 |
$dom = new \DOMDocument(); |
| 350 |
foreach ( $paragraphs as $paragraph ) { |
| 351 |
$fragment = $dom->createDocumentFragment(); |
| 352 |
|
| 353 |
// Wrap the paragraph in a div tag and parse it as HTML. |
| 354 |
// We need to use div instead of p, because it is semantically incorrect to have |
| 355 |
// block elements inside paragraph tags. |
| 356 |
$wrapped_content = '<div>' . $paragraph . '</div>'; |
| 357 |
$fragment->appendXML( $wrapped_content ); |
| 358 |
|
| 359 |
// Append the fragment to the document. |
| 360 |
$dom->appendChild( $fragment ); |
| 361 |
|
| 362 |
// Ignore errors parsing the HTML. |
| 363 |
libxml_clear_errors(); |
| 364 |
} |
| 365 |
|
| 366 |
// Fetch all div tags (paragraphs). |
| 367 |
$paragraphs = $dom->getElementsByTagName( 'div' ); |
| 368 |
} else { |
| 369 |
// Otherwise, just parse the content as HTML. |
| 370 |
$dom = $html_parser->loadHTML( $content ); |
| 371 |
$fragment = $dom->createDocumentFragment(); |
| 372 |
|
| 373 |
// When loading the HTML, it is wrapped in a html tag. |
| 374 |
// So we need to get the child nodes of the html tag. |
| 375 |
$html_element = $dom->getElementsByTagName( 'html' )->item( 0 ); |
| 376 |
if ( null === $html_element ) { |
| 377 |
return new \WP_Error( 'traffic_boost_html_not_found', __( 'HTML element not found in parsed content', 'wp-parsely' ) ); |
| 378 |
} |
| 379 |
$elements = $html_element->childNodes; |
| 380 |
|
| 381 |
// Append the child nodes to the fragment. |
| 382 |
foreach ( iterator_to_array( $elements ) as $element ) { |
| 383 |
if ( $element instanceof \DOMElement ) { |
| 384 |
$fragment->appendChild( $element ); |
| 385 |
} |
| 386 |
} |
| 387 |
$paragraphs = $fragment->childNodes; |
| 388 |
} |
| 389 |
|
| 390 |
$is_first_paragraph = true; |
| 391 |
$is_last_paragraph = false; |
| 392 |
$paragraph = null; |
| 393 |
$paragraph_offset = 0; |
| 394 |
|
| 395 |
// Counts the global offset of the link text in the post content. |
| 396 |
$offset_count = 0; |
| 397 |
|
| 398 |
/** @var \DOMElement $p The paragraph element. */ |
| 399 |
foreach ( $paragraphs as $p ) { |
| 400 |
// If the smart link is applied, we need to find the paragraph that contains the smart link. |
| 401 |
if ( $this->is_applied() ) { |
| 402 |
// Check each anchor tag within the paragraph. |
| 403 |
$anchors = $p->getElementsByTagName( 'a' ); |
| 404 |
/** @var \DOMElement $anchor The anchor element. */ |
| 405 |
foreach ( $anchors as $anchor ) { |
| 406 |
// Check if the data-smartlink attribute contains the UID. |
| 407 |
if ( $anchor->hasAttribute( 'data-smartlink' ) && stripos( $anchor->getAttribute( 'data-smartlink' ), $this->uid ) !== false ) { |
| 408 |
// Save the outer HTML of the paragraph. |
| 409 |
$is_first_paragraph = $p === $paragraphs->item( 0 ); |
| 410 |
$is_last_paragraph = $p === $paragraphs->item( $paragraphs->length - 1 ); |
| 411 |
$paragraph = $html_parser->saveHTML( $p ); |
| 412 |
break 2; |
| 413 |
} |
| 414 |
} |
| 415 |
} elseif ( strpos( $p->textContent, $this->text ) !== false ) { |
| 416 |
// If the smart link is not applied, we need to find the paragraph that contains the |
| 417 |
// smart link text, and with the correct offset. |
| 418 |
|
| 419 |
/** |
| 420 |
* Counts the local offset of the link text in the paragraph content. |
| 421 |
* |
| 422 |
* @var int |
| 423 |
*/ |
| 424 |
$paragraph_offset = 0; |
| 425 |
|
| 426 |
// Loop each occurrence of the link text in the paragraph content. |
| 427 |
$text_pos = 0; |
| 428 |
// phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition |
| 429 |
while ( ( $text_pos = strpos( $p->textContent, $this->text, $text_pos ) ) !== false ) { |
| 430 |
// If the global offset is the same as the offset of the link text in the paragraph, we found the paragraph. |
| 431 |
if ( $offset_count === $this->offset ) { |
| 432 |
$is_first_paragraph = $p === $paragraphs->item( 0 ); |
| 433 |
$is_last_paragraph = $p === $paragraphs->item( $paragraphs->length - 1 ); |
| 434 |
|
| 435 |
// Check if the paragraph node is one of the allowed tags. |
| 436 |
if ( ! in_array( $p->nodeName, self::ALLOWED_TAGS, true ) ) { |
| 437 |
return new \WP_Error( 'traffic_boost_invalid_offset', __( 'The selection is not within a valid paragraph.', 'wp-parsely' ) ); |
| 438 |
} |
| 439 |
|
| 440 |
$paragraph = $html_parser->saveHTML( $p ); |
| 441 |
|
| 442 |
// Find the text node containing our target text. |
| 443 |
$text_node = $this->find_text_node( $p, $this->text, $paragraph_offset ); |
| 444 |
if ( null === $text_node ) { |
| 445 |
return new \WP_Error( 'traffic_boost_text_node_not_found', __( 'Text node not found', 'wp-parsely' ) ); |
| 446 |
} |
| 447 |
|
| 448 |
// Validate the link placement. |
| 449 |
$validation_result = $this->validate_link_placement( $text_node, $this->text ); |
| 450 |
if ( is_wp_error( $validation_result ) ) { |
| 451 |
libxml_clear_errors(); |
| 452 |
return $validation_result; |
| 453 |
} |
| 454 |
|
| 455 |
break 2; |
| 456 |
} |
| 457 |
++$text_pos; |
| 458 |
++$offset_count; |
| 459 |
++$paragraph_offset; |
| 460 |
} |
| 461 |
} |
| 462 |
} |
| 463 |
|
| 464 |
if ( null === $paragraph ) { |
| 465 |
return new \WP_Error( 'traffic_boost_paragraph_not_found', __( 'Paragraph not found.', 'wp-parsely' ) ); |
| 466 |
} |
| 467 |
|
| 468 |
// If the post is not using the block editor, unwrap the paragraph from the paragraph tags. |
| 469 |
if ( ! $source_post_has_blocks ) { |
| 470 |
// Remove the paragraph tags from the paragraph. |
| 471 |
$paragraph = preg_replace( '/(^<div>)|(<\/div>$)/', '', $paragraph ); |
| 472 |
} |
| 473 |
|
| 474 |
/** @var string $paragraph The paragraph that has the smart link UID. */ |
| 475 |
/** @var int $paragraph_offset The offset of the smart link in the paragraph. */ |
| 476 |
$this->paragraph_data = array( |
| 477 |
'paragraph' => $paragraph, |
| 478 |
'is_first_paragraph' => $is_first_paragraph, |
| 479 |
'is_last_paragraph' => $is_last_paragraph, |
| 480 |
'paragraph_offset' => $paragraph_offset, |
| 481 |
); |
| 482 |
|
| 483 |
libxml_clear_errors(); |
| 484 |
return $this->paragraph_data; |
| 485 |
/* phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase */ |
| 486 |
} |
| 487 |
|
| 488 |
/** |
| 489 |
* Sets the source post from a URL. |
| 490 |
* |
| 491 |
* @since 3.19.0 |
| 492 |
* |
| 493 |
* @param string $url The URL. |
| 494 |
*/ |
| 495 |
public function set_source_from_url( string $url ): bool { |
| 496 |
$source_post_id = Utils::get_post_id_by_url( $url ); |
| 497 |
|
| 498 |
if ( 0 !== $source_post_id ) { |
| 499 |
// Set the source post and update the canonical URL. |
| 500 |
$this->set_source_post_id( $source_post_id ); |
| 501 |
return true; |
| 502 |
} |
| 503 |
|
| 504 |
return false; |
| 505 |
} |
| 506 |
|
| 507 |
/** |
| 508 |
* Recursively searches for text nodes containing the specified text. |
| 509 |
* |
| 510 |
* @since 3.19.0 |
| 511 |
* |
| 512 |
* @param \DOMNode $node The node to search in. |
| 513 |
* @param string $search_text The text to search for. |
| 514 |
* @param int $offset The offset of the occurrence to find. |
| 515 |
* @return \DOMNode|null The text node if found, null otherwise. |
| 516 |
*/ |
| 517 |
private function find_text_node( \DOMNode $node, string $search_text, int $offset ) { |
| 518 |
/* phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase */ |
| 519 |
// If the node is a text node, check if it contains the search text. |
| 520 |
if ( XML_TEXT_NODE === $node->nodeType ) { |
| 521 |
if ( strpos( $node->textContent, $search_text ) !== false ) { |
| 522 |
if ( 0 === $offset ) { |
| 523 |
return $node; |
| 524 |
} |
| 525 |
--$offset; |
| 526 |
} |
| 527 |
} |
| 528 |
|
| 529 |
// If the node has child nodes, recursively search for the text node. |
| 530 |
if ( $node->hasChildNodes() ) { |
| 531 |
/** @var \DOMNode $child */ |
| 532 |
foreach ( $node->childNodes as $child ) { |
| 533 |
$result = $this->find_text_node( $child, $search_text, $offset ); |
| 534 |
if ( null !== $result ) { |
| 535 |
return $result; |
| 536 |
} |
| 537 |
} |
| 538 |
} |
| 539 |
|
| 540 |
// If the text node is not found, return null. |
| 541 |
return null; |
| 542 |
/* phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase */ |
| 543 |
} |
| 544 |
|
| 545 |
/** |
| 546 |
* Validates if a text node can have a link placed around it. |
| 547 |
* |
| 548 |
* Checks if the node is inside a link and if so, only allows the operation |
| 549 |
* if it's replacing the entire link. |
| 550 |
* |
| 551 |
* @since 3.19.0 |
| 552 |
* |
| 553 |
* @param \DOMNode $node The node to validate. |
| 554 |
* @param string $search_text The text that will be linked. |
| 555 |
* @return array{valid: bool, replace_node?: \DOMElement|null}|\WP_Error Validation result. |
| 556 |
*/ |
| 557 |
private function validate_link_placement( \DOMNode $node, string $search_text ) { |
| 558 |
/* phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase */ |
| 559 |
$current = $node; |
| 560 |
while ( $current ) { |
| 561 |
// If the current node is an anchor tag. |
| 562 |
if ( 'a' === $current->nodeName ) { |
| 563 |
// If the current node is not the direct parent of our text node, it means |
| 564 |
// it's trying to create a nested link, so we need to return an error. |
| 565 |
if ( $current !== $node->parentNode ) { |
| 566 |
return new \WP_Error( |
| 567 |
'traffic_boost_invalid_link_placement', |
| 568 |
__( 'Cannot create nested links. The text is already part of another link.', 'wp-parsely' ) |
| 569 |
); |
| 570 |
} |
| 571 |
|
| 572 |
// Check if the entire link text matches the search text. If not, throw |
| 573 |
// an error, because it's trying to create a nested link. |
| 574 |
if ( trim( $current->textContent ) !== trim( $search_text ) ) { |
| 575 |
return new \WP_Error( |
| 576 |
'traffic_boost_invalid_link_placement', |
| 577 |
__( 'Cannot create nested links. The text is already part of another link.', 'wp-parsely' ) |
| 578 |
); |
| 579 |
} |
| 580 |
|
| 581 |
// Check if the link is already linked to this smart link. |
| 582 |
if ( $current instanceof \DOMElement && |
| 583 |
strpos( $current->getAttribute( 'href' ), $this->get_link_href() ) !== false ) { |
| 584 |
return new \WP_Error( |
| 585 |
'traffic_boost_invalid_link_placement', |
| 586 |
__( 'The current link is already linked to this Smart Link.', 'wp-parsely' ) |
| 587 |
); |
| 588 |
} |
| 589 |
|
| 590 |
// Since the link is valid, return the current node to be replaced. |
| 591 |
return array( |
| 592 |
'valid' => true, |
| 593 |
'replace_node' => $current instanceof \DOMElement ? $current : null, |
| 594 |
); |
| 595 |
} |
| 596 |
$current = $current->parentNode; |
| 597 |
} |
| 598 |
|
| 599 |
// If no anchor tag was found, the link placement is valid. |
| 600 |
return array( 'valid' => true ); |
| 601 |
/* phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase */ |
| 602 |
} |
| 603 |
|
| 604 |
/** |
| 605 |
* Finds the smart link anchor element in the paragraph that has the data-smartlink attribute set |
| 606 |
* to the smart link UID. |
| 607 |
* |
| 608 |
* @since 3.19.0 |
| 609 |
* |
| 610 |
* @param \DOMDocument $node The node to search in. |
| 611 |
* @return \DOMElement|false The smart link anchor element if found, false otherwise. |
| 612 |
*/ |
| 613 |
private function find_smart_link_anchor( $node ) { |
| 614 |
$xpath = new \DOMXPath( $node ); |
| 615 |
|
| 616 |
// Query for any 'a' element that has a data-smartlink attribute matching our UID. |
| 617 |
$query = sprintf( '//a[@data-smartlink="%s"]', esc_attr( $this->uid ) ); |
| 618 |
$smart_link_nodes = $xpath->query( $query, $node ); |
| 619 |
|
| 620 |
// Return false if no matching nodes were found. |
| 621 |
if ( false === $smart_link_nodes || 0 === $smart_link_nodes->length ) { |
| 622 |
return false; |
| 623 |
} |
| 624 |
|
| 625 |
// Return the first matching node. |
| 626 |
/** @var \DOMElement $first_node */ |
| 627 |
$first_node = $smart_link_nodes->item( 0 ); |
| 628 |
|
| 629 |
return $first_node; |
| 630 |
} |
| 631 |
|
| 632 |
/** |
| 633 |
* Applies the inbound smart link to the post. |
| 634 |
* |
| 635 |
* @since 3.19.0 |
| 636 |
* |
| 637 |
* @return bool|\WP_Error True if the inbound smart link was applied, WP_Error on failure. |
| 638 |
*/ |
| 639 |
public function apply() { |
| 640 |
/* phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase */ |
| 641 |
if ( $this->is_applied() ) { |
| 642 |
return new \WP_Error( 'traffic_boost_already_applied', __( 'Smart Link already applied', 'wp-parsely' ) ); |
| 643 |
} |
| 644 |
|
| 645 |
if ( ! class_exists( 'DOMDocument' ) ) { |
| 646 |
return new \WP_Error( 'traffic_boost_dom_not_available', __( 'DOMDocument is not available', 'wp-parsely' ) ); |
| 647 |
} |
| 648 |
|
| 649 |
// Get the source post. |
| 650 |
$source_post = $this->source_post; |
| 651 |
if ( null === $source_post ) { |
| 652 |
$source_post = get_post( $this->source_post_id ); |
| 653 |
} |
| 654 |
|
| 655 |
if ( null === $source_post ) { |
| 656 |
return new \WP_Error( 'traffic_boost_source_post_not_found', __( 'Source post not found', 'wp-parsely' ) ); |
| 657 |
} |
| 658 |
|
| 659 |
$source_post_content = $source_post->post_content; |
| 660 |
|
| 661 |
// Find the paragraph that contains the link text. |
| 662 |
$paragraph_data = $this->get_paragraph( $source_post ); |
| 663 |
|
| 664 |
if ( is_wp_error( $paragraph_data ) ) { |
| 665 |
return $paragraph_data; |
| 666 |
} |
| 667 |
|
| 668 |
$paragraph = $paragraph_data['paragraph']; |
| 669 |
$paragraph_offset = $paragraph_data['paragraph_offset']; |
| 670 |
|
| 671 |
// Initialize the HTML parser. |
| 672 |
$temp_doc = new \DOMDocument(); |
| 673 |
$html_parser = new HTML5( |
| 674 |
array( |
| 675 |
'target_document' => $temp_doc, |
| 676 |
'disable_html_ns' => true, |
| 677 |
) |
| 678 |
); |
| 679 |
libxml_use_internal_errors( true ); |
| 680 |
|
| 681 |
// Load the paragraph HTML into a DOMDocument. |
| 682 |
$paragraph_fragment = $html_parser->loadHTMLFragment( $paragraph ); |
| 683 |
|
| 684 |
$errors = libxml_get_errors(); |
| 685 |
|
| 686 |
// If there are errors parsing the paragraph HTML, return an error. |
| 687 |
if ( count( $errors ) > 0 ) { |
| 688 |
libxml_clear_errors(); |
| 689 |
return new \WP_Error( 'traffic_boost_error_parsing_html', __( 'Error parsing the paragraph HTML', 'wp-parsely' ), $errors ); |
| 690 |
} |
| 691 |
|
| 692 |
// Find the text node containing our target text at the specified offset. |
| 693 |
$text_node = $this->find_text_node( $paragraph_fragment, $this->text, $paragraph_offset ); |
| 694 |
|
| 695 |
if ( null === $text_node ) { |
| 696 |
libxml_clear_errors(); |
| 697 |
return new \WP_Error( 'traffic_boost_text_not_found', __( 'No text found in paragraph', 'wp-parsely' ) ); |
| 698 |
} |
| 699 |
|
| 700 |
// Validate the link placement, to ensure we're not creating nested links and to |
| 701 |
// determine if we're replacing an existing link or creating a new one. |
| 702 |
$validation_result = $this->validate_link_placement( $text_node, $this->text ); |
| 703 |
|
| 704 |
// Validation failed, return an error. |
| 705 |
if ( is_wp_error( $validation_result ) ) { |
| 706 |
libxml_clear_errors(); |
| 707 |
return $validation_result; |
| 708 |
} |
| 709 |
|
| 710 |
// Store the smart link node, so we can use it later to find the line that contains the smart link. |
| 711 |
$smart_link_node = null; |
| 712 |
|
| 713 |
// If we're replacing an existing link, handle differently. |
| 714 |
if ( isset( $validation_result['replace_node'] ) ) { |
| 715 |
// Store the original link attributes, so we can restore them if the link gets deleted. |
| 716 |
/** @var \DOMAttr[] $attributes */ |
| 717 |
$attributes = $validation_result['replace_node']->attributes; |
| 718 |
$original_link_attributes = array(); |
| 719 |
foreach ( $attributes as $attribute ) { |
| 720 |
$original_link_attributes[ $attribute->name ] = $attribute->value; |
| 721 |
} |
| 722 |
update_post_meta( $this->smart_link_id, '_traffic_boost_original_link_attributes', $original_link_attributes ); |
| 723 |
|
| 724 |
$existing_link = $validation_result['replace_node']; |
| 725 |
|
| 726 |
// If there is an existing smart link UID, we need to delete it. |
| 727 |
$existing_smart_link_uid = $existing_link->getAttribute( 'data-smartlink' ); |
| 728 |
if ( '' !== $existing_smart_link_uid ) { |
| 729 |
$smart_link = self::get_smart_link( $existing_smart_link_uid, $this->source_post_id ); |
| 730 |
if ( $smart_link->exists() ) { |
| 731 |
$smart_link->delete(); |
| 732 |
} |
| 733 |
} |
| 734 |
|
| 735 |
// Update the existing link. |
| 736 |
$existing_link->setAttribute( 'href', $this->get_link_href() ); |
| 737 |
$existing_link->setAttribute( 'data-smartlink', $this->uid ); |
| 738 |
$existing_link->setAttribute( 'title', $this->title ); |
| 739 |
$smart_link_node = $existing_link; |
| 740 |
} else { // If not replacing an existing link, create a new link. |
| 741 |
// Get the position of the text within this specific text node. |
| 742 |
$text_content = $text_node->textContent; |
| 743 |
$target_pos = strpos( $text_content, $this->text ); |
| 744 |
|
| 745 |
if ( false === $target_pos ) { |
| 746 |
libxml_clear_errors(); |
| 747 |
return new \WP_Error( 'traffic_boost_text_not_found', __( 'Link text not found at specified offset', 'wp-parsely' ) ); |
| 748 |
} |
| 749 |
|
| 750 |
// Create the smart link anchor element. |
| 751 |
$smart_link_anchor = $temp_doc->createElement( 'a' ); |
| 752 |
$smart_link_anchor->setAttribute( 'href', $this->get_link_href() ); |
| 753 |
$smart_link_anchor->setAttribute( 'data-smartlink', $this->uid ); |
| 754 |
$smart_link_anchor->setAttribute( 'title', $this->title ); |
| 755 |
$smart_link_anchor->textContent = $this->text; |
| 756 |
$smart_link_node = $smart_link_anchor; |
| 757 |
// Split the text node into before and after parts. |
| 758 |
$before_text = substr( $text_content, 0, $target_pos ); |
| 759 |
$after_text = substr( $text_content, $target_pos + strlen( $this->text ) ); |
| 760 |
|
| 761 |
// Create text nodes for before and after parts. |
| 762 |
$before_node = $temp_doc->createTextNode( $before_text ); |
| 763 |
$after_node = $temp_doc->createTextNode( $after_text ); |
| 764 |
|
| 765 |
if ( null === $text_node->parentNode ) { |
| 766 |
libxml_clear_errors(); |
| 767 |
return new \WP_Error( 'traffic_boost_text_node_parent_not_found', __( 'Text node parent not found', 'wp-parsely' ) ); |
| 768 |
} |
| 769 |
|
| 770 |
// Replace the original text node with our three new nodes. |
| 771 |
$text_node->parentNode->insertBefore( $before_node, $text_node ); |
| 772 |
$text_node->parentNode->insertBefore( $smart_link_anchor, $text_node ); |
| 773 |
$text_node->parentNode->insertBefore( $after_node, $text_node ); |
| 774 |
$text_node->parentNode->removeChild( $text_node ); |
| 775 |
} |
| 776 |
|
| 777 |
// To avoid unwanted replacements, we'll be focusing only on the single line where the link is being inserted. |
| 778 |
// Get the line that contains the smart link. |
| 779 |
$line_with_smart_link = $this->find_line_with_text( |
| 780 |
$html_parser->saveHTML( $paragraph_fragment ), // The full paragraph HTML with the smart link. |
| 781 |
$html_parser->saveHTML( $smart_link_node ) // The smart link anchor HTML. |
| 782 |
); |
| 783 |
|
| 784 |
// Get the original line where the smart link will be inserted. |
| 785 |
$original_line = $this->find_original_line( $source_post_content, $line_with_smart_link ); |
| 786 |
|
| 787 |
if ( '' === $original_line ) { |
| 788 |
return new \WP_Error( 'traffic_boost_original_line_not_found', __( 'Could not find the original line containing the link text', 'wp-parsely' ) ); |
| 789 |
} |
| 790 |
|
| 791 |
// Replace the original line with the new HTML. |
| 792 |
$source_post_content = str_replace( $original_line, $line_with_smart_link, $source_post_content ); |
| 793 |
|
| 794 |
// Backup the original post content and paragraph in a post meta, for rollback purposes. |
| 795 |
$revisions = wp_get_post_revisions( $source_post->ID, array( 'posts_per_page' => 1 ) ); |
| 796 |
if ( count( $revisions ) > 0 ) { |
| 797 |
// Get the latest revision ID. |
| 798 |
/** @var \WP_Post $latest_revision */ |
| 799 |
$latest_revision = array_shift( $revisions ); |
| 800 |
update_post_meta( $this->smart_link_id, '_traffic_boost_source_post_revision', $latest_revision->ID ); |
| 801 |
} else { |
| 802 |
// If revisions are disabled, store the original content. |
| 803 |
update_post_meta( $this->smart_link_id, '_traffic_boost_source_original_post_content', $source_post->post_content ); |
| 804 |
} |
| 805 |
|
| 806 |
// Always backup the original paragraph, before the link was applied. |
| 807 |
update_post_meta( $this->smart_link_id, '_traffic_boost_source_original_paragraph', $paragraph ); |
| 808 |
|
| 809 |
// Update the post content. |
| 810 |
$updated_post = wp_update_post( |
| 811 |
array( |
| 812 |
'ID' => $this->source_post_id, |
| 813 |
'post_content' => $source_post_content, |
| 814 |
), |
| 815 |
true |
| 816 |
); |
| 817 |
|
| 818 |
if ( is_wp_error( $updated_post ) && ! $this->is_ignorable_update_error( $updated_post ) ) { |
| 819 |
return $updated_post; |
| 820 |
} |
| 821 |
|
| 822 |
// Flush the caches for the smart link. |
| 823 |
$this->flush_all_cache(); |
| 824 |
|
| 825 |
// Set the applied flag to true. |
| 826 |
$this->set_status( Smart_Link_Status::APPLIED ); |
| 827 |
|
| 828 |
// Save the smart link. |
| 829 |
$this->save(); |
| 830 |
|
| 831 |
return true; |
| 832 |
/* phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase */ |
| 833 |
} |
| 834 |
|
| 835 |
/** |
| 836 |
* Removes an inbound smart link from the post, and deletes the smart link by default. |
| 837 |
* |
| 838 |
* @since 3.19.0 |
| 839 |
* |
| 840 |
* @param bool $restore_original_link Whether to restore the original link, if it was replaced. |
| 841 |
* @param bool $delete_smart_link Whether to delete the smart link after removing it from the post. |
| 842 |
* @return bool|\WP_Error True if the inbound smart link was deleted, WP_Error on failure. |
| 843 |
*/ |
| 844 |
public function remove( $restore_original_link = false, $delete_smart_link = true ) { |
| 845 |
/* phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase */ |
| 846 |
// If the smart link is not applied, we can just delete it. |
| 847 |
if ( ! $this->is_applied() ) { |
| 848 |
if ( $delete_smart_link ) { |
| 849 |
return $this->delete(); |
| 850 |
} |
| 851 |
|
| 852 |
return true; |
| 853 |
} |
| 854 |
|
| 855 |
if ( ! class_exists( 'DOMDocument' ) ) { |
| 856 |
return new \WP_Error( 'traffic_boost_dom_not_available', __( 'DOMDocument is not available', 'wp-parsely' ) ); |
| 857 |
} |
| 858 |
|
| 859 |
$source_post = $this->source_post; |
| 860 |
if ( null === $source_post ) { |
| 861 |
$source_post = get_post( $this->source_post_id ); |
| 862 |
} |
| 863 |
|
| 864 |
if ( null === $source_post ) { |
| 865 |
return new \WP_Error( 'traffic_boost_source_post_not_found', __( 'Source post not found', 'wp-parsely' ) ); |
| 866 |
} |
| 867 |
|
| 868 |
$source_post_content = $source_post->post_content; |
| 869 |
|
| 870 |
// Initialize the HTML parser. |
| 871 |
$html_parser = new HTML5( |
| 872 |
array( |
| 873 |
'disable_html_ns' => true, |
| 874 |
) |
| 875 |
); |
| 876 |
|
| 877 |
libxml_use_internal_errors( true ); |
| 878 |
|
| 879 |
// Load the paragraph HTML into a DOMDocument. |
| 880 |
$content_dom = $html_parser->loadHTML( $source_post_content ); |
| 881 |
|
| 882 |
$errors = libxml_get_errors(); |
| 883 |
|
| 884 |
// If there are errors parsing the paragraph HTML, return an error. |
| 885 |
if ( count( $errors ) > 0 ) { |
| 886 |
libxml_clear_errors(); |
| 887 |
return new \WP_Error( 'traffic_boost_error_parsing_html', __( 'Error parsing the paragraph HTML', 'wp-parsely' ), $errors ); |
| 888 |
} |
| 889 |
|
| 890 |
// Remove the anchor element with the smart link UID. |
| 891 |
$smart_link_anchor = $this->find_smart_link_anchor( $content_dom ); |
| 892 |
|
| 893 |
if ( false === $smart_link_anchor ) { |
| 894 |
return new \WP_Error( 'traffic_boost_smart_link_anchor_not_found', __( 'Smart Link anchor not found', 'wp-parsely' ) ); |
| 895 |
} |
| 896 |
|
| 897 |
$original_paragraph_html = $html_parser->saveHTML( $smart_link_anchor->parentNode ); |
| 898 |
|
| 899 |
// Get the parent node of the smart link anchor. |
| 900 |
$parent_node = $smart_link_anchor->parentNode; |
| 901 |
if ( null === $parent_node ) { |
| 902 |
return new \WP_Error( 'traffic_boost_parent_node_not_found', __( 'Parent node not found', 'wp-parsely' ) ); |
| 903 |
} |
| 904 |
|
| 905 |
// Get the original link attributes, if they exist. |
| 906 |
$previous_link_attributes = get_post_meta( $this->smart_link_id, '_traffic_boost_original_link_attributes', true ); |
| 907 |
|
| 908 |
// If the smart link replaced an existing link, restore the original link. |
| 909 |
if ( $restore_original_link && is_array( $previous_link_attributes ) ) { |
| 910 |
$original_link = $content_dom->createElement( 'a' ); |
| 911 |
$original_link->textContent = $smart_link_anchor->textContent; |
| 912 |
|
| 913 |
foreach ( $previous_link_attributes as $attribute => $value ) { |
| 914 |
if ( '' !== $value ) { |
| 915 |
$original_link->setAttribute( $attribute, $value ); |
| 916 |
} |
| 917 |
} |
| 918 |
|
| 919 |
// Remove the previous link attributes meta. |
| 920 |
delete_post_meta( $this->smart_link_id, '_traffic_boost_original_link_attributes' ); |
| 921 |
|
| 922 |
// Replace the smart link anchor with the original link. |
| 923 |
$parent_node->replaceChild( $original_link, $smart_link_anchor ); |
| 924 |
} else { |
| 925 |
// If the smart link did not replace an existing link, we need to remove the smart link anchor. |
| 926 |
// Create a text node with the original text. |
| 927 |
$text_node = $content_dom->createTextNode( $smart_link_anchor->textContent ); |
| 928 |
|
| 929 |
// Replace the smart link anchor with the text node. |
| 930 |
$parent_node->replaceChild( $text_node, $smart_link_anchor ); |
| 931 |
} |
| 932 |
|
| 933 |
// Get the modified HTML. |
| 934 |
$paragraph_html = $html_parser->saveHTML( $parent_node ); |
| 935 |
|
| 936 |
// Replace the paragraph with the new HTML. |
| 937 |
$source_post_content = str_replace( $original_paragraph_html, $paragraph_html, $source_post_content ); |
| 938 |
|
| 939 |
// Update the post content. |
| 940 |
$updated_post = wp_update_post( |
| 941 |
array( |
| 942 |
'ID' => $this->source_post_id, |
| 943 |
'post_content' => $source_post_content, |
| 944 |
), |
| 945 |
true |
| 946 |
); |
| 947 |
|
| 948 |
if ( is_wp_error( $updated_post ) && ! $this->is_ignorable_update_error( $updated_post ) ) { |
| 949 |
return $updated_post; |
| 950 |
} |
| 951 |
|
| 952 |
// Flush the cache for the post. |
| 953 |
$this->flush_all_cache(); |
| 954 |
|
| 955 |
// Set the applied flag to false. |
| 956 |
$this->set_status( Smart_Link_Status::PENDING ); |
| 957 |
|
| 958 |
// Delete the smart link. |
| 959 |
if ( $delete_smart_link ) { |
| 960 |
return $this->delete(); |
| 961 |
} |
| 962 |
|
| 963 |
return true; |
| 964 |
/* phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase */ |
| 965 |
} |
| 966 |
|
| 967 |
/** |
| 968 |
* Updates the text of the smart link. |
| 969 |
* |
| 970 |
* @since 3.19.0 |
| 971 |
* |
| 972 |
* @param string $new_text The new text of the smart link. |
| 973 |
* @param int $offset The offset of the text to update. |
| 974 |
* @param bool $restore_original_link Whether to restore the original link, if it was replaced. |
| 975 |
* @return bool|\WP_Error True if the smart link was updated, WP_Error on failure. |
| 976 |
*/ |
| 977 |
public function update_link_text( string $new_text, int $offset, bool $restore_original_link = false ) { |
| 978 |
global $wpdb; |
| 979 |
|
| 980 |
// To make sure the smart link is updated in a single atomic operation, start a transaction. |
| 981 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 982 |
$wpdb->query( 'START TRANSACTION' ); |
| 983 |
|
| 984 |
// Remove the existing smart link from the post. |
| 985 |
$deleted = $this->remove( $restore_original_link, false ); |
| 986 |
|
| 987 |
if ( is_wp_error( $deleted ) ) { |
| 988 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 989 |
$wpdb->query( 'ROLLBACK' ); |
| 990 |
return $deleted; |
| 991 |
} |
| 992 |
|
| 993 |
// Update the text of the smart link. |
| 994 |
$this->text = $new_text; |
| 995 |
$this->offset = $offset; |
| 996 |
|
| 997 |
// Clean-up local caches. |
| 998 |
$this->paragraph_data = null; |
| 999 |
$this->post_data = null; |
| 1000 |
$this->source_post = null; |
| 1001 |
|
| 1002 |
// Apply the new smart link to the post. |
| 1003 |
$applied = $this->apply(); |
| 1004 |
|
| 1005 |
if ( is_wp_error( $applied ) ) { |
| 1006 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 1007 |
$wpdb->query( 'ROLLBACK' ); |
| 1008 |
return $applied; |
| 1009 |
} |
| 1010 |
|
| 1011 |
// Commit the transaction. |
| 1012 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 1013 |
$wpdb->query( 'COMMIT' ); |
| 1014 |
return $applied; |
| 1015 |
} |
| 1016 |
|
| 1017 |
/** |
| 1018 |
* Creates a new instance of an Inbound Smart Link from a Smart Link object. |
| 1019 |
* |
| 1020 |
* This is used to convert a Smart Link object to an Inbound Smart Link object. |
| 1021 |
* |
| 1022 |
* @since 3.16.0 |
| 1023 |
* |
| 1024 |
* @param Smart_Link $smart_link The Smart Link object. |
| 1025 |
* @return Inbound_Smart_Link The Inbound Smart Link object. |
| 1026 |
*/ |
| 1027 |
public static function from_smart_link( Smart_Link $smart_link ): Inbound_Smart_Link { |
| 1028 |
$inbound_smart_link = new self( '', '', '', 0 ); |
| 1029 |
$reflection_class = new ReflectionClass( $smart_link ); |
| 1030 |
|
| 1031 |
foreach ( $reflection_class->getProperties() as $property ) { |
| 1032 |
// Make the property accessible. |
| 1033 |
$property->setAccessible( true ); |
| 1034 |
$value = $property->getValue( $smart_link ); |
| 1035 |
// Copy the property value. |
| 1036 |
$property->setValue( $inbound_smart_link, $value ); |
| 1037 |
} |
| 1038 |
|
| 1039 |
// Make sure the source post ID is set. |
| 1040 |
$inbound_smart_link->set_source_post_id( $smart_link->source_post_id ); |
| 1041 |
|
| 1042 |
return $inbound_smart_link; |
| 1043 |
} |
| 1044 |
|
| 1045 |
/** |
| 1046 |
* Gets the existing inbound smart links for a post. |
| 1047 |
* |
| 1048 |
* @since 3.19.0 |
| 1049 |
* |
| 1050 |
* @param int $post_id The post ID. |
| 1051 |
* @return array<Inbound_Smart_Link> The existing inbound smart links. |
| 1052 |
*/ |
| 1053 |
public static function get_existing_suggestions( int $post_id ): array { |
| 1054 |
// Get pending (suggestions) inbound smart links for the post. |
| 1055 |
$smart_links = self::get_inbound_smart_links( $post_id, Smart_Link_Status::PENDING ); |
| 1056 |
|
| 1057 |
return $smart_links; |
| 1058 |
} |
| 1059 |
|
| 1060 |
/** |
| 1061 |
* Gets the number of pending inbound smart link suggestions for a post. |
| 1062 |
* |
| 1063 |
* @since 3.19.0 |
| 1064 |
* |
| 1065 |
* @param int $post_id The post ID. |
| 1066 |
* @return int The number of pending inbound smart links. |
| 1067 |
*/ |
| 1068 |
public static function get_suggestions_count( int $post_id ): int { |
| 1069 |
$cache_key = self::get_suggestions_count_cache_key( $post_id ); |
| 1070 |
$count = wp_cache_get( $cache_key, PARSELY_CACHE_GROUP ); |
| 1071 |
|
| 1072 |
if ( false !== $count && is_numeric( $count ) ) { |
| 1073 |
return (int) $count; |
| 1074 |
} |
| 1075 |
|
| 1076 |
$args = array( |
| 1077 |
'post_type' => 'parsely_smart_link', |
| 1078 |
'posts_per_page' => 0, |
| 1079 |
'fields' => 'ids', |
| 1080 |
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query |
| 1081 |
'tax_query' => array( |
| 1082 |
'relation' => 'AND', |
| 1083 |
array( |
| 1084 |
'taxonomy' => 'smart_link_destination', |
| 1085 |
'field' => 'slug', |
| 1086 |
'include_children' => false, |
| 1087 |
'terms' => (string) $post_id, |
| 1088 |
), |
| 1089 |
array( |
| 1090 |
'taxonomy' => 'smart_link_status', |
| 1091 |
'field' => 'slug', |
| 1092 |
'include_children' => false, |
| 1093 |
'terms' => Smart_Link_Status::PENDING, |
| 1094 |
), |
| 1095 |
), |
| 1096 |
); |
| 1097 |
|
| 1098 |
$query = new \WP_Query( $args ); |
| 1099 |
|
| 1100 |
wp_cache_set( $cache_key, $query->found_posts, PARSELY_CACHE_GROUP, DAY_IN_SECONDS ); |
| 1101 |
|
| 1102 |
return $query->found_posts; |
| 1103 |
} |
| 1104 |
|
| 1105 |
/** |
| 1106 |
* Deletes all pending (not applied) inbound smart links suggestions for a given post. |
| 1107 |
* |
| 1108 |
* @since 3.19.0 |
| 1109 |
* |
| 1110 |
* @param int $post_id The post ID. |
| 1111 |
* @return array<string,int> The results of the deletion. |
| 1112 |
*/ |
| 1113 |
public static function delete_pending_suggestions( int $post_id ): array { |
| 1114 |
// Get all posts of type parsely_smart_link that have the destination taxonomy set to the post_id |
| 1115 |
// and the smart_link_status set to pending. |
| 1116 |
$args = array( |
| 1117 |
'post_type' => 'parsely_smart_link', |
| 1118 |
'posts_per_page' => -1, |
| 1119 |
'fields' => 'ids', |
| 1120 |
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query |
| 1121 |
'tax_query' => array( |
| 1122 |
'relation' => 'AND', |
| 1123 |
array( |
| 1124 |
'taxonomy' => 'smart_link_destination', |
| 1125 |
'field' => 'slug', |
| 1126 |
'include_children' => false, |
| 1127 |
'terms' => (string) $post_id, |
| 1128 |
), |
| 1129 |
array( |
| 1130 |
'taxonomy' => 'smart_link_status', |
| 1131 |
'field' => 'slug', |
| 1132 |
'include_children' => false, |
| 1133 |
'terms' => Smart_Link_Status::PENDING, |
| 1134 |
), |
| 1135 |
), |
| 1136 |
); |
| 1137 |
|
| 1138 |
$query = new \WP_Query( $args ); |
| 1139 |
|
| 1140 |
$results = array( |
| 1141 |
'success' => 0, |
| 1142 |
'failed' => 0, |
| 1143 |
); |
| 1144 |
|
| 1145 |
foreach ( $query->posts as $post ) { |
| 1146 |
if ( ! is_int( $post ) ) { |
| 1147 |
++$results['failed']; |
| 1148 |
continue; |
| 1149 |
} |
| 1150 |
|
| 1151 |
$smart_link = self::get_smart_link_by_id( $post ); |
| 1152 |
|
| 1153 |
if ( false === $smart_link ) { |
| 1154 |
++$results['failed']; |
| 1155 |
continue; |
| 1156 |
} |
| 1157 |
|
| 1158 |
if ( $smart_link->delete() ) { |
| 1159 |
++$results['success']; |
| 1160 |
} else { |
| 1161 |
++$results['failed']; |
| 1162 |
} |
| 1163 |
} |
| 1164 |
|
| 1165 |
// Flush the cache for the post. |
| 1166 |
self::flush_cache_by_post_id( $post_id ); |
| 1167 |
|
| 1168 |
return $results; |
| 1169 |
} |
| 1170 |
|
| 1171 |
/** |
| 1172 |
* Gets the inbound smart links for a post. |
| 1173 |
* |
| 1174 |
* @since 3.19.0 |
| 1175 |
* |
| 1176 |
* @param int $post_id The ID of the post. |
| 1177 |
* @param string $status The status of the smart links to get. |
| 1178 |
* @return array<self> The inbound smart links. |
| 1179 |
*/ |
| 1180 |
public static function get_inbound_smart_links( int $post_id, string $status = Smart_Link_Status::ALL ): array { |
| 1181 |
$inbound_smart_links = parent::get_inbound_smart_links( $post_id, $status ); |
| 1182 |
|
| 1183 |
return array_map( |
| 1184 |
function ( Smart_Link $smart_link ) { |
| 1185 |
return self::from_smart_link( $smart_link ); |
| 1186 |
}, |
| 1187 |
$inbound_smart_links |
| 1188 |
); |
| 1189 |
} |
| 1190 |
|
| 1191 |
/** |
| 1192 |
* Gets an inbound smart link by its ID. |
| 1193 |
* |
| 1194 |
* @since 3.19.0 |
| 1195 |
* |
| 1196 |
* @param int $smart_link_id The ID of the smart link. |
| 1197 |
* @return self|false The inbound smart link, or false if not found. |
| 1198 |
*/ |
| 1199 |
public static function get_smart_link_by_id( int $smart_link_id ) { |
| 1200 |
$smart_link = parent::get_smart_link_by_id( $smart_link_id ); |
| 1201 |
if ( false === $smart_link ) { |
| 1202 |
return false; |
| 1203 |
} |
| 1204 |
|
| 1205 |
return self::from_smart_link( $smart_link ); |
| 1206 |
} |
| 1207 |
|
| 1208 |
/** |
| 1209 |
* Gets an inbound smart link by its source and destination posts. |
| 1210 |
* |
| 1211 |
* @since 3.19.0 |
| 1212 |
* |
| 1213 |
* @param int $source_post_id The ID of the source post. |
| 1214 |
* @param int $destination_post_id The ID of the destination post. |
| 1215 |
* @return self|false The inbound smart link, or false if not found. |
| 1216 |
*/ |
| 1217 |
public static function get_smart_link_by_source_and_destination( int $source_post_id, int $destination_post_id ) { |
| 1218 |
$args = array( |
| 1219 |
'post_type' => 'parsely_smart_link', |
| 1220 |
'posts_per_page' => 1, |
| 1221 |
'fields' => 'ids', |
| 1222 |
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query |
| 1223 |
'tax_query' => array( |
| 1224 |
array( |
| 1225 |
'taxonomy' => 'smart_link_destination', |
| 1226 |
'field' => 'slug', |
| 1227 |
'include_children' => false, |
| 1228 |
'terms' => (string) $destination_post_id, |
| 1229 |
), |
| 1230 |
array( |
| 1231 |
'taxonomy' => 'smart_link_source', |
| 1232 |
'field' => 'slug', |
| 1233 |
'include_children' => false, |
| 1234 |
'terms' => (string) $source_post_id, |
| 1235 |
), |
| 1236 |
), |
| 1237 |
); |
| 1238 |
|
| 1239 |
$query = new \WP_Query( $args ); |
| 1240 |
|
| 1241 |
if ( 0 === $query->post_count ) { |
| 1242 |
return false; |
| 1243 |
} |
| 1244 |
|
| 1245 |
/** @var int $post_id */ |
| 1246 |
$post_id = $query->posts[0]; |
| 1247 |
|
| 1248 |
return self::get_smart_link_by_id( $post_id ); |
| 1249 |
} |
| 1250 |
|
| 1251 |
/** |
| 1252 |
* Finds the line containing the specified text in the HTML. |
| 1253 |
* |
| 1254 |
* @since 3.19.0 |
| 1255 |
* |
| 1256 |
* @param string $html HTML to search through. |
| 1257 |
* @param string $text Text to search for. |
| 1258 |
* @return string The line containing the text. |
| 1259 |
*/ |
| 1260 |
private function find_line_with_text( string $html, string $text ): string { |
| 1261 |
$lines = explode( "\n", $html ); |
| 1262 |
foreach ( $lines as $line ) { |
| 1263 |
if ( strpos( $line, $text ) !== false ) { |
| 1264 |
return $line; |
| 1265 |
} |
| 1266 |
} |
| 1267 |
|
| 1268 |
return ''; |
| 1269 |
} |
| 1270 |
|
| 1271 |
/** |
| 1272 |
* Finds the original line for the search line in the original text. |
| 1273 |
* |
| 1274 |
* This is used to find which line in the original text should be replaced with the new |
| 1275 |
* line, that includes the smart link. |
| 1276 |
* |
| 1277 |
* @since 3.19.0 |
| 1278 |
* |
| 1279 |
* @param string $original_text The original text. |
| 1280 |
* @param string $search_line The line to search through. |
| 1281 |
* @return string The original line. |
| 1282 |
*/ |
| 1283 |
private function find_original_line( string $original_text, string $search_line ): string { |
| 1284 |
$lines = explode( "\n", $original_text ); |
| 1285 |
foreach ( $lines as $original_line ) { |
| 1286 |
if ( $original_line === $search_line ) { |
| 1287 |
return $original_line; |
| 1288 |
} |
| 1289 |
|
| 1290 |
// If the lines are the same, without HTML tags, return the original line. |
| 1291 |
if ( $this->is_the_same_line( $original_line, $search_line ) ) { |
| 1292 |
return $original_line; |
| 1293 |
} |
| 1294 |
} |
| 1295 |
|
| 1296 |
return ''; |
| 1297 |
} |
| 1298 |
|
| 1299 |
/** |
| 1300 |
* Compares two strings to check if they are equal when ignoring HTML and formatting. |
| 1301 |
* |
| 1302 |
* @since 3.19.0 |
| 1303 |
* |
| 1304 |
* @param string $line1 First line to compare. |
| 1305 |
* @param string $line2 Second line to compare. |
| 1306 |
* @return bool True if lines are the same, false otherwise. |
| 1307 |
*/ |
| 1308 |
private function is_the_same_line( string $line1, string $line2 ): bool { |
| 1309 |
// Strip HTML tags. |
| 1310 |
$text1 = wp_strip_all_tags( $line1 ); |
| 1311 |
$text2 = wp_strip_all_tags( $line2 ); |
| 1312 |
|
| 1313 |
// Normalize whitespace. |
| 1314 |
$text1 = preg_replace( '/\s+/', ' ', trim( $text1 ) ); |
| 1315 |
$text2 = preg_replace( '/\s+/', ' ', trim( $text2 ) ); |
| 1316 |
|
| 1317 |
if ( null === $text1 || null === $text2 ) { |
| 1318 |
return false; |
| 1319 |
} |
| 1320 |
|
| 1321 |
// Convert to lowercase. |
| 1322 |
$text1 = strtolower( $text1 ); |
| 1323 |
$text2 = strtolower( $text2 ); |
| 1324 |
|
| 1325 |
// Decode HTML entities. |
| 1326 |
$text1 = html_entity_decode( $text1 ); |
| 1327 |
$text2 = html_entity_decode( $text2 ); |
| 1328 |
|
| 1329 |
return $text1 === $text2; |
| 1330 |
} |
| 1331 |
|
| 1332 |
/** |
| 1333 |
* Flushes the cache for the post. |
| 1334 |
* |
| 1335 |
* @since 3.19.0 |
| 1336 |
* |
| 1337 |
* @param int $post_id The post ID. |
| 1338 |
*/ |
| 1339 |
protected static function flush_cache_by_post_id( int $post_id ): void { |
| 1340 |
parent::flush_cache_by_post_id( $post_id ); |
| 1341 |
|
| 1342 |
$cache_key = self::get_suggestions_count_cache_key( $post_id ); |
| 1343 |
wp_cache_delete( $cache_key, PARSELY_CACHE_GROUP ); |
| 1344 |
} |
| 1345 |
|
| 1346 |
/** |
| 1347 |
* Checks if a WP_Error from wp_update_post() is ignorable. |
| 1348 |
* |
| 1349 |
* @since 3.19.0 |
| 1350 |
* |
| 1351 |
* @param WP_Error $error The error to check. |
| 1352 |
* @return bool True if the error is ignorable, false otherwise. |
| 1353 |
*/ |
| 1354 |
private function is_ignorable_update_error( WP_Error $error ): bool { |
| 1355 |
// The 'invalid_page_template' error can be returned from wp_update_post() |
| 1356 |
// if the saved page template is a custom type that no longer exists. |
| 1357 |
// This error is returned *after* the post has been updated with a new smart |
| 1358 |
// link, so we can safely ignore it. |
| 1359 |
// |
| 1360 |
// The post will use the 'default' page template if a custom type |
| 1361 |
// doesn't exist anyway. |
| 1362 |
if ( 'invalid_page_template' === $error->get_error_code() ) { |
| 1363 |
return true; |
| 1364 |
} |
| 1365 |
|
| 1366 |
return false; |
| 1367 |
} |
| 1368 |
|
| 1369 |
/** |
| 1370 |
* Gets the cache key for the traffic boost suggestions count. |
| 1371 |
* |
| 1372 |
* @since 3.19.0 |
| 1373 |
* |
| 1374 |
* @param int $post_id The post ID. |
| 1375 |
* @return string The cache key. |
| 1376 |
*/ |
| 1377 |
private static function get_suggestions_count_cache_key( int $post_id ): string { |
| 1378 |
return sprintf( 'traffic-boost-suggestions-count-%d', $post_id ); |
| 1379 |
} |
| 1380 |
} |
| 1381 |
|