PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.0
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.0.0, at includes/api/class-global-seo-endpoint.php

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