| @@ -18,8 +18,23 @@ | ||
| 18 | 18 | */ |
| 19 | 19 | class Indexable_Repository { |
| 20 | 20 | |
| 21 | 21 | /** |
| 22 | + * The maximum number of comma-separated phrases honoured by a title-keyword search. | |
| 23 | + * Any phrases beyond this are ignored so an oversized list cannot blow up the query. | |
| 24 | + * | |
| 25 | + * @var int | |
| 26 | + */ | |
| 27 | + public const MAX_TITLE_KEYWORD_PHRASES = 10; | |
| 28 | + | |
| 29 | + /** | |
| 30 | + * The maximum page size honoured by a title-keyword search. | |
| 31 | + * | |
| 32 | + * @var int | |
| 33 | + */ | |
| 34 | + public const MAX_TITLE_KEYWORD_PAGE_SIZE = 100; | |
| 35 | + | |
| 36 | + /** | |
| 22 | 37 | * The indexable builder. |
| 23 | 38 | * |
| 24 | 39 | * @var Indexable_Builder |
| 25 | 40 | */ |
| @@ -106,9 +121,10 @@ | ||
| 106 | 121 | * Attempts to find the indexable for the current WordPress page. Returns false if no indexable could be found. |
| 107 | 122 | * This may be the result of the indexable not existing or of being unable to determine what type of page the |
| 108 | 123 | * current page is. |
| 109 | 124 | * |
| 110 | - * @return bool|Indexable The indexable, false if none could be found. | |
| 125 | + * @return bool|Indexable The indexable. If no indexable is found returns an empty indexable. Returns false if | |
| 126 | + * there is a database error. | |
| 111 | 127 | */ |
| 112 | 128 | public function for_current_page() { |
| 113 | 129 | $indexable = false; |
| 114 | 130 | |
| @@ -147,9 +163,9 @@ | ||
| 147 | 163 | [ |
| 148 | 164 | 'object_type' => 'unknown', |
| 149 | 165 | 'post_status' => 'unindexed', |
| 150 | 166 | 'version' => 1, |
| 151 | - ] | |
| 167 | + ], | |
| 152 | 168 | ); |
| 153 | 169 | } |
| 154 | 170 | |
| 155 | 171 | return $indexable; |
| @@ -216,8 +232,29 @@ | ||
| 216 | 232 | return \array_map( [ $this, 'upgrade_indexable' ], $indexables ); |
| 217 | 233 | } |
| 218 | 234 | |
| 219 | 235 | /** |
| 236 | + * Retrieves a paginated set of indexable instances of public indexables. | |
| 237 | + * | |
| 238 | + * @param int $page The page number (1-based). | |
| 239 | + * @param int $page_size The number of items per page. | |
| 240 | + * @param string $post_type The post type indexables to find. | |
| 241 | + * | |
| 242 | + * @return Indexable[] The array with the paginated indexable instances which are public. | |
| 243 | + */ | |
| 244 | + public function find_all_public_paginated( int $page, int $page_size, string $post_type ): array { | |
| 245 | + $offset = ( ( $page - 1 ) * $page_size ); | |
| 246 | + | |
| 247 | + $query = $this->query()->where_raw( '( is_public IS NULL OR is_public = 1 ) AND ( is_robots_noindex IS NULL OR is_robots_noindex = 0 )' ); | |
| 248 | + $query->where( 'object_sub_type', $post_type ); | |
| 249 | + $query->where( 'post_status', 'publish' ); | |
| 250 | + | |
| 251 | + $indexables = $query->order_by_asc( 'id' )->limit( $page_size )->offset( $offset )->find_many(); | |
| 252 | + | |
| 253 | + return \array_map( [ $this, 'upgrade_indexable' ], $indexables ); | |
| 254 | + } | |
| 255 | + | |
| 256 | + /** | |
| 220 | 257 | * Retrieves the homepage indexable. |
| 221 | 258 | * |
| 222 | 259 | * @param bool $auto_create Optional. Create the indexable if it does not exist. |
| 223 | 260 | * |
| @@ -375,8 +412,18 @@ | ||
| 375 | 412 | } |
| 376 | 413 | |
| 377 | 414 | $indexables_to_create = \array_diff( $object_ids, $indexables_available ); |
| 378 | 415 | |
| 416 | + if ( ! empty( $indexables_to_create ) ) { | |
| 417 | + // Warm the object caches for the whole batch, so each build below does not trigger its own uncached queries. | |
| 418 | + if ( $object_type === 'post' ) { | |
| 419 | + \_prime_post_caches( $indexables_to_create ); | |
| 420 | + } | |
| 421 | + elseif ( $object_type === 'term' ) { | |
| 422 | + \_prime_term_caches( $indexables_to_create ); | |
| 423 | + } | |
| 424 | + } | |
| 425 | + | |
| 379 | 426 | foreach ( $indexables_to_create as $indexable_to_create ) { |
| 380 | 427 | $indexables[] = $this->builder->build_for_id_and_type( $indexable_to_create, $object_type ); |
| 381 | 428 | } |
| 382 | 429 | } |
| @@ -386,9 +433,9 @@ | ||
| 386 | 433 | |
| 387 | 434 | /** |
| 388 | 435 | * Finds the indexables by id's. |
| 389 | 436 | * |
| 390 | - * @param array $indexable_ids The indexable id's. | |
| 437 | + * @param int[] $indexable_ids The indexable id's. | |
| 391 | 438 | * |
| 392 | 439 | * @return Indexable[] The found indexables. |
| 393 | 440 | */ |
| 394 | 441 | public function find_by_ids( array $indexable_ids ) { |
| @@ -443,9 +490,9 @@ | ||
| 443 | 490 | /** |
| 444 | 491 | * Returns all subpages with a given post_parent. |
| 445 | 492 | * |
| 446 | 493 | * @param int $post_parent The post parent. |
| 447 | - * @param array $exclude_ids The id's to exclude. | |
| 494 | + * @param int[] $exclude_ids The id's to exclude. | |
| 448 | 495 | * |
| 449 | 496 | * @return Indexable[] array of indexables. |
| 450 | 497 | */ |
| 451 | 498 | public function get_subpages_by_post_parent( $post_parent, $exclude_ids = [] ) { |
| @@ -460,8 +507,236 @@ | ||
| 460 | 507 | return $query->find_many(); |
| 461 | 508 | } |
| 462 | 509 | |
| 463 | 510 | /** |
| 511 | + * Returns most recently modified posts of a post type. | |
| 512 | + * | |
| 513 | + * @param string $post_type The post type. | |
| 514 | + * @param int $limit The maximum number of posts to return. | |
| 515 | + * @param bool $exclude_older_than_one_year Whether to exclude posts older than one year. | |
| 516 | + * @param string $search_filter Optional. A search filter to apply to the breadcrumb title. | |
| 517 | + * | |
| 518 | + * @return Indexable[] array of indexables. | |
| 519 | + */ | |
| 520 | + public function get_recently_modified_posts( string $post_type, int $limit, bool $exclude_older_than_one_year, string $search_filter = '' ) { | |
| 521 | + $query = $this->query() | |
| 522 | + ->where( 'object_type', 'post' ) | |
| 523 | + ->where( 'object_sub_type', $post_type ) | |
| 524 | + ->where_raw( '( is_public IS NULL OR is_public = 1 )' ) | |
| 525 | + ->order_by_desc( 'object_last_modified' ) | |
| 526 | + ->limit( $limit ); | |
| 527 | + | |
| 528 | + if ( $exclude_older_than_one_year === true ) { | |
| 529 | + $query->where_gte( 'object_published_at', \gmdate( 'Y-m-d H:i:s', \strtotime( '-1 year' ) ) ); | |
| 530 | + } | |
| 531 | + | |
| 532 | + if ( $search_filter !== '' ) { | |
| 533 | + $query->where_like( 'breadcrumb_title', '%' . $search_filter . '%' ); | |
| 534 | + } | |
| 535 | + | |
| 536 | + $query->order_by_desc( 'object_last_modified' ) | |
| 537 | + ->limit( $limit ); | |
| 538 | + | |
| 539 | + return $query->find_many(); | |
| 540 | + } | |
| 541 | + | |
| 542 | + /** | |
| 543 | + * Finds posts whose breadcrumb title contains any of the given comma-separated phrases. | |
| 544 | + * | |
| 545 | + * The search string is a comma-separated list. Each value is matched as a whole | |
| 546 | + * contiguous substring of the breadcrumb title, and a post is returned when it | |
| 547 | + * contains any one of the values (a logical OR between values). For example, | |
| 548 | + * "hiking boots, trail" returns posts whose title contains "hiking boots" or "trail". | |
| 549 | + * | |
| 550 | + * Results are paginated and ordered most recently modified first (with the indexable | |
| 551 | + * id as a stable tiebreaker), so requesting a later page returns older matches. | |
| 552 | + * | |
| 553 | + * At most self::MAX_TITLE_KEYWORD_PHRASES phrases are honoured; any beyond that are ignored. | |
| 554 | + * The page size is clamped to the range 1..self::MAX_TITLE_KEYWORD_PAGE_SIZE. | |
| 555 | + * | |
| 556 | + * @param string $keywords The comma-separated phrases to match against the breadcrumb title. | |
| 557 | + * @param int $page The page of results to return, 1-based. | |
| 558 | + * @param int $page_size The number of posts per page. | |
| 559 | + * @param string $post_type The post type to restrict the search to. | |
| 560 | + * | |
| 561 | + * @return Indexable[] The matching indexables for the requested page, ordered by most recently modified. | |
| 562 | + */ | |
| 563 | + public function find_posts_by_title_keywords( string $keywords, int $page = 1, int $page_size = 10, string $post_type = 'post' ) { | |
| 564 | + $phrases = \array_map( 'trim', \explode( ',', $keywords ) ); | |
| 565 | + $phrases = \array_filter( | |
| 566 | + $phrases, | |
| 567 | + static function ( $phrase ) { | |
| 568 | + return $phrase !== ''; | |
| 569 | + }, | |
| 570 | + ); | |
| 571 | + $phrases = \array_slice( $phrases, 0, self::MAX_TITLE_KEYWORD_PHRASES ); | |
| 572 | + | |
| 573 | + // An empty search must not degrade into matching every post. | |
| 574 | + if ( empty( $phrases ) ) { | |
| 575 | + return []; | |
| 576 | + } | |
| 577 | + | |
| 578 | + $likes = \array_fill( 0, \count( $phrases ), 'breadcrumb_title LIKE %s' ); | |
| 579 | + $params = \array_map( | |
| 580 | + function ( $phrase ) { | |
| 581 | + return '%' . $this->wpdb->esc_like( $phrase ) . '%'; | |
| 582 | + }, | |
| 583 | + $phrases, | |
| 584 | + ); | |
| 585 | + | |
| 586 | + $page_size = \min( \max( 1, $page_size ), self::MAX_TITLE_KEYWORD_PAGE_SIZE ); | |
| 587 | + $offset = ( ( \max( 1, $page ) - 1 ) * $page_size ); | |
| 588 | + | |
| 589 | + $indexables = $this->query() | |
| 590 | + ->where( 'object_type', 'post' ) | |
| 591 | + ->where( 'object_sub_type', $post_type ) | |
| 592 | + ->where_raw( '( ' . \implode( ' OR ', $likes ) . ' )', \array_values( $params ) ) | |
| 593 | + ->order_by_desc( 'object_last_modified' ) | |
| 594 | + ->order_by_desc( 'id' ) | |
| 595 | + ->limit( $page_size ) | |
| 596 | + ->offset( $offset ) | |
| 597 | + ->find_many(); | |
| 598 | + | |
| 599 | + return \array_map( [ $this, 'upgrade_indexable' ], $indexables ); | |
| 600 | + } | |
| 601 | + | |
| 602 | + /** | |
| 603 | + * Returns the most recently modified cornerstone content of a post type. | |
| 604 | + * | |
| 605 | + * @param string $post_type The post type. | |
| 606 | + * @param int|null $limit The maximum number of posts to return. | |
| 607 | + * | |
| 608 | + * @return Indexable[] array of indexables. | |
| 609 | + */ | |
| 610 | + public function get_recent_cornerstone_for_post_type( string $post_type, ?int $limit ) { | |
| 611 | + $query = $this->query() | |
| 612 | + ->where( 'object_type', 'post' ) | |
| 613 | + ->where( 'object_sub_type', $post_type ) | |
| 614 | + ->where_raw( '( is_public IS NULL OR is_public = 1 )' ) | |
| 615 | + ->where( 'is_cornerstone', 1 ) | |
| 616 | + ->order_by_desc( 'object_last_modified' ); | |
| 617 | + | |
| 618 | + if ( $limit !== null ) { | |
| 619 | + $query->limit( $limit ); | |
| 620 | + } | |
| 621 | + | |
| 622 | + return $query->find_many(); | |
| 623 | + } | |
| 624 | + | |
| 625 | + /** | |
| 626 | + * Returns the most recently modified about page based on schema_page_type. | |
| 627 | + * | |
| 628 | + * @param string $post_type The post type. | |
| 629 | + * | |
| 630 | + * @return Indexable|false The about page if its there. | |
| 631 | + */ | |
| 632 | + public function get_most_recent_about_page( $post_type ) { | |
| 633 | + $query = $this->query() | |
| 634 | + ->where( 'object_type', 'post' ) | |
| 635 | + ->where( 'object_sub_type', $post_type ) | |
| 636 | + ->where( 'schema_page_type', 'AboutPage' ) | |
| 637 | + ->where_raw( '( is_public IS NULL OR is_public = 1 )' ) | |
| 638 | + ->order_by_desc( 'object_last_modified' ); | |
| 639 | + | |
| 640 | + return $query->find_one(); | |
| 641 | + } | |
| 642 | + | |
| 643 | + /** | |
| 644 | + * Returns the most recently modified posts with keywords of a post type. | |
| 645 | + * | |
| 646 | + * @param string $post_type The post type. | |
| 647 | + * @param int|null $limit The maximum number of posts to return. | |
| 648 | + * @param string|null $date_limit Only include content modified after this date. | |
| 649 | + * | |
| 650 | + * @return array<array<string, string>>|false The array of indexable columns. False if the query failed. | |
| 651 | + */ | |
| 652 | + public function get_recent_posts_with_keywords_for_post_type( string $post_type, ?int $limit = null, ?string $date_limit = null ) { | |
| 653 | + $query = $this->query() | |
| 654 | + ->select( 'object_id' ) | |
| 655 | + ->select( 'primary_focus_keyword_score' ) | |
| 656 | + ->select( 'breadcrumb_title' ) | |
| 657 | + ->where( 'object_type', 'post' ) | |
| 658 | + ->where( 'object_sub_type', $post_type ) | |
| 659 | + ->where_not_equal( 'primary_focus_keyword_score', 0 ) | |
| 660 | + ->where_not_null( 'primary_focus_keyword_score' ) | |
| 661 | + ->where_raw( "( post_status = 'publish' OR post_status IS NULL )" ) | |
| 662 | + ->where_raw( '( is_robots_noindex IS NULL OR is_robots_noindex <> 1 )' ) | |
| 663 | + ->order_by_desc( 'object_last_modified' ); | |
| 664 | + | |
| 665 | + if ( $limit !== null ) { | |
| 666 | + $query->limit( $limit ); | |
| 667 | + } | |
| 668 | + | |
| 669 | + if ( $date_limit !== null ) { | |
| 670 | + $query->where_gte( 'object_last_modified', $date_limit ); | |
| 671 | + } | |
| 672 | + | |
| 673 | + return $query->find_array(); | |
| 674 | + } | |
| 675 | + | |
| 676 | + /** | |
| 677 | + * Returns the most recently modified posts with readability scores of a post type. | |
| 678 | + * | |
| 679 | + * @param string $post_type The post type. | |
| 680 | + * @param int|null $limit The maximum number of posts to return. | |
| 681 | + * @param string|null $date_limit Only include content modified after this date. | |
| 682 | + * | |
| 683 | + * @return array<array<string, string|null>>|false The array of indexable columns. False if the query failed. | |
| 684 | + */ | |
| 685 | + public function get_recent_posts_with_readability_scores_for_post_type( string $post_type, ?int $limit = null, ?string $date_limit = null ) { | |
| 686 | + $query = $this->query() | |
| 687 | + ->select( 'object_id' ) | |
| 688 | + ->select( 'readability_score' ) | |
| 689 | + ->select( 'breadcrumb_title' ) | |
| 690 | + ->where( 'object_type', 'post' ) | |
| 691 | + ->where( 'object_sub_type', $post_type ) | |
| 692 | + ->where_not_null( 'estimated_reading_time_minutes' ) | |
| 693 | + ->where_raw( "( post_status = 'publish' OR post_status IS NULL )" ) | |
| 694 | + ->order_by_desc( 'object_last_modified' ); | |
| 695 | + | |
| 696 | + if ( $limit !== null ) { | |
| 697 | + $query->limit( $limit ); | |
| 698 | + } | |
| 699 | + | |
| 700 | + if ( $date_limit !== null ) { | |
| 701 | + $query->where_gte( 'object_last_modified', $date_limit ); | |
| 702 | + } | |
| 703 | + | |
| 704 | + return $query->find_array(); | |
| 705 | + } | |
| 706 | + | |
| 707 | + /** | |
| 708 | + * Returns the most recently modified posts for a post type. | |
| 709 | + * | |
| 710 | + * @param string $post_type The post type. | |
| 711 | + * @param int|null $limit The maximum number of posts to return. | |
| 712 | + * @param string|null $date_limit Only include content modified after this date. | |
| 713 | + * | |
| 714 | + * @return array<array<string, string|null>>|false The array of indexable columns. False if the query failed. | |
| 715 | + */ | |
| 716 | + public function get_recent_posts_for_post_type( string $post_type, ?int $limit = null, ?string $date_limit = null ) { | |
| 717 | + $query = $this->query() | |
| 718 | + ->select( 'object_id' ) | |
| 719 | + ->select( 'breadcrumb_title' ) | |
| 720 | + ->select( 'description' ) | |
| 721 | + ->where( 'object_type', 'post' ) | |
| 722 | + ->where( 'object_sub_type', $post_type ) | |
| 723 | + ->where_raw( "( post_status = 'publish' OR post_status IS NULL )" ) | |
| 724 | + ->where_raw( '( is_robots_noindex IS NULL OR is_robots_noindex <> 1 )' ) | |
| 725 | + ->order_by_desc( 'object_last_modified' ); | |
| 726 | + | |
| 727 | + if ( $limit !== null ) { | |
| 728 | + $query->limit( $limit ); | |
| 729 | + } | |
| 730 | + | |
| 731 | + if ( $date_limit !== null ) { | |
| 732 | + $query->where_gte( 'object_last_modified', $date_limit ); | |
| 733 | + } | |
| 734 | + | |
| 735 | + return $query->find_array(); | |
| 736 | + } | |
| 737 | + | |
| 738 | + /** | |
| 464 | 739 | * Updates the incoming link count for an indexable without first fetching it. |
| 465 | 740 | * |
| 466 | 741 | * @param int $indexable_id The indexable id. |
| 467 | 742 | * @param int $count The incoming link count. |
| @@ -488,9 +763,9 @@ | ||
| 488 | 763 | */ |
| 489 | 764 | public function ensure_permalink( $indexable ) { |
| 490 | 765 | // @phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- self::class is safe. |
| 491 | 766 | // @phpcs:ignore Squiz.PHP.CommentedOutCode.Found |
| 492 | - // _deprecated_function( __METHOD__, 'WPSEO 17.3', self::class . '::upgrade_indexable' ); | |
| 767 | + // _deprecated_function( __METHOD__, 'Yoast SEO 17.3', self::class . '::upgrade_indexable' ); | |
| 493 | 768 | |
| 494 | 769 | return $this->upgrade_indexable( $indexable ); |
| 495 | 770 | } |
| 496 | 771 | |
| @@ -510,20 +785,21 @@ | ||
| 510 | 785 | |
| 511 | 786 | /** |
| 512 | 787 | * Resets the permalinks of the passed object type and subtype. |
| 513 | 788 | * |
| 514 | - * @param string|null $type The type of the indexable. Can be null. | |
| 515 | - * @param string|null $subtype The subtype. Can be null. | |
| 789 | + * @param string|null $type The type of the indexable. Can be null. | |
| 790 | + * @param string|null $subtype The subtype. Can be null. | |
| 791 | + * @param int|null $object_id The object ID. Can be null. | |
| 516 | 792 | * |
| 517 | 793 | * @return int|bool The number of permalinks changed if the query was succesful. False otherwise. |
| 518 | 794 | */ |
| 519 | - public function reset_permalink( $type = null, $subtype = null ) { | |
| 795 | + public function reset_permalink( $type = null, $subtype = null, $object_id = null ) { | |
| 520 | 796 | $query = $this->query()->set( |
| 521 | 797 | [ |
| 522 | 798 | 'permalink' => null, |
| 523 | 799 | 'permalink_hash' => null, |
| 524 | 800 | 'version' => 0, |
| 525 | - ] | |
| 801 | + ], | |
| 526 | 802 | ); |
| 527 | 803 | |
| 528 | 804 | if ( $type !== null ) { |
| 529 | 805 | $query->where( 'object_type', $type ); |
| @@ -532,7 +808,20 @@ | ||
| 532 | 808 | if ( $type !== null && $subtype !== null ) { |
| 533 | 809 | $query->where( 'object_sub_type', $subtype ); |
| 534 | 810 | } |
| 535 | 811 | |
| 812 | + if ( $object_id !== null ) { | |
| 813 | + $query->where( 'object_id', $object_id ); | |
| 814 | + } | |
| 815 | + | |
| 536 | 816 | return $query->update_many(); |
| 817 | + } | |
| 818 | + | |
| 819 | + /** | |
| 820 | + * Gets the total number of stored indexables. | |
| 821 | + * | |
| 822 | + * @return int The total number of stored indexables. | |
| 823 | + */ | |
| 824 | + public function get_total_number_of_indexables() { | |
| 825 | + return $this->query()->count(); | |
| 537 | 826 | } |
| 538 | 827 | } |