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

664 lines 23.4 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 // MERGE, never replace. This entity row is shared: the Content Type
244 // Matrix stores its per-feature tri-states (meta_enabled, schema_enabled,
245 // open_graph_enabled, twitter_card_enabled, analytics_enabled) and its
246 // sitemap_include flag under the SAME key, and normalize_settings_patch()
247 // whitelists only the Global SEO fields — so a wholesale replace here
248 // silently dropped every matrix choice the moment the user saved the
249 // Global SEO screen next door. Global SEO's own keys still win, because
250 // sanitize_settings() emits them complete.
251 $merged = array_merge(is_array($previous) ? $previous : [], $sanitized_settings);
252
253 // Update settings for this post type
254 $all_settings[$post_type] = $merged;
255
256 // Save to database
257 $updated = update_option(self::OPTION_NAME, $all_settings);
258
259 if ($updated || $previous === $merged) {
260 return new WP_REST_Response([
261 'success' => true,
262 'data' => $merged,
263 'post_type' => $post_type,
264 'message' => sprintf('Settings saved successfully for post type: %s', $post_type)
265 ], 200);
266 }
267
268 return new WP_Error(
269 'save_failed',
270 'Failed to save settings',
271 ['status' => 500]
272 );
273 }
274
275 /**
276 * Get all global SEO settings for all post types
277 *
278 * @since 1.0.0
279 *
280 * @param WP_REST_Request $request Request object
281 * @return WP_REST_Response Response object
282 */
283 public function get_all_settings(WP_REST_Request $request): WP_REST_Response {
284 $all_settings = get_option(self::OPTION_NAME, []);
285
286 return new WP_REST_Response([
287 'success' => true,
288 'data' => $all_settings,
289 'count' => count($all_settings),
290 'message' => 'All global SEO settings retrieved successfully'
291 ], 200);
292 }
293
294 /**
295 * Reset settings for a specific post type to defaults
296 *
297 * @since 1.0.0
298 *
299 * @param WP_REST_Request $request Request object
300 * @return WP_REST_Response|WP_Error Response object or error
301 */
302 public function reset_settings(WP_REST_Request $request) {
303 $post_type = $request->get_param('post_type');
304
305 // Validate post type
306 $validation = $this->validate_post_type($post_type);
307 if (is_wp_error($validation)) {
308 return $validation;
309 }
310
311 // Get all settings
312 $all_settings = get_option(self::OPTION_NAME, []);
313
314 // Reset only what this screen owns. The entity row is shared with the
315 // Content Type Matrix, so dropping the whole row would reset the user's
316 // per-feature tri-states and sitemap choice as a side effect of a button
317 // that says nothing about them. DEFAULT_SETTINGS is the exact set of
318 // Global SEO keys, so anything outside it belongs to another screen.
319 $stored = $all_settings[$post_type] ?? [];
320 $kept = is_array($stored) ? array_diff_key($stored, self::DEFAULT_SETTINGS) : [];
321
322 if ($kept === []) {
323 unset($all_settings[$post_type]);
324 } else {
325 $all_settings[$post_type] = $kept;
326 }
327
328 // Save updated settings
329 update_option(self::OPTION_NAME, $all_settings);
330
331 // Get default settings
332 $default_settings = $this->get_default_settings($post_type);
333
334 return new WP_REST_Response([
335 'success' => true,
336 'data' => $default_settings,
337 'post_type' => $post_type,
338 'message' => sprintf('Settings reset to defaults for post type: %s', $post_type)
339 ], 200);
340 }
341
342
343
344
345 /**
346 * Validate post type
347 *
348 * @since 1.0.0
349 *
350 * @param string $post_type Post type to validate
351 * @return true|WP_Error True if valid, WP_Error otherwise
352 */
353 private function validate_post_type(string $post_type) {
354 if (empty($post_type)) {
355 return new WP_Error(
356 'missing_post_type',
357 'Post type parameter is required',
358 ['status' => 400]
359 );
360 }
361
362 // Check if post type exists
363 if (!post_type_exists($post_type)) {
364 return new WP_Error(
365 'invalid_post_type',
366 sprintf('Post type "%s" does not exist', $post_type),
367 ['status' => 400]
368 );
369 }
370
371 // Apply the shared Global SEO target policy (public + viewable + not on
372 // the deny list) so REST rejects the same types the admin UI hides.
373 if (!\ThinkRank\SEO\Global_SEO_Post_Types::is_allowed($post_type)) {
374 return new WP_Error(
375 'non_public_post_type',
376 sprintf('Post type "%s" is not a valid Global SEO target', $post_type),
377 ['status' => 400]
378 );
379 }
380
381 return true;
382 }
383
384 /**
385 * Get default settings for a post type
386 *
387 * @since 1.0.0
388 *
389 * @param string $post_type Post type
390 * @return array Default settings
391 */
392 private function get_default_settings(string $post_type): array {
393 $defaults = self::DEFAULT_SETTINGS;
394
395 // Customize defaults based on post type
396 switch ($post_type) {
397 case 'post':
398 $defaults['schema_type'] = 'Article';
399 $defaults['article_type'] = 'BlogPosting';
400 $defaults['media_type'] = '';
401 $defaults['link_suggestions'] = true;
402 break;
403
404 case 'page':
405 $defaults['schema_type'] = 'WebPage';
406 $defaults['article_type'] = '';
407 $defaults['media_type'] = '';
408 $defaults['link_suggestions'] = true;
409 break;
410
411 case 'attachment':
412 $defaults['schema_type'] = 'Media';
413 $defaults['article_type'] = '';
414 $defaults['media_type'] = 'ImageObject';
415 $defaults['title'] = '%title% %sep% %sitename%';
416 $defaults['description'] = '%caption%';
417 break;
418
419 case 'product':
420 $defaults['schema_type'] = 'Product';
421 $defaults['article_type'] = '';
422 $defaults['media_type'] = '';
423 $defaults['link_suggestions'] = false;
424 break;
425
426 default:
427 // For custom post types, use generic defaults
428 $defaults['schema_type'] = 'WebPage';
429 $defaults['article_type'] = '';
430 $defaults['media_type'] = '';
431 $defaults['link_suggestions'] = true;
432 break;
433 }
434
435 return $defaults;
436 }
437
438 /**
439 * Sanitize settings array
440 *
441 * @since 1.0.0
442 *
443 * @param array $settings Settings to sanitize
444 * @return array Sanitized settings
445 */
446 private function sanitize_settings(array $settings): array {
447 // Start from the shared per-field normalizer (drops unknown keys, coerces
448 // types/enums). The REST save replaces the whole object, so the three
449 // robots structures are then emitted complete — every subkey present —
450 // by overlaying the normalized values onto full defaults.
451 $sanitized = self::normalize_settings_patch($settings);
452
453 $sanitized['robots_meta'] = array_merge([
454 'index' => true,
455 'noindex' => false,
456 'nofollow' => false,
457 'noarchive' => false,
458 'noimageindex' => false,
459 'nosnippet' => false,
460 ], $sanitized['robots_meta'] ?? []);
461
462 $sanitized['robots_meta_enabled'] = $sanitized['robots_meta_enabled'] ?? false;
463
464 $sanitized['advanced_robots_meta'] = array_merge([
465 'snippet_enabled' => true,
466 'max_snippet' => -1,
467 'video_preview_enabled' => true,
468 'max_video_preview' => -1,
469 'image_preview_enabled' => true,
470 'max_image_preview' => 'large',
471 ], $sanitized['advanced_robots_meta'] ?? []);
472
473 return $sanitized;
474 }
475
476 /**
477 * Normalize a PARTIAL global-SEO settings patch.
478 *
479 * Keeps only recognized keys and coerces each SUPPLIED value to its
480 * canonical type — booleans, enum allow-lists, clamped numerics, sanitized
481 * text, and nested robots structures containing only their known subkeys.
482 * Absent keys are NOT filled with defaults.
483 *
484 * This is the single per-field contract shared by both write paths so they
485 * can no longer diverge: the REST endpoint layers full defaults on top (a
486 * whole-object replace), while the MCP ability merges the returned patch into
487 * the stored template (a partial update). Sharing it fixes the ability
488 * previously retaining string booleans and unknown nested keys.
489 *
490 * @since 1.20.1
491 * @param array $settings Raw settings (full or partial).
492 * @return array Normalized subset containing only supplied, recognized keys.
493 */
494 public static function normalize_settings_patch(array $settings): array {
495 $out = [];
496
497 // Templates, not plain text: sanitize_text_field() would eat %date% and
498 // %category% as percent-encoding and store "te%" / "tegory%" (#521).
499 if (isset($settings['title'])) {
500 $out['title'] = Pattern_Resolver::sanitize_template((string) $settings['title']);
501 }
502 if (isset($settings['description'])) {
503 $out['description'] = Pattern_Resolver::sanitize_template((string) $settings['description']);
504 }
505 if (isset($settings['schema_type'])) {
506 $value = sanitize_text_field((string) $settings['schema_type']);
507 $out['schema_type'] = in_array($value, self::ALLOWED_SCHEMA_TYPES, true) ? $value : 'WebPage';
508 }
509 if (isset($settings['article_type'])) {
510 $value = sanitize_text_field((string) $settings['article_type']);
511 $out['article_type'] = in_array($value, self::ALLOWED_ARTICLE_TYPES, true) ? $value : '';
512 }
513 if (isset($settings['media_type'])) {
514 $value = sanitize_text_field((string) $settings['media_type']);
515 $out['media_type'] = in_array($value, self::ALLOWED_MEDIA_TYPES, true) ? $value : '';
516 }
517 if (isset($settings['link_suggestions'])) {
518 $out['link_suggestions'] = (bool) $settings['link_suggestions'];
519 }
520 if (isset($settings['robots_meta_enabled'])) {
521 $out['robots_meta_enabled'] = (bool) $settings['robots_meta_enabled'];
522 }
523
524 // Nested robots_meta: only the recognized boolean subkeys that were
525 // actually supplied (unknown nested keys are dropped, values coerced).
526 if (isset($settings['robots_meta']) && is_array($settings['robots_meta'])) {
527 $robots = [];
528 foreach (['index', 'noindex', 'nofollow', 'noarchive', 'noimageindex', 'nosnippet'] as $key) {
529 if (isset($settings['robots_meta'][$key])) {
530 $robots[$key] = (bool) $settings['robots_meta'][$key];
531 }
532 }
533 $out['robots_meta'] = $robots;
534 }
535
536 // Nested advanced_robots_meta: recognized booleans, clamped numerics, and
537 // the image-preview enum — again only for supplied subkeys.
538 if (isset($settings['advanced_robots_meta']) && is_array($settings['advanced_robots_meta'])) {
539 $adv_in = $settings['advanced_robots_meta'];
540 $adv = [];
541 foreach (['snippet_enabled', 'video_preview_enabled', 'image_preview_enabled'] as $key) {
542 if (isset($adv_in[$key])) {
543 $adv[$key] = (bool) $adv_in[$key];
544 }
545 }
546 if (isset($adv_in['max_snippet'])) {
547 $adv['max_snippet'] = max(-1, (int) $adv_in['max_snippet']);
548 }
549 if (isset($adv_in['max_video_preview'])) {
550 $adv['max_video_preview'] = max(-1, (int) $adv_in['max_video_preview']);
551 }
552 if (isset($adv_in['max_image_preview'])) {
553 $adv['max_image_preview'] = in_array($adv_in['max_image_preview'], self::ALLOWED_IMAGE_PREVIEW, true)
554 ? $adv_in['max_image_preview']
555 : 'large';
556 }
557 $out['advanced_robots_meta'] = $adv;
558 }
559
560 return $out;
561 }
562
563 /**
564 * Get query arguments for GET settings endpoint
565 *
566 * @since 1.0.0
567 *
568 * @return array Arguments array
569 */
570 private function get_settings_query_args(): array {
571 return [
572 'post_type' => [
573 'required' => true,
574 'type' => 'string',
575 'description' => 'Post type to retrieve settings for',
576 'sanitize_callback' => 'sanitize_key'
577 ]
578 ];
579 }
580
581 /**
582 * Get arguments for POST save settings endpoint
583 *
584 * @since 1.0.0
585 *
586 * @return array Arguments array
587 */
588 private function get_save_settings_args(): array {
589 return [
590 'post_type' => [
591 'required' => true,
592 'type' => 'string',
593 'description' => 'Post type to save settings for',
594 'sanitize_callback' => 'sanitize_key'
595 ],
596 'settings' => [
597 'required' => true,
598 'type' => 'object',
599 'description' => 'Settings object containing title, description, schema_type, article_type, and media_type',
600 'properties' => [
601 'title' => [
602 'type' => 'string',
603 'description' => 'Title format with variables like %title%, %sitename%, %sep%'
604 ],
605 'description' => [
606 'type' => 'string',
607 'description' => 'Description format with variables like %excerpt%'
608 ],
609 'schema_type' => [
610 'type' => 'string',
611 'description' => 'Schema.org type (e.g., Article, WebPage, Media, Product)'
612 ],
613 'article_type' => [
614 'type' => 'string',
615 'description' => 'Article type (e.g., BlogPosting, NewsArticle) - used when schema_type is Article'
616 ],
617 'media_type' => [
618 'type' => 'string',
619 'description' => 'Media type (e.g., ImageObject, VideoObject) - used when schema_type is Media'
620 ],
621 'link_suggestions' => [
622 'type' => 'boolean',
623 'description' => 'Enable link suggestions and pillar content feature'
624 ],
625 'robots_meta' => [
626 'type' => 'object',
627 'description' => 'Robots meta settings',
628 'properties' => [
629 'index' => ['type' => 'boolean'],
630 'noindex' => ['type' => 'boolean'],
631 'nofollow' => ['type' => 'boolean'],
632 'noarchive' => ['type' => 'boolean'],
633 'noimageindex' => ['type' => 'boolean'],
634 'nosnippet' => ['type' => 'boolean']
635 ]
636 ]
637 ]
638 ]
639 ];
640 }
641
642 /**
643 * Check read permissions
644 *
645 * @since 1.0.0
646 *
647 * @return bool True if user has read permissions
648 */
649 public function check_read_permissions(): bool {
650 return current_user_can('edit_posts');
651 }
652
653 /**
654 * Check manage permissions
655 *
656 * @since 1.0.0
657 *
658 * @return bool True if user has manage permissions
659 */
660 public function check_manage_permissions(): bool {
661 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_global_seo');
662 }
663 }
664