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 / abilities / settings / class-update-global-settings.php

class-update-global-settings.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/abilities/settings/class-update-global-settings.php

187 lines 5.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Update global settings ability.
4 *
5 * @package ThinkRank\Abilities\Settings
6 */
7
8 declare(strict_types=1);
9
10 namespace ThinkRank\Abilities\Settings;
11
12 use ThinkRank\Abilities\Ability_Base;
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit; // Exit if accessed directly.
16 }
17
18 /**
19 * Updates ThinkRank global SEO settings.
20 *
21 * ThinkRank stores global SEO as per-post-type templates in the
22 * `thinkrank_global_seo_settings` option. This ability therefore requires a
23 * `post_type` and merges the provided settings into that post type's template.
24 */
25 class Update_Global_Settings extends Ability_Base {
26 /**
27 * Option storing per-post-type global SEO templates.
28 */
29 private const OPTION_NAME = 'thinkrank_global_seo_settings';
30
31 /**
32 * Constructor.
33 */
34 public function __construct() {
35 $this->id = 'thinkrank/update-global-settings';
36 $this->label = __( 'Update ThinkRank Global Settings', 'thinkrank' );
37 $this->description = __( 'Update ThinkRank global SEO settings for a specific post type. ThinkRank stores global SEO as per-post-type templates, so a post type is required. Read the current values with get-global-settings first; only the keys you pass are changed.', 'thinkrank' );
38 }
39
40 /**
41 * {@inheritDoc}
42 *
43 * @return array<string, bool|float|string>
44 */
45 public function get_annotations() {
46 return [
47 'readonly' => false,
48 // Settings writes are recoverable: the matching get-* ability reads
49 // the previous value, so nothing is lost that cannot be put back.
50 // `destructive` is reserved for calls that lose data or reach
51 // outside the site, and marking routine configuration with it made
52 // MCP clients demand a human approval for every save — which users
53 // reported as a permission bug, because the client's refusal reads
54 // as "No approval received" (#675).
55 'destructive' => false,
56 'idempotent' => true,
57 'priority' => 2.0,
58 'openWorldHint' => false,
59 ];
60 }
61
62 /**
63 * {@inheritDoc}
64 *
65 * @return array<string, mixed>
66 */
67 public function get_input_schema() {
68 return [
69 'type' => 'object',
70 'additionalProperties' => false,
71 'properties' => [
72 'post_type' => [
73 'type' => 'string',
74 'description' => __( 'The post type whose global SEO template to update.', 'thinkrank' ),
75 ],
76 'settings' => [
77 'type' => 'object',
78 'description' => __( 'Key-value pairs of ThinkRank global settings to merge into the post type template.', 'thinkrank' ),
79 ],
80 ],
81 'required' => [ 'post_type', 'settings' ],
82 ];
83 }
84
85 /**
86 * {@inheritDoc}
87 *
88 * @return array<string, mixed>
89 */
90 public function get_output_schema() {
91 return [
92 'type' => 'object',
93 'properties' => [
94 'success' => [ 'type' => 'boolean' ],
95 'message' => [ 'type' => 'string' ],
96 'post_type' => [ 'type' => 'string' ],
97 ],
98 ];
99 }
100
101 /**
102 * Execute ability.
103 *
104 * @param array<string, mixed> $input Ability input payload.
105 * @return array<string, mixed>|\WP_Error
106 */
107 public function execute( $input ) {
108 $post_type = isset( $input['post_type'] ) ? sanitize_key( (string) $input['post_type'] ) : '';
109 $settings = isset( $input['settings'] ) && is_array( $input['settings'] ) ? $input['settings'] : [];
110
111 if ( '' === $post_type || ! post_type_exists( $post_type ) ) {
112 return new \WP_Error(
113 'thinkrank_invalid_post_type',
114 __( 'A valid post type slug is required.', 'thinkrank' ),
115 [ 'status' => 400 ]
116 );
117 }
118
119 // Apply the shared Global SEO target policy (public + viewable + not on
120 // the deny list) so the ability rejects the same types the REST endpoint
121 // and the admin UI do.
122 if ( ! \ThinkRank\SEO\Global_SEO_Post_Types::is_allowed( $post_type ) ) {
123 return new \WP_Error(
124 'thinkrank_non_public_post_type',
125 __( 'Global SEO settings are only available for valid Global SEO target post types.', 'thinkrank' ),
126 [ 'status' => 400 ]
127 );
128 }
129
130 if ( empty( $settings ) ) {
131 return new \WP_Error(
132 'thinkrank_missing_settings_payload',
133 __( 'A settings payload is required.', 'thinkrank' ),
134 [ 'status' => 400 ]
135 );
136 }
137
138 // Normalize through the SAME per-field contract the REST endpoint uses so
139 // both write paths coerce identically (booleans, enums, clamped numerics,
140 // nested robots subkeys) and neither can persist string booleans or
141 // unknown nested keys.
142 $settings = \ThinkRank\API\Global_SEO_Endpoint::normalize_settings_patch( $settings );
143
144 $all = get_option( self::OPTION_NAME, [] );
145 if ( ! is_array( $all ) ) {
146 $all = [];
147 }
148
149 // Patch contract: this ability performs a PARTIAL update. Top-level keys
150 // in the payload replace the stored value; the two nested robots
151 // structures (robots_meta, advanced_robots_meta) are merged subkey-wise so
152 // a partial nested patch preserves sibling subkeys instead of wiping them.
153 $existing = isset( $all[ $post_type ] ) && is_array( $all[ $post_type ] ) ? $all[ $post_type ] : [];
154 $merged = $existing;
155 foreach ( $settings as $key => $value ) {
156 if (
157 in_array( $key, [ 'robots_meta', 'advanced_robots_meta' ], true )
158 && isset( $existing[ $key ] ) && is_array( $existing[ $key ] ) && is_array( $value )
159 ) {
160 $merged[ $key ] = array_merge( $existing[ $key ], $value );
161 } else {
162 $merged[ $key ] = $value;
163 }
164 }
165 $unchanged = ( $merged === $existing );
166 $all[ $post_type ] = $merged;
167
168 $updated = update_option( self::OPTION_NAME, $all );
169
170 // update_option() returns false both on a write failure and on a no-op
171 // (value unchanged). Only the former is an error.
172 if ( ! $updated && ! $unchanged ) {
173 return new \WP_Error(
174 'thinkrank_save_failed',
175 __( 'Failed to save global SEO settings.', 'thinkrank' ),
176 [ 'status' => 500 ]
177 );
178 }
179
180 return [
181 'success' => true,
182 'message' => __( 'Global SEO settings updated.', 'thinkrank' ),
183 'post_type' => $post_type,
184 ];
185 }
186 }
187