PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / api / class-global-seo-endpoint.php

class-global-seo-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.1.1, at includes/api/class-global-seo-endpoint.php

643 lines 22.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Global SEO API Endpoints Class
5 *
6 * REST API endpoints for managing global SEO settings across different WordPress post types.
7 * Provides functionality to save and retrieve SEO settings including title formats,
8 * meta descriptions, schema types, and article types for all registered post types.
9 *
10 * @package ThinkRank
11 * @subpackage API
12 * @since 1.0.0
13 */
14
15 declare(strict_types=1);
16
17 namespace ThinkRank\API;
18
19 use ThinkRank\SEO\Pattern_Resolver;
20 use WP_REST_Controller;
21 use WP_REST_Request;
22 use WP_REST_Response;
23 use WP_Error;
24
25 // Prevent direct access
26 if (!defined('ABSPATH')) {
27 exit;
28 }
29
30 /**
31 * Global SEO API Endpoints Class
32 *
33 * Provides REST API endpoints for managing global SEO settings for different post types
34 * including title formats, meta descriptions, schema types, and article types.
35 *
36 * @since 1.0.0
37 */
38 class Global_SEO_Endpoint extends WP_REST_Controller {
39
40 /**
41 * API namespace
42 *
43 * @since 1.0.0
44 * @var string
45 */
46 protected $namespace = 'thinkrank/v1';
47
48 /**
49 * API resource base
50 *
51 * @since 1.0.0
52 * @var string
53 */
54 protected $rest_base = 'global-seo';
55
56 /**
57 * WordPress option name for storing global SEO settings
58 *
59 * @since 1.0.0
60 * @var string
61 */
62 private const OPTION_NAME = 'thinkrank_global_seo_settings';
63
64 /**
65 * Allowed values for enumerated settings fields. Shared with the MCP
66 * `update-global-settings` ability so every write path validates identically.
67 *
68 * @since 1.20.1
69 */
70 public const ALLOWED_SCHEMA_TYPES = ['Article', 'WebPage', 'Media', 'Product'];
71 public const ALLOWED_ARTICLE_TYPES = ['', 'Article', 'NewsArticle', 'BlogPosting'];
72 public const ALLOWED_MEDIA_TYPES = ['', 'ImageObject', 'VideoObject'];
73 public const ALLOWED_IMAGE_PREVIEW = ['none', 'standard', 'large'];
74
75 /**
76 * Default settings structure for post types
77 *
78 * @since 1.0.0
79 * @var array
80 */
81 private const DEFAULT_SETTINGS = [
82 'title' => '%title% %sep% %sitename%',
83 'description' => '%excerpt%',
84 'schema_type' => 'WebPage',
85 'article_type' => '',
86 'media_type' => '',
87 'link_suggestions' => true,
88 'robots_meta' => [
89 'index' => true,
90 'noindex' => false,
91 'nofollow' => false,
92 'noarchive' => false,
93 'noimageindex' => false,
94 'nosnippet' => false
95 ],
96 'robots_meta_enabled' => false,
97 'advanced_robots_meta' => [
98 'snippet_enabled' => true,
99 'max_snippet' => -1,
100 'video_preview_enabled' => true,
101 'max_video_preview' => -1,
102 'image_preview_enabled' => true,
103 'max_image_preview' => 'large'
104 ]
105 ];
106
107 /**
108 * Register API routes
109 *
110 * @since 1.0.0
111 */
112 public function register_routes(): void {
113 // Get/Save global SEO settings
114 register_rest_route(
115 $this->namespace,
116 '/' . $this->rest_base . '/settings',
117 [
118 [
119 'methods' => 'GET',
120 'callback' => [$this, 'get_settings'],
121 'permission_callback' => [$this, 'check_read_permissions'],
122 'args' => $this->get_settings_query_args()
123 ],
124 [
125 'methods' => 'POST',
126 'callback' => [$this, 'save_settings'],
127 'permission_callback' => [$this, 'check_manage_permissions'],
128 'args' => $this->get_save_settings_args()
129 ]
130 ]
131 );
132
133 // Get all global SEO settings (all post types)
134 register_rest_route(
135 $this->namespace,
136 '/' . $this->rest_base . '/settings/all',
137 [
138 [
139 'methods' => 'GET',
140 'callback' => [$this, 'get_all_settings'],
141 'permission_callback' => [$this, 'check_read_permissions']
142 ]
143 ]
144 );
145
146 // Reset settings for a specific post type
147 register_rest_route(
148 $this->namespace,
149 '/' . $this->rest_base . '/settings/reset',
150 [
151 [
152 'methods' => 'POST',
153 'callback' => [$this, 'reset_settings'],
154 'permission_callback' => [$this, 'check_manage_permissions'],
155 'args' => [
156 'post_type' => [
157 'required' => true,
158 'type' => 'string',
159 'description' => 'Post type to reset settings for',
160 'sanitize_callback' => 'sanitize_key'
161 ]
162 ]
163 ]
164 ]
165 );
166 }
167
168 /**
169 * Get global SEO settings for a specific post type
170 *
171 * @since 1.0.0
172 *
173 * @param WP_REST_Request $request Request object
174 * @return WP_REST_Response|WP_Error Response object or error
175 */
176 public function get_settings(WP_REST_Request $request) {
177 $post_type = $request->get_param('post_type');
178
179 // Validate post type
180 $validation = $this->validate_post_type($post_type);
181 if (is_wp_error($validation)) {
182 return $validation;
183 }
184
185 // Get all settings
186 $all_settings = get_option(self::OPTION_NAME, []);
187
188 // Merge any saved values for this post type over the per-post-type
189 // defaults. A plain `?? defaults` fallback is all-or-nothing: once a
190 // partial record exists for the post type (e.g. one written without a
191 // description template, or by an importer/another feature), every field
192 // it omits — including the default `%excerpt%` description — would come
193 // back blank in the settings UI. Merging keeps saved values authoritative
194 // while restoring defaults for any keys the saved record doesn't set.
195 $saved = is_array($all_settings[$post_type] ?? null) ? $all_settings[$post_type] : [];
196 $settings = array_merge($this->get_default_settings($post_type), $saved);
197
198 return new WP_REST_Response([
199 'success' => true,
200 'data' => $settings,
201 'post_type' => $post_type,
202 'message' => sprintf('Settings retrieved successfully for post type: %s', $post_type)
203 ], 200);
204 }
205
206 /**
207 * Save global SEO settings for a specific post type
208 *
209 * @since 1.0.0
210 *
211 * @param WP_REST_Request $request Request object
212 * @return WP_REST_Response|WP_Error Response object or error
213 */
214 public function save_settings(WP_REST_Request $request) {
215 $post_type = $request->get_param('post_type');
216 $settings = $request->get_param('settings');
217
218 // Validate post type
219 $validation = $this->validate_post_type($post_type);
220 if (is_wp_error($validation)) {
221 return $validation;
222 }
223
224 // Validate settings structure
225 if (empty($settings) || !is_array($settings)) {
226 return new WP_Error(
227 'invalid_settings',
228 'Settings must be provided as an array',
229 ['status' => 400]
230 );
231 }
232
233 // Sanitize settings
234 $sanitized_settings = $this->sanitize_settings($settings);
235
236 // Get all existing settings
237 $all_settings = get_option(self::OPTION_NAME, []);
238
239 // Capture the previously-stored value so a genuine write failure can be
240 // told apart from a no-op save (payload identical to what's stored).
241 $previous = $all_settings[$post_type] ?? null;
242
243 // Update settings for this post type
244 $all_settings[$post_type] = $sanitized_settings;
245
246 // Save to database
247 $updated = update_option(self::OPTION_NAME, $all_settings);
248
249 if ($updated || $previous === $sanitized_settings) {
250 return new WP_REST_Response([
251 'success' => true,
252 'data' => $sanitized_settings,
253 'post_type' => $post_type,
254 'message' => sprintf('Settings saved successfully for post type: %s', $post_type)
255 ], 200);
256 }
257
258 return new WP_Error(
259 'save_failed',
260 'Failed to save settings',
261 ['status' => 500]
262 );
263 }
264
265 /**
266 * Get all global SEO settings for all post types
267 *
268 * @since 1.0.0
269 *
270 * @param WP_REST_Request $request Request object
271 * @return WP_REST_Response Response object
272 */
273 public function get_all_settings(WP_REST_Request $request): WP_REST_Response {
274 $all_settings = get_option(self::OPTION_NAME, []);
275
276 return new WP_REST_Response([
277 'success' => true,
278 'data' => $all_settings,
279 'count' => count($all_settings),
280 'message' => 'All global SEO settings retrieved successfully'
281 ], 200);
282 }
283
284 /**
285 * Reset settings for a specific post type to defaults
286 *
287 * @since 1.0.0
288 *
289 * @param WP_REST_Request $request Request object
290 * @return WP_REST_Response|WP_Error Response object or error
291 */
292 public function reset_settings(WP_REST_Request $request) {
293 $post_type = $request->get_param('post_type');
294
295 // Validate post type
296 $validation = $this->validate_post_type($post_type);
297 if (is_wp_error($validation)) {
298 return $validation;
299 }
300
301 // Get all settings
302 $all_settings = get_option(self::OPTION_NAME, []);
303
304 // Remove settings for this post type (will fall back to defaults)
305 unset($all_settings[$post_type]);
306
307 // Save updated settings
308 update_option(self::OPTION_NAME, $all_settings);
309
310 // Get default settings
311 $default_settings = $this->get_default_settings($post_type);
312
313 return new WP_REST_Response([
314 'success' => true,
315 'data' => $default_settings,
316 'post_type' => $post_type,
317 'message' => sprintf('Settings reset to defaults for post type: %s', $post_type)
318 ], 200);
319 }
320
321
322
323
324 /**
325 * Validate post type
326 *
327 * @since 1.0.0
328 *
329 * @param string $post_type Post type to validate
330 * @return true|WP_Error True if valid, WP_Error otherwise
331 */
332 private function validate_post_type(string $post_type) {
333 if (empty($post_type)) {
334 return new WP_Error(
335 'missing_post_type',
336 'Post type parameter is required',
337 ['status' => 400]
338 );
339 }
340
341 // Check if post type exists
342 if (!post_type_exists($post_type)) {
343 return new WP_Error(
344 'invalid_post_type',
345 sprintf('Post type "%s" does not exist', $post_type),
346 ['status' => 400]
347 );
348 }
349
350 // Apply the shared Global SEO target policy (public + viewable + not on
351 // the deny list) so REST rejects the same types the admin UI hides.
352 if (!\ThinkRank\SEO\Global_SEO_Post_Types::is_allowed($post_type)) {
353 return new WP_Error(
354 'non_public_post_type',
355 sprintf('Post type "%s" is not a valid Global SEO target', $post_type),
356 ['status' => 400]
357 );
358 }
359
360 return true;
361 }
362
363 /**
364 * Get default settings for a post type
365 *
366 * @since 1.0.0
367 *
368 * @param string $post_type Post type
369 * @return array Default settings
370 */
371 private function get_default_settings(string $post_type): array {
372 $defaults = self::DEFAULT_SETTINGS;
373
374 // Customize defaults based on post type
375 switch ($post_type) {
376 case 'post':
377 $defaults['schema_type'] = 'Article';
378 $defaults['article_type'] = 'BlogPosting';
379 $defaults['media_type'] = '';
380 $defaults['link_suggestions'] = true;
381 break;
382
383 case 'page':
384 $defaults['schema_type'] = 'WebPage';
385 $defaults['article_type'] = '';
386 $defaults['media_type'] = '';
387 $defaults['link_suggestions'] = true;
388 break;
389
390 case 'attachment':
391 $defaults['schema_type'] = 'Media';
392 $defaults['article_type'] = '';
393 $defaults['media_type'] = 'ImageObject';
394 $defaults['title'] = '%title% %sep% %sitename%';
395 $defaults['description'] = '%caption%';
396 break;
397
398 case 'product':
399 $defaults['schema_type'] = 'Product';
400 $defaults['article_type'] = '';
401 $defaults['media_type'] = '';
402 $defaults['link_suggestions'] = false;
403 break;
404
405 default:
406 // For custom post types, use generic defaults
407 $defaults['schema_type'] = 'WebPage';
408 $defaults['article_type'] = '';
409 $defaults['media_type'] = '';
410 $defaults['link_suggestions'] = true;
411 break;
412 }
413
414 return $defaults;
415 }
416
417 /**
418 * Sanitize settings array
419 *
420 * @since 1.0.0
421 *
422 * @param array $settings Settings to sanitize
423 * @return array Sanitized settings
424 */
425 private function sanitize_settings(array $settings): array {
426 // Start from the shared per-field normalizer (drops unknown keys, coerces
427 // types/enums). The REST save replaces the whole object, so the three
428 // robots structures are then emitted complete — every subkey present —
429 // by overlaying the normalized values onto full defaults.
430 $sanitized = self::normalize_settings_patch($settings);
431
432 $sanitized['robots_meta'] = array_merge([
433 'index' => true,
434 'noindex' => false,
435 'nofollow' => false,
436 'noarchive' => false,
437 'noimageindex' => false,
438 'nosnippet' => false,
439 ], $sanitized['robots_meta'] ?? []);
440
441 $sanitized['robots_meta_enabled'] = $sanitized['robots_meta_enabled'] ?? false;
442
443 $sanitized['advanced_robots_meta'] = array_merge([
444 'snippet_enabled' => true,
445 'max_snippet' => -1,
446 'video_preview_enabled' => true,
447 'max_video_preview' => -1,
448 'image_preview_enabled' => true,
449 'max_image_preview' => 'large',
450 ], $sanitized['advanced_robots_meta'] ?? []);
451
452 return $sanitized;
453 }
454
455 /**
456 * Normalize a PARTIAL global-SEO settings patch.
457 *
458 * Keeps only recognized keys and coerces each SUPPLIED value to its
459 * canonical type — booleans, enum allow-lists, clamped numerics, sanitized
460 * text, and nested robots structures containing only their known subkeys.
461 * Absent keys are NOT filled with defaults.
462 *
463 * This is the single per-field contract shared by both write paths so they
464 * can no longer diverge: the REST endpoint layers full defaults on top (a
465 * whole-object replace), while the MCP ability merges the returned patch into
466 * the stored template (a partial update). Sharing it fixes the ability
467 * previously retaining string booleans and unknown nested keys.
468 *
469 * @since 1.20.1
470 * @param array $settings Raw settings (full or partial).
471 * @return array Normalized subset containing only supplied, recognized keys.
472 */
473 public static function normalize_settings_patch(array $settings): array {
474 $out = [];
475
476 // Templates, not plain text: sanitize_text_field() would eat %date% and
477 // %category% as percent-encoding and store "te%" / "tegory%" (#521).
478 if (isset($settings['title'])) {
479 $out['title'] = Pattern_Resolver::sanitize_template((string) $settings['title']);
480 }
481 if (isset($settings['description'])) {
482 $out['description'] = Pattern_Resolver::sanitize_template((string) $settings['description']);
483 }
484 if (isset($settings['schema_type'])) {
485 $value = sanitize_text_field((string) $settings['schema_type']);
486 $out['schema_type'] = in_array($value, self::ALLOWED_SCHEMA_TYPES, true) ? $value : 'WebPage';
487 }
488 if (isset($settings['article_type'])) {
489 $value = sanitize_text_field((string) $settings['article_type']);
490 $out['article_type'] = in_array($value, self::ALLOWED_ARTICLE_TYPES, true) ? $value : '';
491 }
492 if (isset($settings['media_type'])) {
493 $value = sanitize_text_field((string) $settings['media_type']);
494 $out['media_type'] = in_array($value, self::ALLOWED_MEDIA_TYPES, true) ? $value : '';
495 }
496 if (isset($settings['link_suggestions'])) {
497 $out['link_suggestions'] = (bool) $settings['link_suggestions'];
498 }
499 if (isset($settings['robots_meta_enabled'])) {
500 $out['robots_meta_enabled'] = (bool) $settings['robots_meta_enabled'];
501 }
502
503 // Nested robots_meta: only the recognized boolean subkeys that were
504 // actually supplied (unknown nested keys are dropped, values coerced).
505 if (isset($settings['robots_meta']) && is_array($settings['robots_meta'])) {
506 $robots = [];
507 foreach (['index', 'noindex', 'nofollow', 'noarchive', 'noimageindex', 'nosnippet'] as $key) {
508 if (isset($settings['robots_meta'][$key])) {
509 $robots[$key] = (bool) $settings['robots_meta'][$key];
510 }
511 }
512 $out['robots_meta'] = $robots;
513 }
514
515 // Nested advanced_robots_meta: recognized booleans, clamped numerics, and
516 // the image-preview enum — again only for supplied subkeys.
517 if (isset($settings['advanced_robots_meta']) && is_array($settings['advanced_robots_meta'])) {
518 $adv_in = $settings['advanced_robots_meta'];
519 $adv = [];
520 foreach (['snippet_enabled', 'video_preview_enabled', 'image_preview_enabled'] as $key) {
521 if (isset($adv_in[$key])) {
522 $adv[$key] = (bool) $adv_in[$key];
523 }
524 }
525 if (isset($adv_in['max_snippet'])) {
526 $adv['max_snippet'] = max(-1, (int) $adv_in['max_snippet']);
527 }
528 if (isset($adv_in['max_video_preview'])) {
529 $adv['max_video_preview'] = max(-1, (int) $adv_in['max_video_preview']);
530 }
531 if (isset($adv_in['max_image_preview'])) {
532 $adv['max_image_preview'] = in_array($adv_in['max_image_preview'], self::ALLOWED_IMAGE_PREVIEW, true)
533 ? $adv_in['max_image_preview']
534 : 'large';
535 }
536 $out['advanced_robots_meta'] = $adv;
537 }
538
539 return $out;
540 }
541
542 /**
543 * Get query arguments for GET settings endpoint
544 *
545 * @since 1.0.0
546 *
547 * @return array Arguments array
548 */
549 private function get_settings_query_args(): array {
550 return [
551 'post_type' => [
552 'required' => true,
553 'type' => 'string',
554 'description' => 'Post type to retrieve settings for',
555 'sanitize_callback' => 'sanitize_key'
556 ]
557 ];
558 }
559
560 /**
561 * Get arguments for POST save settings endpoint
562 *
563 * @since 1.0.0
564 *
565 * @return array Arguments array
566 */
567 private function get_save_settings_args(): array {
568 return [
569 'post_type' => [
570 'required' => true,
571 'type' => 'string',
572 'description' => 'Post type to save settings for',
573 'sanitize_callback' => 'sanitize_key'
574 ],
575 'settings' => [
576 'required' => true,
577 'type' => 'object',
578 'description' => 'Settings object containing title, description, schema_type, article_type, and media_type',
579 'properties' => [
580 'title' => [
581 'type' => 'string',
582 'description' => 'Title format with variables like %title%, %sitename%, %sep%'
583 ],
584 'description' => [
585 'type' => 'string',
586 'description' => 'Description format with variables like %excerpt%'
587 ],
588 'schema_type' => [
589 'type' => 'string',
590 'description' => 'Schema.org type (e.g., Article, WebPage, Media, Product)'
591 ],
592 'article_type' => [
593 'type' => 'string',
594 'description' => 'Article type (e.g., BlogPosting, NewsArticle) - used when schema_type is Article'
595 ],
596 'media_type' => [
597 'type' => 'string',
598 'description' => 'Media type (e.g., ImageObject, VideoObject) - used when schema_type is Media'
599 ],
600 'link_suggestions' => [
601 'type' => 'boolean',
602 'description' => 'Enable link suggestions and pillar content feature'
603 ],
604 'robots_meta' => [
605 'type' => 'object',
606 'description' => 'Robots meta settings',
607 'properties' => [
608 'index' => ['type' => 'boolean'],
609 'noindex' => ['type' => 'boolean'],
610 'nofollow' => ['type' => 'boolean'],
611 'noarchive' => ['type' => 'boolean'],
612 'noimageindex' => ['type' => 'boolean'],
613 'nosnippet' => ['type' => 'boolean']
614 ]
615 ]
616 ]
617 ]
618 ];
619 }
620
621 /**
622 * Check read permissions
623 *
624 * @since 1.0.0
625 *
626 * @return bool True if user has read permissions
627 */
628 public function check_read_permissions(): bool {
629 return current_user_can('edit_posts');
630 }
631
632 /**
633 * Check manage permissions
634 *
635 * @since 1.0.0
636 *
637 * @return bool True if user has manage permissions
638 */
639 public function check_manage_permissions(): bool {
640 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_global_seo');
641 }
642 }
643