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