PluginProbe
Parse.ly / 3.20.6
Parse.ly v3.20.6
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 3.20.2 3.20.3 All 105 releases
wp-parsely / src / Models / class-inbound-smart-link.php

class-inbound-smart-link.php in Parse.ly 3.20.6, at src/Models/class-inbound-smart-link.php

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