PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.0
2.11.0 2.10.0 2.9.0 2.8.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 All 52 releases
thinkrank / includes / abilities / content / class-update-post-seo.php

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

269 lines 9.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Update post SEO ability.
4 *
5 * @package ThinkRank\Abilities\Content
6 */
7
8 declare(strict_types=1);
9
10 namespace ThinkRank\Abilities\Content;
11
12 use ThinkRank\Abilities\Ability_Base;
13 use ThinkRank\Admin\Metabox_Manager;
14 use ThinkRank\SEO\Object_Redirect;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit; // Exit if accessed directly.
18 }
19
20 /**
21 * Updates ThinkRank SEO data for a post object.
22 */
23 class Update_Post_Seo extends Ability_Base {
24 /**
25 * Constructor.
26 */
27 public function __construct() {
28 $this->id = 'thinkrank/update-post-seo';
29 $this->label = __( 'Update ThinkRank Post SEO', 'thinkrank' );
30 $this->description = __( 'Update ThinkRank SEO metadata for a post, page, or custom post type item. Read the current values with get-post-seo first; only the fields you pass are changed, and get-post-seo-checks reports what would improve.', 'thinkrank' );
31 }
32
33 /**
34 * {@inheritDoc}
35 *
36 * @return array<string, mixed>
37 */
38 public function get_input_schema() {
39 return [
40 'type' => 'object',
41 'additionalProperties' => false,
42 'properties' => [
43 'post_id' => [
44 'type' => 'integer',
45 'description' => __( 'The target post ID.', 'thinkrank' ),
46 ],
47 'settings' => [
48 'type' => 'object',
49 'additionalProperties' => true,
50 'description' => __( 'ThinkRank post SEO fields to update.', 'thinkrank' ),
51 'properties' => [
52 'title' => [ 'type' => 'string' ],
53 'description' => [ 'type' => 'string' ],
54 'canonical_url' => [ 'type' => 'string' ],
55 'redirect_url' => [
56 'type' => 'string',
57 'description' => __( 'Send visitors from this post to this URL. Empty string removes the redirect. Requires ThinkRank Pro.', 'thinkrank' ),
58 ],
59 'redirect_type' => [
60 'type' => 'integer',
61 'enum' => Object_Redirect::TYPES,
62 'description' => __( 'Redirect status code. Defaults to 301.', 'thinkrank' ),
63 ],
64 'focus_keyword' => [ 'type' => 'string' ],
65 'focus_keywords' => [
66 'type' => 'array',
67 'items' => [ 'type' => 'string' ],
68 ],
69 'robots_meta_enabled' => [ 'type' => 'boolean' ],
70 'robots_meta' => [ 'type' => 'object' ],
71 'advanced_robots_meta' => [ 'type' => 'object' ],
72 'exclude_from_search' => [
73 'type' => 'boolean',
74 'description' => __( 'Keep this post out of this site\'s own search results. Not noindex: search engines are unaffected, and the post keeps its own URL. Use robots_meta for search engines.', 'thinkrank' ),
75 ],
76 'exclude_from_archives' => [
77 'type' => 'boolean',
78 'description' => __( 'Keep this post out of category, tag, author, date and blog listings on this site. Not noindex: search engines are unaffected, and the post keeps its own URL, its feed entry and its sitemap entry.', 'thinkrank' ),
79 ],
80 'og_title' => [ 'type' => 'string' ],
81 'og_description' => [ 'type' => 'string' ],
82 'og_image' => [ 'type' => 'string' ],
83 'twitter_title' => [ 'type' => 'string' ],
84 'twitter_description' => [ 'type' => 'string' ],
85 'twitter_image' => [ 'type' => 'string' ],
86 ],
87 ],
88 ],
89 'required' => [ 'post_id', 'settings' ],
90 ];
91 }
92
93 /**
94 * {@inheritDoc}
95 *
96 * @return array<string, mixed>
97 */
98 public function get_output_schema() {
99 return [
100 'type' => 'object',
101 'properties' => [
102 'success' => [ 'type' => 'boolean' ],
103 'message' => [ 'type' => 'string' ],
104 'post_id' => [ 'type' => 'integer' ],
105 ],
106 ];
107 }
108
109 /**
110 * Execute ability.
111 *
112 * @param array<string, mixed> $input Ability input payload.
113 * @return array<string, mixed>|\WP_Error
114 */
115 public function execute( $input ) {
116 $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
117 $settings = isset( $input['settings'] ) && is_array( $input['settings'] ) ? $input['settings'] : [];
118
119 if ( ! $post_id || empty( $settings ) ) {
120 return new \WP_Error(
121 'thinkrank_invalid_post_update',
122 __( 'A valid post ID and settings payload are required.', 'thinkrank' ),
123 [ 'status' => 400 ]
124 );
125 }
126
127 if ( ! get_post( $post_id ) instanceof \WP_Post ) {
128 return new \WP_Error(
129 'thinkrank_post_not_found',
130 __( 'No post was found for the provided ID.', 'thinkrank' ),
131 [ 'status' => 404 ]
132 );
133 }
134
135 if ( ! current_user_can( 'edit_post', $post_id ) ) {
136 return new \WP_Error(
137 'thinkrank_cannot_edit_post',
138 __( 'You are not allowed to edit this post.', 'thinkrank' ),
139 [ 'status' => 403 ]
140 );
141 }
142
143 $fields = $this->map_fields( $settings, $post_id );
144
145 if ( empty( $fields ) ) {
146 return new \WP_Error(
147 'thinkrank_no_valid_post_meta_keys',
148 __( 'No valid ThinkRank post SEO keys were provided.', 'thinkrank' ),
149 [ 'status' => 400 ]
150 );
151 }
152
153 $manager = new Metabox_Manager();
154 $manager->save_seo_fields( $post_id, $fields );
155
156 // Everything else is stored unconditionally; the redirect can be
157 // refused (no Pro, plain permalinks, a destination that is this post's
158 // own URL). Reporting success for a redirect that was not written would
159 // leave the caller believing the site now redirects when it does not.
160 $redirect_error = $manager->get_last_redirect_error();
161 if ( null !== $redirect_error ) {
162 return new \WP_Error(
163 $redirect_error->get_error_code(),
164 $redirect_error->get_error_message(),
165 [ 'status' => 400 ]
166 );
167 }
168
169 return [
170 'success' => true,
171 'message' => __( 'Post SEO metadata updated.', 'thinkrank' ),
172 'post_id' => $post_id,
173 ];
174 }
175
176 /**
177 * Map normalized input keys to metabox form field names.
178 *
179 * @param array<string, mixed> $settings Normalized settings payload.
180 * @param int $post_id Target post ID.
181 * @return array<string, mixed>
182 */
183 private function map_fields( array $settings, $post_id ) {
184 $fields = [];
185
186 $simple = [
187 'title' => 'thinkrank_seo_title',
188 'description' => 'thinkrank_meta_description',
189 'canonical_url' => 'thinkrank_canonical_url',
190 'redirect_url' => 'thinkrank_redirect_url',
191 'focus_keyword' => 'thinkrank_focus_keyword',
192 'og_title' => 'thinkrank_og_title',
193 'og_description' => 'thinkrank_og_description',
194 'og_image' => 'thinkrank_og_image',
195 'twitter_title' => 'thinkrank_twitter_title',
196 'twitter_description' => 'thinkrank_twitter_description',
197 'twitter_image' => 'thinkrank_twitter_image',
198 ];
199
200 foreach ( $simple as $key => $field ) {
201 if ( array_key_exists( $key, $settings ) ) {
202 $fields[ $field ] = (string) $settings[ $key ];
203 }
204 }
205
206 // Only meaningful alongside a destination: sending a code on its own
207 // would be read by save_object_redirect() as "no redirect submitted"
208 // and dropped, so requiring the pair keeps the payload honest.
209 if ( array_key_exists( 'redirect_type', $settings ) && array_key_exists( 'redirect_url', $settings ) ) {
210 $fields['thinkrank_redirect_type'] = Object_Redirect::normalize_type( $settings['redirect_type'] );
211 }
212
213 if ( array_key_exists( 'focus_keywords', $settings ) && is_array( $settings['focus_keywords'] ) ) {
214 $fields['thinkrank_focus_keywords'] = (string) wp_json_encode( array_values( $settings['focus_keywords'] ) );
215 }
216
217 $has_robots = array_key_exists( 'robots_meta', $settings ) || array_key_exists( 'advanced_robots_meta', $settings );
218
219 if ( array_key_exists( 'robots_meta_enabled', $settings ) ) {
220 // Sanitized for the same reason as the visibility flags below: the
221 // string "false" passes the schema and is truthy in PHP.
222 $fields['thinkrank_robots_meta_enabled'] = (int) rest_sanitize_boolean( $settings['robots_meta_enabled'] );
223 } elseif ( $has_robots ) {
224 // The metabox only persists robots blobs when the toggle key is
225 // present; default to the currently stored value (or 1).
226 $existing = get_post_meta( $post_id, '_thinkrank_robots_meta_enabled', true );
227 $fields['thinkrank_robots_meta_enabled'] = '' === $existing ? 1 : (int) (bool) $existing;
228 }
229
230 if ( array_key_exists( 'robots_meta', $settings ) ) {
231 $fields['thinkrank_robots_meta'] = (string) wp_json_encode( (array) $settings['robots_meta'] );
232 }
233
234 if ( array_key_exists( 'advanced_robots_meta', $settings ) ) {
235 $fields['thinkrank_advanced_robots_meta'] = (string) wp_json_encode( (array) $settings['advanced_robots_meta'] );
236 }
237
238 // On-site visibility (#633). get-post-seo already reports both, because
239 // it returns get_post_metadata() wholesale — so without these an agent
240 // could read a field it had no way to change, which is the worst shape
241 // a tool pair can have.
242 //
243 // The value goes through Metabox_Manager::save_visibility_meta(), which
244 // stores 1 on a truthy submission and DELETES the meta on a falsy one,
245 // so '' is how a flag is cleared. Passing the boolean straight through
246 // would work too; the string keeps this in the same shape as every
247 // other field in this map.
248 //
249 // rest_sanitize_boolean() rather than PHP truthiness: WP_Ability only
250 // VALIDATES the input against the schema, it never sanitizes it, and
251 // rest_is_boolean() accepts the strings 'true'/'false'/'1'/'0' for a
252 // boolean property. So a caller that sends "false" — which the schema
253 // accepts — reaches this line with a truthy string and would have the
254 // flag SET, the opposite of what it asked for, reported as a success.
255 foreach (
256 [
257 'exclude_from_search' => 'thinkrank_exclude_from_search',
258 'exclude_from_archives' => 'thinkrank_exclude_from_archives',
259 ] as $key => $field
260 ) {
261 if ( array_key_exists( $key, $settings ) ) {
262 $fields[ $field ] = rest_sanitize_boolean( $settings[ $key ] ) ? '1' : '';
263 }
264 }
265
266 return $fields;
267 }
268 }
269