Skip to content

Commit 992d0ac

Browse files
authored
Merge pull request #22608 from Yoast/add/alert-for-unused-ai-generate
Add notification for unused SEO title and description features
2 parents 52ee80b + aa0ae8c commit 992d0ac

7 files changed

Lines changed: 487 additions & 0 deletions

File tree

inc/options/class-wpseo-option-wpseo.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ class WPSEO_Option_Wpseo extends WPSEO_Option {
151151
'ai_free_sparks_started_on' => null,
152152
'enable_llms_txt' => false,
153153
'last_updated_on' => false,
154+
'default_seo_title' => [],
155+
'default_seo_meta_desc' => [],
154156
];
155157

156158
/**
@@ -432,6 +434,8 @@ protected function validate_option( $dirty, $clean, $old ) {
432434
case 'last_known_public_taxonomies':
433435
case 'new_post_types':
434436
case 'new_taxonomies':
437+
case 'default_seo_title':
438+
case 'default_seo_meta_desc':
435439
$clean[ $key ] = $old[ $key ];
436440

437441
if ( isset( $dirty[ $key ] ) ) {
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
<?php
2+
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
3+
namespace Yoast\WP\SEO\Alerts\Application\Default_SEO_Data;
4+
5+
use Yoast\WP\SEO\Alerts\Infrastructure\Default_SEO_Data\Default_SEO_Data_Collector;
6+
use Yoast\WP\SEO\Conditionals\Admin_Conditional;
7+
use Yoast\WP\SEO\Helpers\Indexable_Helper;
8+
use Yoast\WP\SEO\Helpers\Post_Type_Helper;
9+
use Yoast\WP\SEO\Helpers\Product_Helper;
10+
use Yoast\WP\SEO\Helpers\Short_Link_Helper;
11+
use Yoast\WP\SEO\Integrations\Integration_Interface;
12+
use Yoast_Notification;
13+
use Yoast_Notification_Center;
14+
15+
/**
16+
* Default_SEO_Data_Alert class.
17+
*/
18+
class Default_SEO_Data_Alert implements Integration_Interface {
19+
20+
public const NOTIFICATION_ID = 'wpseo-default-seo-data';
21+
22+
/**
23+
* The notifications center.
24+
*
25+
* @var Yoast_Notification_Center
26+
*/
27+
private $notification_center;
28+
29+
/**
30+
* The default SEO data collector.
31+
*
32+
* @var Default_SEO_Data_Collector
33+
*/
34+
private $default_seo_data_collector;
35+
36+
/**
37+
* The short link helper.
38+
*
39+
* @var Short_Link_Helper
40+
*/
41+
private $short_link_helper;
42+
43+
/**
44+
* The product helper.
45+
*
46+
* @var Product_Helper
47+
*/
48+
private $product_helper;
49+
50+
/**
51+
* The indexable helper.
52+
*
53+
* @var Indexable_Helper
54+
*/
55+
private $indexable_helper;
56+
57+
/**
58+
* The post type helper.
59+
*
60+
* @var Post_Type_Helper
61+
*/
62+
private $post_type_helper;
63+
64+
/**
65+
* Default_SEO_Data_Alert constructor.
66+
*
67+
* @param Yoast_Notification_Center $notification_center The notification center.
68+
* @param Default_SEO_Data_Collector $default_seo_data_collector The default SEO data collector.
69+
* @param Short_Link_Helper $short_link_helper The short link helper.
70+
* @param Product_Helper $product_helper The product helper.
71+
* @param Indexable_Helper $indexable_helper The indexable helper.
72+
* @param Post_Type_Helper $post_type_helper The post type helper.
73+
*/
74+
public function __construct(
75+
Yoast_Notification_Center $notification_center,
76+
Default_SEO_Data_Collector $default_seo_data_collector,
77+
Short_Link_Helper $short_link_helper,
78+
Product_Helper $product_helper,
79+
Indexable_Helper $indexable_helper,
80+
Post_Type_Helper $post_type_helper
81+
) {
82+
$this->notification_center = $notification_center;
83+
$this->default_seo_data_collector = $default_seo_data_collector;
84+
$this->short_link_helper = $short_link_helper;
85+
$this->product_helper = $product_helper;
86+
$this->indexable_helper = $indexable_helper;
87+
$this->post_type_helper = $post_type_helper;
88+
}
89+
90+
/**
91+
* Returns the conditionals based on which this loadable should be active.
92+
*
93+
* @return array<string>
94+
*/
95+
public static function get_conditionals() {
96+
return [ Admin_Conditional::class ];
97+
}
98+
99+
/**
100+
* Initializes the integration.
101+
*
102+
* @return void
103+
*/
104+
public function register_hooks() {
105+
\add_action( 'admin_init', [ $this, 'add_notifications' ] );
106+
}
107+
108+
/**
109+
* Adds notifications (when necessary).
110+
*
111+
* We want to show this notification only when there are enough posts that have the default SEO title or meta description, or both.
112+
* If this is not the case we will not show the notification at all since it does not serve a purpose yet.
113+
*
114+
* @return void
115+
*/
116+
public function add_notifications() {
117+
if ( ! $this->indexable_helper->should_index_indexables() ) {
118+
// Do not show the notification when indexables are disabled.
119+
$this->notification_center->remove_notification_by_id( self::NOTIFICATION_ID );
120+
return;
121+
}
122+
123+
if ( ! $this->post_type_helper->is_indexable( 'post' ) || ! $this->post_type_helper->has_metabox( 'post' ) ) {
124+
// Do not show the notification when posts are not indexable or have no metabox.
125+
$this->notification_center->remove_notification_by_id( self::NOTIFICATION_ID );
126+
return;
127+
}
128+
129+
$posts_with_default_seo_title = $this->default_seo_data_collector->get_posts_with_default_seo_title();
130+
$posts_with_default_seo_description = $this->default_seo_data_collector->get_posts_with_default_seo_description();
131+
132+
$has_enough_posts_with_default_title = \count( $posts_with_default_seo_title ) > 4;
133+
$has_enough_posts_with_default_desc = \count( $posts_with_default_seo_description ) > 4;
134+
135+
if ( ! $has_enough_posts_with_default_title && ! $has_enough_posts_with_default_desc ) {
136+
$this->notification_center->remove_notification_by_id( self::NOTIFICATION_ID );
137+
return;
138+
}
139+
140+
$notification = $this->get_default_seo_data_notification( $has_enough_posts_with_default_title, $has_enough_posts_with_default_desc );
141+
142+
$this->notification_center->add_notification( $notification );
143+
}
144+
145+
/**
146+
* Build the default SEO data notification.
147+
*
148+
* @param bool $has_enough_posts_with_default_title Whether there are content types with default SEO title in their most recent posts.
149+
* @param bool $has_enough_posts_with_default_desc Whether there are content types with default SEO description in their most recent posts.
150+
*
151+
* @return Yoast_Notification The notification containing the suggested plugin.
152+
*/
153+
private function get_default_seo_data_notification( $has_enough_posts_with_default_title, $has_enough_posts_with_default_desc ): Yoast_Notification {
154+
$message = $this->get_default_seo_data_message( $has_enough_posts_with_default_title, $has_enough_posts_with_default_desc );
155+
156+
return new Yoast_Notification(
157+
$message,
158+
[
159+
'id' => self::NOTIFICATION_ID,
160+
'type' => Yoast_Notification::WARNING,
161+
'capabilities' => [ 'wpseo_manage_options' ],
162+
]
163+
);
164+
}
165+
166+
/**
167+
* Creates a message to inform users that they are using only default SEO data lately.
168+
*
169+
* @param bool $has_enough_posts_with_default_title Whether there are content types with default SEO title in their most recent posts.
170+
* @param bool $has_enough_posts_with_default_desc Whether there are content types with default SEO description in their most recent posts.
171+
*
172+
* @return string The default SEO data message.
173+
*/
174+
private function get_default_seo_data_message( $has_enough_posts_with_default_title, $has_enough_posts_with_default_desc ): string {
175+
$shortlink = ( $this->product_helper->is_premium() ) ? $this->short_link_helper->get( 'https://yoa.st/ai-generate-alert-premium/' ) : $this->short_link_helper->get( 'https://yoa.st/ai-generate-alert-free/' );
176+
177+
if ( $has_enough_posts_with_default_title && $has_enough_posts_with_default_desc ) {
178+
$default_seo_data = \esc_html__( 'SEO titles and meta descriptions', 'wordpress-seo' );
179+
}
180+
elseif ( $has_enough_posts_with_default_title ) {
181+
$default_seo_data = \esc_html__( 'SEO titles', 'wordpress-seo' );
182+
}
183+
elseif ( $has_enough_posts_with_default_desc ) {
184+
$default_seo_data = \esc_html__( 'meta descriptions', 'wordpress-seo' );
185+
}
186+
else {
187+
$default_seo_data = \esc_html__( 'SEO data', 'wordpress-seo' );
188+
}
189+
190+
/* translators: %1$s expands to "SEO title" or "meta description", %2$s expands to an opening strong tag, %3$s expands to a closing strong tag, %4$s expands to an opening link tag, %5$s expands to a closing link tag. */
191+
$message = ( $this->product_helper->is_premium() ) ? \esc_html__( 'Your recent posts are using default %1$s, making them less appealing in search. Create custom titles and descriptions instantly with %2$sYoast AI Generate%3$s. %4$sLearn how to use it%5$s.', 'wordpress-seo' ) : \esc_html__( 'Your recent posts are using default %1$s, which makes them easy to overlook. Catch attention in search with custom titles and descriptions from %2$sYoast AI Generate%3$s. %4$sTry it for free.%5$s', 'wordpress-seo' );
192+
193+
return \sprintf(
194+
$message,
195+
$default_seo_data,
196+
'<strong>',
197+
'</strong>',
198+
'<a href="' . \esc_url( $shortlink ) . '">',
199+
'</a>'
200+
);
201+
}
202+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong
3+
namespace Yoast\WP\SEO\Alerts\Infrastructure\Default_SEO_Data;
4+
5+
use Yoast\WP\SEO\Helpers\Options_Helper;
6+
7+
/**
8+
* Class that collects default SEO data.
9+
*/
10+
class Default_SEO_Data_Collector {
11+
12+
/**
13+
* Holds the Options_Helper instance.
14+
*
15+
* @var Options_Helper
16+
*/
17+
private $options_helper;
18+
19+
/**
20+
* The constructor.
21+
*
22+
* @param Options_Helper $options_helper The options helper.
23+
*/
24+
public function __construct( Options_Helper $options_helper ) {
25+
$this->options_helper = $options_helper;
26+
}
27+
28+
/**
29+
* Returns the posts with default SEO title in their most recent.
30+
*
31+
* @return string[] The posts with default SEO title in their most recent.
32+
*/
33+
public function get_posts_with_default_seo_title(): array {
34+
return $this->options_helper->get( 'default_seo_title', [] );
35+
}
36+
37+
/**
38+
* Returns the posts with default SEO description in their most recent.
39+
*
40+
* @return string[] The posts with default SEO description in their most recent.
41+
*/
42+
public function get_posts_with_default_seo_description(): array {
43+
return $this->options_helper->get( 'default_seo_meta_desc', [] );
44+
}
45+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
3+
namespace Yoast\WP\SEO\Alerts\User_Interface\Default_SEO_Data;
4+
5+
use Yoast\WP\SEO\Alerts\User_Interface\Default_Seo_Data\Default_SEO_Data_Cron_Scheduler;
6+
use Yoast\WP\SEO\Conditionals\No_Conditionals;
7+
use Yoast\WP\SEO\Helpers\Options_Helper;
8+
use Yoast\WP\SEO\Integrations\Integration_Interface;
9+
use Yoast\WP\SEO\Repositories\Indexable_Repository;
10+
11+
/**
12+
* Cron Callback integration. This handles the actual process of detecting default SEO data in recent posts and updating the relevant options.
13+
*
14+
* @phpcs:disable Yoast.NamingConventions.ObjectNameDepth.MaxExceeded
15+
*/
16+
class Default_SEO_Data_Cron_Callback_Integration implements Integration_Interface {
17+
18+
use No_Conditionals;
19+
20+
/**
21+
* The options helper.
22+
*
23+
* @var Options_Helper
24+
*/
25+
private $options_helper;
26+
27+
/**
28+
* The scheduler.
29+
*
30+
* @var Default_SEO_Data_Cron_Scheduler
31+
*/
32+
private $scheduler;
33+
34+
/**
35+
* The indexable repository.
36+
*
37+
* @var Indexable_Repository
38+
*/
39+
private $indexable_repository;
40+
41+
/**
42+
* Constructor.
43+
*
44+
* @param Options_Helper $options_helper The options helper.
45+
* @param Default_SEO_Data_Cron_Scheduler $scheduler The scheduler.
46+
* @param Indexable_Repository $indexable_repository The indexable repository.
47+
*/
48+
public function __construct(
49+
Options_Helper $options_helper,
50+
Default_SEO_Data_Cron_Scheduler $scheduler,
51+
Indexable_Repository $indexable_repository
52+
) {
53+
$this->options_helper = $options_helper;
54+
$this->scheduler = $scheduler;
55+
$this->indexable_repository = $indexable_repository;
56+
}
57+
58+
/**
59+
* Registers the hooks.
60+
*
61+
* @return void
62+
*/
63+
public function register_hooks() {
64+
\add_action(
65+
Default_SEO_Data_Cron_Scheduler::CRON_HOOK,
66+
[
67+
$this,
68+
'detect_default_seo_data_in_recent',
69+
]
70+
);
71+
}
72+
73+
/**
74+
* Detects default SEO data in recent posts and updates the relevant options.
75+
*
76+
* @return void
77+
*/
78+
public function detect_default_seo_data_in_recent(): void {
79+
if ( ! \wp_doing_cron() ) {
80+
return;
81+
}
82+
83+
$recent_posts = $this->indexable_repository->get_recently_modified_posts( 'post', 5, false );
84+
85+
$recent_default_seo_title = [];
86+
$recent_default_seo_meta_desc = [];
87+
foreach ( $recent_posts as $post ) {
88+
if ( $post->title === null ) {
89+
$recent_default_seo_title[] = $post->object_id;
90+
}
91+
92+
if ( $post->description === null ) {
93+
$recent_default_seo_meta_desc[] = $post->object_id;
94+
}
95+
}
96+
97+
$this->options_helper->set( 'default_seo_title', $recent_default_seo_title );
98+
$this->options_helper->set( 'default_seo_meta_desc', $recent_default_seo_meta_desc );
99+
}
100+
}

0 commit comments

Comments
 (0)