PluginProbe
Parse.ly / 3.24.3
Parse.ly v3.24.3
3.24.3 3.24.2 3.24.1 3.24.0 3.23.7 3.23.6 3.23.5 3.23.4 3.23.3 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.17.0 3.18.0 3.18.1 3.19.0 3.19.1 3.19.2 3.19.3 3.2.0 3.2.1 3.20.0 3.20.1 All 107 releases
wp-parsely / src / Models / class-inbound-smart-link.php

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

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