PluginProbe
Parse.ly / 3.24.2
Parse.ly v3.24.2
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 +1215 -71 3.16.3 → 3.24.2 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,123 @@
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
86 215 *
87 - * @return array<mixed> The post data.
216 + * @return SmartLinkPostData The post data.
88 217 */
89 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 +
90 244 if ( null !== $this->post_data ) {
91 245 return $this->post_data;
92 246 }
93 247
@@ -95,18 +249,26 @@
95 249 $this->source_post = get_post( $this->source_post_id );
96 250 }
97 251
98 252 $post = $this->source_post;
99 -
100 253 if ( ! $post instanceof WP_Post ) {
101 - return array();
254 + return $empty_post_data;
102 255 }
103 256
104 - // Get the post content.
105 - $content = $post->post_content;
106 257 // Get the paragraph that has the smart link UID.
107 - $paragraph = $this->get_paragraph( $content );
258 + $paragraph = $this->get_paragraph( $post );
108 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 +
109 271 $author_name = get_the_author();
110 272 if ( '' === $author_name ) {
111 273 // If the author name is empty, use the author login name.
112 274 $author_name = get_the_author_meta( 'user_login', intval( $post->post_author ) );
@@ -111,26 +273,36 @@
111 273 // If the author name is empty, use the author login name.
112 274 $author_name = get_the_author_meta( 'user_login', intval( $post->post_author ) );
113 275 }
114 276
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;
277 + $post_type = get_post_type_object( $post->post_type );
278 + if ( null === $post_type ) {
279 + return $empty_post_data;
119 280 }
120 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 +
121 287 $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' ),
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' ) : '',
133 305 );
134 306
135 307 return $this->post_data;
136 308 }
@@ -139,78 +311,711 @@
139 311 * Get the HTML paragraph that has the smart link UID.
140 312 *
141 313 * @since 3.16.0
142 314 *
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}
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.
146 318 */
147 - private function get_paragraph( string $content ): array {
319 + private function get_paragraph( \WP_Post $post ) {
320 + /* phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase */
148 321 if ( null !== $this->paragraph_data ) {
149 322 return $this->paragraph_data;
150 323 }
151 324
152 - $paragraph = '';
153 325 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 - );
326 + return new \WP_Error( 'traffic_boost_dom_not_available', __( 'DOMDocument is not available', 'wp-parsely' ) );
160 327 }
161 328
329 + // Initialize the HTML parser.
162 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 + );
163 338
164 - $dom = new DOMDocument();
165 - $dom->loadHTML( mb_convert_encoding( $content, 'HTML-ENTITIES', 'UTF-8' ), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
339 + $source_post_has_blocks = has_blocks( $post );
340 + $content = $post->post_content;
166 341
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 - );
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;
176 388 }
177 389
178 - // Fetch all paragraph tags.
179 - $paragraphs = $dom->getElementsByTagName( 'p' );
180 -
181 390 $is_first_paragraph = true;
182 391 $is_last_paragraph = false;
392 + $paragraph = null;
393 + $paragraph_offset = 0;
183 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. */
184 399 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;
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 + }
195 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 + }
196 461 }
197 462 }
198 463
199 - if ( false === $paragraph ) {
200 - $paragraph = '<p>' . __( 'Unable to fetch paragraph.', 'wp-parsely' ) . '</p>';
464 + if ( null === $paragraph ) {
465 + return new \WP_Error( 'traffic_boost_paragraph_not_found', __( 'Paragraph not found.', 'wp-parsely' ) );
201 466 }
202 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. */
203 476 $this->paragraph_data = array(
204 477 'paragraph' => $paragraph,
205 478 'is_first_paragraph' => $is_first_paragraph,
206 479 'is_last_paragraph' => $is_last_paragraph,
480 + 'paragraph_offset' => $paragraph_offset,
207 481 );
208 482
483 + libxml_clear_errors();
209 484 return $this->paragraph_data;
485 + /* phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase */
210 486 }
211 487
212 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 + /**
213 1018 * Creates a new instance of an Inbound Smart Link from a Smart Link object.
214 1019 *
215 1020 * This is used to convert a Smart Link object to an Inbound Smart Link object.
216 1021 *
@@ -230,7 +1035,346 @@
230 1035 // Copy the property value.
231 1036 $property->setValue( $inbound_smart_link, $value );
232 1037 }
233 1038
1039 + // Make sure the source post ID is set.
1040 + $inbound_smart_link->set_source_post_id( $smart_link->source_post_id );
1041 +
234 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, // phpcs:ignore WordPressVIPMinimum.Performance.NoPaging.posts_per_page_posts_per_page
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 );
235 1379 }
236 1380 }