PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.29.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.29.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 / admin / class-metabox-manager.php

class-metabox-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.29.0, at includes/admin/class-metabox-manager.php

1,252 lines 49.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Meta Box Manager
5 *
6 * Handles ThinkRank meta boxes in post/page edit screens
7 *
8 * @package ThinkRank\Admin
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\Admin;
15
16 use ThinkRank\AI\Metadata_Generator;
17 use ThinkRank\AI\SEOScoreCalculator;
18 use ThinkRank\Core\Settings;
19 use ThinkRank\Core\Database;
20 use ThinkRank\Core\Plan_Config;
21 use ThinkRank\SEO\Focus_Keywords;
22 use ThinkRank\SEO\Pattern_Resolver;
23
24 // Prevent direct access
25 if (!defined('ABSPATH')) {
26 exit;
27 }
28
29 /**
30 * Meta Box Manager Class
31 *
32 * Single Responsibility: Manage post/page meta boxes
33 *
34 * @since 1.0.0
35 */
36 class Metabox_Manager {
37
38 /**
39 * Settings instance
40 *
41 * @var Settings
42 */
43 private Settings $settings;
44
45 /**
46 * Metadata generator instance
47 *
48 * @var Metadata_Generator
49 */
50 private Metadata_Generator $metadata_generator;
51
52 /**
53 * SEO Score Calculator instance
54 *
55 * @var SEOScoreCalculator
56 */
57 private SEOScoreCalculator $seo_calculator;
58
59 /**
60 * Constructor
61 *
62 * @param Settings|null $settings Settings instance
63 * @param Metadata_Generator|null $metadata_generator Metadata generator instance
64 * @param SEOScoreCalculator|null $seo_calculator SEO Score Calculator instance
65 */
66 public function __construct(?Settings $settings = null, ?Metadata_Generator $metadata_generator = null, ?SEOScoreCalculator $seo_calculator = null) {
67 $this->settings = $settings ?? Settings::instance();
68 $this->metadata_generator = $metadata_generator ?? new Metadata_Generator();
69 $this->seo_calculator = $seo_calculator ?? new SEOScoreCalculator(new Database());
70 }
71
72 /**
73 * Initialize meta box manager
74 *
75 * @return void
76 */
77 public function init(): void {
78 add_action('add_meta_boxes', [$this, 'add_meta_boxes']);
79 add_action('save_post', [$this, 'save_meta_boxes'], 10, 2);
80 add_action('admin_enqueue_scripts', [$this, 'enqueue_metabox_scripts']);
81 add_action('init', [$this, 'register_meta_fields']);
82
83 // AJAX handlers for meta box functionality
84 add_action('wp_ajax_thinkrank_generate_post_metadata', [$this, 'ajax_generate_post_metadata']);
85
86 // Full metabox save used by editors that don't submit the #post form
87 // (e.g. the Elementor editor). Persists every metabox field at once.
88 add_action('wp_ajax_thinkrank_save_metabox', [$this, 'ajax_save_metabox']);
89
90 // Removed debug hooks
91 }
92
93 /**
94 * Register meta fields for REST API access
95 *
96 * @return void
97 */
98 public function register_meta_fields(): void {
99 // Register schema form data meta fields
100 register_post_meta('', '_thinkrank_schema_form_data', [
101 'show_in_rest' => true,
102 'single' => true,
103 'type' => 'string',
104 'sanitize_callback' => [$this, 'sanitize_json_meta_field'],
105 'auth_callback' => function () {
106 return current_user_can('edit_posts') || current_user_can('edit_pages');
107 }
108 ]);
109
110 register_post_meta('', '_thinkrank_selected_schema_type', [
111 'show_in_rest' => true,
112 'single' => true,
113 'type' => 'string',
114 'auth_callback' => function () {
115 return current_user_can('edit_posts') || current_user_can('edit_pages');
116 }
117 ]);
118
119 register_post_meta('', '_thinkrank_additional_schemas', [
120 'show_in_rest' => true,
121 'single' => true,
122 'type' => 'string',
123 'sanitize_callback' => [$this, 'sanitize_json_ld_field'],
124 'auth_callback' => function() {
125 return current_user_can('edit_posts') || current_user_can('edit_pages');
126 }
127 ]);
128
129 // SEO meta fields for import support
130 $string_meta_fields = [
131 '_thinkrank_canonical_url',
132 '_thinkrank_og_title',
133 '_thinkrank_og_description',
134 '_thinkrank_og_image',
135 '_thinkrank_twitter_title',
136 '_thinkrank_twitter_description',
137 '_thinkrank_twitter_image',
138 '_thinkrank_imported_from',
139 ];
140
141 foreach ($string_meta_fields as $meta_key) {
142 register_post_meta('', $meta_key, [
143 'show_in_rest' => true,
144 'single' => true,
145 'type' => 'string',
146 'auth_callback' => function () {
147 return current_user_can('edit_posts') || current_user_can('edit_pages');
148 }
149 ]);
150 }
151
152 // Multiple focus keywords (array). The legacy single-value
153 // `_thinkrank_focus_keyword` is kept in sync by Focus_Keywords for
154 // backward compatibility and registered for REST as a string elsewhere.
155 register_post_meta('', Focus_Keywords::META_KEY, [
156 'show_in_rest' => [
157 'schema' => [
158 'type' => 'array',
159 'items' => ['type' => 'string'],
160 ],
161 ],
162 'single' => true,
163 'type' => 'array',
164 'sanitize_callback' => function ($value) {
165 return Focus_Keywords::normalize($value);
166 },
167 'auth_callback' => function () {
168 return current_user_can('edit_posts') || current_user_can('edit_pages');
169 }
170 ]);
171
172 register_post_meta('', Focus_Keywords::LEGACY_META_KEY, [
173 'show_in_rest' => true,
174 'single' => true,
175 'type' => 'string',
176 'auth_callback' => function () {
177 return current_user_can('edit_posts') || current_user_can('edit_pages');
178 }
179 ]);
180
181 register_post_meta('', '_thinkrank_robots_meta_enabled', [
182 'show_in_rest' => true,
183 'single' => true,
184 'type' => 'integer',
185 'auth_callback' => function () {
186 return current_user_can('edit_posts') || current_user_can('edit_pages');
187 }
188 ]);
189
190 register_post_meta('', '_thinkrank_robots_meta', [
191 'show_in_rest' => true,
192 'single' => true,
193 'type' => 'string',
194 'sanitize_callback' => [$this, 'sanitize_json_meta_field'],
195 'auth_callback' => function () {
196 return current_user_can('edit_posts') || current_user_can('edit_pages');
197 }
198 ]);
199
200 register_post_meta('', '_thinkrank_advanced_robots_meta', [
201 'show_in_rest' => true,
202 'single' => true,
203 'type' => 'string',
204 'sanitize_callback' => [$this, 'sanitize_json_meta_field'],
205 'auth_callback' => function () {
206 return current_user_can('edit_posts') || current_user_can('edit_pages');
207 }
208 ]);
209
210 register_post_meta('', '_thinkrank_primary_category', [
211 'show_in_rest' => true,
212 'single' => true,
213 'type' => 'integer',
214 'auth_callback' => function () {
215 return current_user_can('edit_posts') || current_user_can('edit_pages');
216 }
217 ]);
218 }
219
220 /**
221 * Sanitize JSON meta field data
222 *
223 * Validates JSON structure and recursively sanitizes all string values
224 * to prevent XSS and injection attacks.
225 *
226 * @param string $value Raw JSON string value
227 * @return string Sanitized JSON string or empty string if invalid
228 */
229 public function sanitize_json_meta_field(string $value): string {
230 // Return empty string for non-string values
231 if (!is_string($value) || empty($value)) {
232 return '';
233 }
234
235 // Validate JSON structure
236 $decoded = json_decode($value, true);
237 if (json_last_error() !== JSON_ERROR_NONE) {
238 // Invalid JSON - return empty string
239 return '';
240 }
241
242 // Check for reasonable data size (prevent JSON bombs)
243 if (strlen($value) > 50000) { // 50KB limit
244 return '';
245 }
246
247 // Recursively sanitize all values
248 $sanitized = $this->sanitize_json_recursively($decoded);
249
250 // Re-encode as JSON
251 $result = wp_json_encode($sanitized);
252 return $result !== false ? $result : '';
253 }
254
255 /**
256 * Recursively sanitize JSON data
257 *
258 * @param mixed $data Data to sanitize
259 * @param int $depth Current recursion depth
260 * @return mixed Sanitized data
261 */
262 private function sanitize_json_recursively($data, int $depth = 0): mixed {
263 // Prevent deep recursion attacks
264 if ($depth > 10) {
265 return null;
266 }
267
268 if (is_array($data)) {
269 $sanitized = [];
270 foreach ($data as $key => $value) {
271 $clean_key = sanitize_key($key);
272 $sanitized[$clean_key] = $this->sanitize_json_recursively($value, $depth + 1);
273 }
274 return $sanitized;
275 }
276
277 if (is_string($data)) {
278 // Sanitize string data to prevent XSS
279 return sanitize_textarea_field($data);
280 }
281
282 if (is_numeric($data)) {
283 return $data;
284 }
285
286 if (is_bool($data)) {
287 return $data;
288 }
289
290 // For any other data type, return null
291 return null;
292 }
293
294 /**
295 * Sanitize JSON-LD meta field data
296 *
297 * Validates JSON structure, recursively sanitizes all string values,
298 * and preserves @ characters in keys (crucial for JSON-LD).
299 *
300 * @param string $value Raw JSON string value
301 *
302 * @return string Sanitized JSON string or empty string if invalid
303 */
304 public function sanitize_json_ld_field( string $value ): string {
305 // Return empty string for non-string values
306 if ( ! is_string( $value ) || empty( $value ) ) {
307 return '';
308 }
309
310 // Validate JSON structure
311 $decoded = json_decode( $value, true );
312 if ( json_last_error() !== JSON_ERROR_NONE ) {
313 // Invalid JSON - return empty string
314 return '';
315 }
316
317 // Check for reasonable data size (prevent JSON bombs)
318 if ( strlen( $value ) > 200000 ) { // Limit to 200KB for larger schemas
319 return '';
320 }
321
322 // Recursively sanitize all values
323 $sanitized = $this->sanitize_json_ld_recursively( $decoded );
324
325 // Re-encode as JSON
326 $result = wp_json_encode( $sanitized );
327
328 return $result !== false ? $result : '';
329 }
330
331 /**
332 * Recursively sanitize JSON-LD data
333 *
334 * Similar to sanitize_json_recursively but preserves @ symbol and case in keys.
335 *
336 * @param mixed $data Data to sanitize
337 * @param int $depth Current recursion depth
338 *
339 * @return mixed Sanitized data
340 */
341 private function sanitize_json_ld_recursively( $data, int $depth = 0 ): mixed {
342 // Prevent deep recursion attacks
343 if ( $depth > 10 ) {
344 return null;
345 }
346
347 if ( is_array( $data ) ) {
348 $sanitized = [];
349 foreach ( $data as $key => $value ) {
350 // Allow alphanumeric, underscore, dash, and @ (crucial for JSON-LD)
351 // Also preserve case as JSON-LD keys are case-sensitive
352 $clean_key = preg_replace( '/[^a-zA-Z0-9_\-@]/', '', (string) $key );
353 $sanitized[ $clean_key ] = $this->sanitize_json_ld_recursively( $value, $depth + 1 );
354 }
355
356 return $sanitized;
357 }
358
359 if ( is_string( $data ) ) {
360 // Sanitize string data to prevent XSS
361 return sanitize_textarea_field( $data );
362 }
363
364 if ( is_numeric( $data ) ) {
365 return $data;
366 }
367
368 if ( is_bool( $data ) ) {
369 return $data;
370 }
371
372 // For any other data type, return null
373 return null;
374 }
375
376 // Removed debug methods
377
378 /**
379 * Add ThinkRank meta boxes
380 *
381 * @return void
382 */
383 public function add_meta_boxes(): void {
384 $post_types = $this->get_supported_post_types();
385
386 $is_block_editor = $this->is_block_editor_screen();
387
388 foreach ($post_types as $post_type) {
389 add_meta_box(
390 'thinkrank-seo-metabox',
391 __('ThinkRank SEO', 'thinkrank'),
392 [$this, 'render_seo_metabox'],
393 $post_type,
394 'normal',
395 'high'
396 );
397
398 // Classic Editor sidebar quick-access widget (mirrors SureRank's
399 // "Manage your SEO" sidebar box). Skipped in the Block Editor, which
400 // surfaces the full panel below the content instead.
401 if (!$is_block_editor) {
402 add_meta_box(
403 'thinkrank-seo-sidebar',
404 __('ThinkRank', 'thinkrank'),
405 [$this, 'render_sidebar_meta_box'],
406 $post_type,
407 'side',
408 'high'
409 );
410 }
411 }
412 }
413
414 /**
415 * Whether the current edit screen is using the Block Editor.
416 *
417 * @return bool True when the Block Editor is active, false for Classic Editor.
418 */
419 private function is_block_editor_screen(): bool {
420 if (!function_exists('get_current_screen')) {
421 return false;
422 }
423
424 $screen = get_current_screen();
425
426 return $screen instanceof \WP_Screen
427 && method_exists($screen, 'is_block_editor')
428 && $screen->is_block_editor();
429 }
430
431 /**
432 * Render the Classic Editor sidebar quick-access widget.
433 *
434 * Shows a short label plus a button that scrolls to (and expands) the full
435 * "ThinkRank SEO" panel in the main column.
436 *
437 * @param \WP_Post $post Post object.
438 * @return void
439 */
440 public function render_sidebar_meta_box(\WP_Post $post): void {
441 $box_title = apply_filters('thinkrank_seo_sidebar_box_title', __('Optimize this content for search & AI with ThinkRank.', 'thinkrank'), $post);
442 $cta_label = apply_filters('thinkrank_seo_sidebar_cta_label', __('Open ThinkRank SEO', 'thinkrank'), $post);
443 ?>
444 <div class="thinkrank-classic-sidebar-box">
445 <p class="thinkrank-classic-sidebar-box-title"><?php echo esc_html($box_title); ?></p>
446 <button
447 type="button"
448 class="button button-primary thinkrank-classic-sidebar-box-cta"
449 >
450 <?php echo esc_html($cta_label); ?>
451 </button>
452 </div>
453 <?php
454 }
455
456 /**
457 * Render SEO meta box
458 *
459 * @param \WP_Post $post Post object
460 * @return void
461 */
462 public function render_seo_metabox(\WP_Post $post): void {
463 // Add nonce for security
464 wp_nonce_field('thinkrank_metabox_nonce', 'thinkrank_metabox_nonce');
465
466 // Get existing metadata
467 $existing_metadata = $this->get_post_metadata($post->ID);
468
469 // Get post content for AI analysis
470 $content_preview = $this->get_content_preview($post);
471
472 // Render React metabox container with hidden form fields for data
473 ?>
474 <div id="thinkrank-metabox-container" class="thinkrank-metabox">
475
476 <!-- Hidden form fields for React to read initial data -->
477 <input type="hidden" id="thinkrank_seo_title" name="thinkrank_seo_title" value="<?php echo esc_attr($existing_metadata['title'] ?? ''); ?>" />
478 <input type="hidden" id="thinkrank_meta_description" name="thinkrank_meta_description" value="<?php echo esc_attr($existing_metadata['description'] ?? ''); ?>" />
479 <?php // Render-time mirrors of the two fields a background writer (Auto AI, bulk, import) can fill after load. The React app never touches these, so on save they still hold the value shown when the form loaded — letting persist_metadata() tell a stale blank apart from a deliberate clear. ?>
480 <input type="hidden" id="thinkrank_seo_title__orig" name="thinkrank_seo_title__orig" value="<?php echo esc_attr($existing_metadata['title'] ?? ''); ?>" />
481 <input type="hidden" id="thinkrank_meta_description__orig" name="thinkrank_meta_description__orig" value="<?php echo esc_attr($existing_metadata['description'] ?? ''); ?>" />
482 <input type="hidden" id="thinkrank_focus_keyword" name="thinkrank_focus_keyword" value="<?php echo esc_attr($existing_metadata['focus_keyword'] ?? ''); ?>" />
483 <input type="hidden" id="thinkrank_focus_keywords" name="thinkrank_focus_keywords" value="<?php echo esc_attr(wp_json_encode($existing_metadata['focus_keywords'] ?? [])); ?>" />
484 <input type="hidden" id="thinkrank_seo_score" name="thinkrank_seo_score" value="<?php echo esc_attr($existing_metadata['seo_score'] ?? '0'); ?>" />
485 <input type="hidden" id="thinkrank_generated_at" name="thinkrank_generated_at" value="<?php echo esc_attr($existing_metadata['generated_at'] ?? ''); ?>" />
486 <input type="hidden" id="thinkrank_pillar_content" name="thinkrank_pillar_content" value="<?php echo esc_attr($existing_metadata['pillar_content'] ?? ''); ?>" />
487 <input type="hidden" id="thinkrank_canonical_url" name="thinkrank_canonical_url" value="<?php echo esc_url($existing_metadata['canonical_url'] ?? ''); ?>" />
488 <input type="hidden" id="thinkrank_robots_meta_enabled" name="thinkrank_robots_meta_enabled" value="<?php echo esc_attr((string) ($existing_metadata['robots_meta_enabled'] ?? '0')); ?>" />
489 <input type="hidden" id="thinkrank_robots_meta" name="thinkrank_robots_meta" value="<?php echo esc_attr((string) ($existing_metadata['robots_meta'] ?? '')); ?>" />
490 <input type="hidden" id="thinkrank_advanced_robots_meta" name="thinkrank_advanced_robots_meta" value="<?php echo esc_attr((string) ($existing_metadata['advanced_robots_meta'] ?? '')); ?>" />
491 <input type="hidden" id="thinkrank_og_title" name="thinkrank_og_title" value="<?php echo esc_attr((string) ($existing_metadata['og_title'] ?? '')); ?>" />
492 <input type="hidden" id="thinkrank_og_description" name="thinkrank_og_description" value="<?php echo esc_attr((string) ($existing_metadata['og_description'] ?? '')); ?>" />
493 <input type="hidden" id="thinkrank_og_image" name="thinkrank_og_image" value="<?php echo esc_url((string) ($existing_metadata['og_image'] ?? '')); ?>" />
494 <input type="hidden" id="thinkrank_twitter_title" name="thinkrank_twitter_title" value="<?php echo esc_attr((string) ($existing_metadata['twitter_title'] ?? '')); ?>" />
495 <input type="hidden" id="thinkrank_twitter_description" name="thinkrank_twitter_description" value="<?php echo esc_attr((string) ($existing_metadata['twitter_description'] ?? '')); ?>" />
496 <input type="hidden" id="thinkrank_twitter_image" name="thinkrank_twitter_image" value="<?php echo esc_url((string) ($existing_metadata['twitter_image'] ?? '')); ?>" />
497 <textarea id="thinkrank_content_preview" style="display: none;"><?php echo esc_textarea($content_preview); ?></textarea>
498 </div>
499 <?php
500
501 }
502
503 /**
504 * Save meta box data
505 *
506 * @param int $post_id Post ID
507 * @param \WP_Post $post Post object
508 * @return void
509 */
510 public function save_meta_boxes(int $post_id, \WP_Post $post): void {
511 // Verify nonce
512 if (!isset($_POST['thinkrank_metabox_nonce'])) {
513 return;
514 }
515
516 $nonce = sanitize_text_field(wp_unslash($_POST['thinkrank_metabox_nonce']));
517 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_nonce')) {
518 return;
519 }
520
521 // Check permissions
522 if (!current_user_can('edit_post', $post_id)) {
523 return;
524 }
525
526 // Skip autosave
527 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
528 return;
529 }
530
531 // The classic/block editor submits the metabox fields as part of the
532 // #post form, so they arrive (slashed) in $_POST. Hand them straight to
533 // the shared persistence routine.
534 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above
535 $this->persist_metadata($post_id, wp_unslash($_POST));
536 }
537
538 /**
539 * Persist metabox fields for a post from a form-field-name => value map,
540 * reusing the shared metabox persistence (sanitization, JSON encoding,
541 * focus-keyword normalization, empty-value deletion).
542 *
543 * Intended for non-form callers such as the MCP abilities layer. The
544 * caller is responsible for authorization; keys use the same
545 * `thinkrank_*` field names accepted by the metabox form (e.g.
546 * `thinkrank_seo_title`, `thinkrank_meta_description`, `thinkrank_robots_meta`).
547 *
548 * @param int $post_id Post to update.
549 * @param array $fields Field name => value map.
550 * @return void
551 */
552 public function save_seo_fields(int $post_id, array $fields): void {
553 $this->persist_metadata($post_id, $fields);
554 }
555
556 /**
557 * Persist all metabox fields for a post from a $_POST-shaped (already
558 * unslashed) source array.
559 *
560 * Shared by `save_meta_boxes()` (classic/block editor form submit) and
561 * `ajax_save_metabox()` (editors like Elementor that don't submit the #post
562 * form). Nonce/permission checks are the caller's responsibility. Each field
563 * is independently sanitized; missing keys are left untouched.
564 *
565 * @param int $post_id Post to update.
566 * @param array $src Field name => raw value map (unslashed).
567 * @return void
568 */
569 private function persist_metadata(int $post_id, array $src): void {
570 // Title & meta description are handled separately below: they can be
571 // written out-of-band (Auto AI on publish, bulk optimization, imports)
572 // after an editor was opened, so a plain save from that now-stale editor
573 // would clobber the generated value with a blank. Focus keywords are
574 // likewise handled separately (array meta) via Focus_Keywords below.
575 $this->persist_seo_text_field($post_id, $src, 'thinkrank_seo_title', '_thinkrank_seo_title', 'sanitize_text_field');
576 $this->persist_seo_text_field($post_id, $src, 'thinkrank_meta_description', '_thinkrank_meta_description', 'sanitize_textarea_field');
577
578 $fields = [
579 'thinkrank_seo_score' => 'absint',
580 'thinkrank_generated_at' => 'sanitize_text_field',
581 'thinkrank_pillar_content' => 'sanitize_text_field',
582 ];
583
584 // Focus keywords: prefer the JSON array field; fall back to the legacy
585 // single string. Focus_Keywords::save() normalizes (dedupe, drop empty,
586 // cap at MAX) and keeps the legacy single-value meta in sync.
587 if (isset($src['thinkrank_focus_keywords'])) {
588 $raw_keywords = $src['thinkrank_focus_keywords'];
589 $decoded = is_string($raw_keywords) ? json_decode($raw_keywords, true) : $raw_keywords;
590 Focus_Keywords::save($post_id, is_array($decoded) ? $decoded : []);
591 } elseif (isset($src['thinkrank_focus_keyword'])) {
592 Focus_Keywords::save($post_id, $src['thinkrank_focus_keyword']);
593 }
594
595 // Update the post slug (post_name) when the metabox permalink field
596 // was edited. This touches the WP post itself, not post meta.
597 if (isset($src['thinkrank_post_slug'])) {
598 $this->maybe_update_slug($post_id, (string) $src['thinkrank_post_slug']);
599 }
600
601 // Save canonical URL separately with URL sanitization
602 if (isset($src['thinkrank_canonical_url'])) {
603 $canonical_url = esc_url_raw((string) $src['thinkrank_canonical_url']);
604 if (empty($canonical_url)) {
605 delete_post_meta($post_id, '_thinkrank_canonical_url');
606 } else {
607 update_post_meta($post_id, '_thinkrank_canonical_url', $canonical_url);
608 }
609 }
610
611 foreach ($fields as $field => $sanitize_callback) {
612 if (isset($src[$field])) {
613 $value = call_user_func($sanitize_callback, $src[$field]);
614 update_post_meta($post_id, "_{$field}", $value);
615 }
616 }
617
618 $this->save_robots_meta($post_id, $src);
619 $this->save_social_meta($post_id, $src);
620
621 // Update last modified timestamp
622 update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
623 }
624
625 /**
626 * Persist one SEO text field with an out-of-band-write guard.
627 *
628 * Auto AI (on publish), bulk optimization and imports write the SEO title /
629 * meta description directly to post meta. When that happens after an editor
630 * was opened, the editor's hidden input is a stale blank; a normal save
631 * would overwrite the freshly generated value with that blank. This guard
632 * skips the write only when the submitted value is empty AND it was also
633 * empty when the form was rendered (the `<field>__orig` mirror), yet the
634 * stored value is now non-empty — i.e. a background writer won the race.
635 *
636 * A deliberate clear still applies: if the field held a value at render and
637 * is submitted empty, `$orig` is non-empty so the guard does not trigger.
638 * Callers that don't send the `__orig` mirror (e.g. the MCP/Elementor
639 * paths) keep the plain write behavior.
640 *
641 * @param int $post_id Post being saved.
642 * @param array $src Unslashed field map.
643 * @param string $field POST field name (e.g. thinkrank_seo_title).
644 * @param string $meta_key Target post meta key.
645 * @param callable $sanitize Sanitizer applied to the submitted value.
646 * @return void
647 */
648 private function persist_seo_text_field(int $post_id, array $src, string $field, string $meta_key, callable $sanitize): void {
649 if (!isset($src[$field])) {
650 return; // Field absent from this submit → leave the stored value untouched.
651 }
652
653 $submitted = (string) call_user_func($sanitize, (string) $src[$field]);
654
655 if ('' === $submitted && isset($src[$field . '__orig'])) {
656 $orig = (string) $src[$field . '__orig'];
657 $stored = (string) get_post_meta($post_id, $meta_key, true);
658 // Blank now, blank at render, but populated in storage → a background
659 // write landed after this editor loaded; don't clobber it.
660 if ('' === $orig && '' !== $stored) {
661 return;
662 }
663 }
664
665 update_post_meta($post_id, $meta_key, $submitted);
666 }
667
668 /**
669 * Update the post slug (post_name) from the metabox permalink field.
670 *
671 * Runs inside the save_post cycle, so wp_update_post() would recurse — a
672 * static guard prevents re-entry. WordPress applies wp_unique_post_slug(),
673 * so a colliding slug is de-duplicated automatically. Empty input is left
674 * alone (WP keeps/auto-generates the slug); auto-drafts and revisions are
675 * skipped so we don't fight the editor's own slug generation.
676 *
677 * @param int $post_id Post to update.
678 * @param string $raw_slug Desired slug from the metabox.
679 * @return void
680 */
681 private function maybe_update_slug(int $post_id, string $raw_slug): void {
682 static $updating = false;
683 if ($updating) {
684 return;
685 }
686
687 $post = get_post($post_id);
688 if (!$post || wp_is_post_revision($post_id)) {
689 return;
690 }
691
692 if (in_array($post->post_status, ['auto-draft', 'trash'], true)) {
693 return;
694 }
695
696 $desired = sanitize_title($raw_slug);
697 if ($desired === '' || $desired === $post->post_name) {
698 return;
699 }
700
701 $updating = true;
702 wp_update_post([
703 'ID' => $post_id,
704 'post_name' => $desired,
705 ]);
706 $updating = false;
707 }
708
709 /**
710 * Persist per-post Open Graph and Twitter Card overrides.
711 *
712 * Empty values delete the meta entry so the frontend falls back to the
713 * SEO title / meta description / featured image chain.
714 */
715 private function save_social_meta(int $post_id, array $src): void {
716 $text_fields = [
717 'thinkrank_og_title' => '_thinkrank_og_title',
718 'thinkrank_og_description' => '_thinkrank_og_description',
719 'thinkrank_twitter_title' => '_thinkrank_twitter_title',
720 'thinkrank_twitter_description' => '_thinkrank_twitter_description',
721 ];
722 foreach ($text_fields as $field => $meta_key) {
723 if (!isset($src[$field])) {
724 continue;
725 }
726 $value = sanitize_textarea_field((string) $src[$field]);
727 if ($value === '') {
728 delete_post_meta($post_id, $meta_key);
729 } else {
730 update_post_meta($post_id, $meta_key, $value);
731 }
732 }
733
734 $url_fields = [
735 'thinkrank_og_image' => '_thinkrank_og_image',
736 'thinkrank_twitter_image' => '_thinkrank_twitter_image',
737 ];
738 foreach ($url_fields as $field => $meta_key) {
739 if (!isset($src[$field])) {
740 continue;
741 }
742 $value = esc_url_raw((string) $src[$field]);
743 if ($value === '') {
744 delete_post_meta($post_id, $meta_key);
745 } else {
746 update_post_meta($post_id, $meta_key, $value);
747 }
748 }
749 }
750
751 /**
752 * Save per-post robots meta and advanced robots meta from the metabox.
753 *
754 * Stores the robots payloads as JSON-encoded strings, sanitized via
755 * sanitize_json_meta_field. The toggle plus the two JSON blobs are the
756 * single source of truth for per-post robots overrides.
757 */
758 private function save_robots_meta(int $post_id, array $src): void {
759 if (!isset($src['thinkrank_robots_meta_enabled'])) {
760 return;
761 }
762
763 $enabled = (int) (bool) $src['thinkrank_robots_meta_enabled'];
764 update_post_meta($post_id, '_thinkrank_robots_meta_enabled', $enabled);
765
766 if (isset($src['thinkrank_robots_meta'])) {
767 update_post_meta($post_id, '_thinkrank_robots_meta', $this->sanitize_json_meta_field((string) $src['thinkrank_robots_meta']));
768 }
769
770 if (isset($src['thinkrank_advanced_robots_meta'])) {
771 update_post_meta($post_id, '_thinkrank_advanced_robots_meta', $this->sanitize_json_meta_field((string) $src['thinkrank_advanced_robots_meta']));
772 }
773 }
774
775
776 /**
777 * Enqueue meta box scripts
778 *
779 * @param string $hook Current admin page hook
780 * @return void
781 */
782 public function enqueue_metabox_scripts(string $hook): void {
783 // Only load on post edit screens (including block editor)
784 if (!in_array($hook, ['post.php', 'post-new.php'], true)) {
785 return;
786 }
787
788 // Get current post type - handle both classic and block editor contexts
789 $current_post_type = $this->get_current_post_type();
790 if (!$current_post_type || !in_array($current_post_type, $this->get_supported_post_types(), true)) {
791 return;
792 }
793
794 // Get post object for additional data
795 global $post;
796
797 // Ensure wp.media is available for the social-image media picker.
798 wp_enqueue_media();
799
800 // No chunk dependencies needed - all bundled into main metabox.js
801 // Enqueue React metabox script with direct dependencies
802 $asset_file = THINKRANK_PLUGIN_DIR . 'assets/metabox.asset.php';
803 $asset = file_exists($asset_file) ? include $asset_file : [
804 'dependencies' => ['react', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-components'],
805 'version' => THINKRANK_VERSION
806 ];
807
808 // Use dependencies directly from the asset file. The pinned "Configure
809 // SEO" launcher needs wp-plugins (already listed by the build) and
810 // resolves PinnedItems from wp.editor / wp.interface at runtime, so we do
811 // NOT add wp-interface here: it is not a registered script handle on
812 // WP 7.x, and an unmet dependency would drop the whole metabox script.
813 $dependencies = $asset['dependencies'];
814
815 wp_enqueue_script(
816 'thinkrank-metabox',
817 THINKRANK_PLUGIN_URL . 'assets/metabox.js',
818 $dependencies,
819 $asset['version'],
820 true
821 );
822
823 // Localize script data (shared builder; reused by the Elementor editor
824 // integration, which has no #post form / hidden inputs of its own).
825 wp_localize_script('thinkrank-metabox', 'thinkrankMetabox', $this->get_localized_data($post->ID));
826
827 // Add defer attribute for non-blocking script loading
828 wp_script_add_data('thinkrank-metabox', 'defer', true);
829
830 // Enqueue metabox styles
831 wp_enqueue_style(
832 'thinkrank-metabox',
833 THINKRANK_PLUGIN_URL . 'assets/metabox.css',
834 ['wp-components'],
835 THINKRANK_VERSION
836 );
837
838 // Classic Editor sidebar widget: lightweight styles + a vanilla-JS
839 // handler so the "Optimize Here" button works without depending on the
840 // React bundle. The box itself is only registered in the Classic Editor.
841 $sidebar_css = <<<'CSS'
842 .thinkrank-classic-sidebar-box-title{margin:0 0 12px;font-size:13px;line-height:1.5;color:#1e1e1e;}
843 .thinkrank-classic-sidebar-box-cta{width:100%;text-align:center;justify-content:center;}
844 CSS;
845 wp_add_inline_style('thinkrank-metabox', $sidebar_css);
846
847 $sidebar_js = <<<'JS'
848 (function(){
849 document.addEventListener('click', function(e){
850 var btn = e.target.closest && e.target.closest('.thinkrank-classic-sidebar-box-cta');
851 if(!btn){return;}
852 e.preventDefault();
853 // Open the ThinkRank SEO drawer mounted by the React metabox app.
854 window.dispatchEvent(new CustomEvent('thinkrank:toggle-seo-drawer'));
855 });
856 })();
857 JS;
858 wp_add_inline_script('thinkrank-metabox', $sidebar_js);
859 }
860
861 /**
862 * Build the data object localized into the metabox script (`thinkrankMetabox`).
863 *
864 * Extracted so the Elementor editor integration can reuse the exact same
865 * configuration. Callers that run outside the #post form (Elementor) also
866 * read `existingMetadata`/`contentPreview`, which they add on top of this.
867 *
868 * @param int $post_id Post being edited.
869 * @return array Localized config consumed by the React metabox.
870 */
871 public function get_localized_data(int $post_id): array {
872 $post = get_post($post_id);
873 $post_type = $post ? $post->post_type : 'post';
874
875 // Resolve the correct REST API base for the current post type.
876 // Falls back to the post type slug for any type without a rest_base.
877 $post_type_obj = get_post_type_object($post_type);
878 $post_rest_base = ($post_type_obj && !empty($post_type_obj->rest_base))
879 ? $post_type_obj->rest_base
880 : $post_type;
881
882 return [
883 'ajaxUrl' => admin_url('admin-ajax.php'),
884 'nonce' => wp_create_nonce('thinkrank_metabox_ajax'),
885 'postId' => $post_id,
886 'postType' => $post_type,
887 'postRestBase' => $post_rest_base,
888 'postPermalink' => get_permalink($post_id),
889 'postSlug' => $post ? $post->post_name : '',
890 'restUrl' => rest_url('thinkrank/v1/'),
891 'restNonce' => wp_create_nonce('wp_rest'),
892 'homeUrl' => home_url(),
893 'siteName' => get_bloginfo('name'),
894 'faviconUrl' => $this->get_site_favicon_url(),
895 'featuredImageUrl' => $this->get_post_featured_image_url($post_id),
896 'strings' => [
897 'generating' => __('Generating...', 'thinkrank'),
898 'analyzing' => __('Analyzing...', 'thinkrank'),
899 'error' => __('Error occurred', 'thinkrank'),
900 'success' => __('Success!', 'thinkrank'),
901 'generated' => __('Metadata generated successfully', 'thinkrank'),
902 'contentTooShort' => __('Please add some content before generating SEO metadata.', 'thinkrank'),
903 'apiError' => __('Failed to connect to AI service. Please check your API settings.', 'thinkrank'),
904 ],
905 'seoScore' => $this->get_persisted_seo_score($post_id),
906 'postModified' => $post ? get_the_modified_date('c', $post) : '',
907 'linkSuggestionsEnabled' => $this->is_link_suggestions_enabled($post_type),
908 'postStatus' => get_post_status($post_id),
909 'isPro' => Plan_Config::is_pro(),
910 // Whether any AI provider API key is configured — gates the
911 // "Generate with AI" button in the metabox
912 'aiConfigured' => !empty($this->settings->get('openai_api_key', ''))
913 || !empty($this->settings->get('claude_api_key', ''))
914 || !empty($this->settings->get('gemini_api_key', ''))
915 || !empty($this->settings->get('openrouter_api_key', '')),
916 // Focus keywords plan limits (max_keywords; 0 = unlimited).
917 'focusKeywords' => Plan_Config::focus_keywords(),
918 // Resolved Global/Bulk SEO variable-tag patterns for this post, shown
919 // as placeholder previews when a field is empty (the frontend applies
920 // these same patterns on output). Typing a value overrides them.
921 'patternPreviews' => Pattern_Resolver::previews($post_id),
922 // Token => value map (keys without %), for live client-side preview of
923 // a custom pattern typed into a metabox field.
924 'patternVariables' => Pattern_Resolver::variables($post_id),
925 ];
926 }
927
928 /**
929 * Latest persisted SEO score for a post.
930 *
931 * Read from the scores table — the same source the posts list column and the
932 * Analysis panel use — so the editor badge agrees with them on load rather
933 * than showing 0 until the Analysis panel mounts and fetches the score.
934 *
935 * The `thinkrank_seo_score` form field is unusable for this: it is only
936 * written by the AI "Analyze Content" flow and is reset to 0 on every save.
937 *
938 * @param int $post_id Post ID
939 * @return int|null Score, or null when the post has never been analyzed.
940 */
941 private function get_persisted_seo_score(int $post_id): ?int {
942 $existing = $this->seo_calculator->get_existing_score_data($post_id);
943 $score = $existing['overall_score'] ?? null;
944
945 return $score !== null ? (int) $score : null;
946 }
947
948 /**
949 * Check if link suggestions are enabled for a post type
950 *
951 * @param string $post_type Post type to check
952 * @return bool True if enabled, false otherwise
953 */
954 private function is_link_suggestions_enabled(string $post_type): bool {
955 $settings = get_option('thinkrank_global_seo_settings', []);
956
957 if (isset($settings[$post_type]['link_suggestions'])) {
958 return (bool) $settings[$post_type]['link_suggestions'];
959 }
960
961 return true;
962 }
963
964 /**
965 * Get current post type in admin context
966 *
967 * Handles both classic editor and block editor contexts
968 *
969 * @return string|null Current post type or null if not found
970 */
971 private function get_current_post_type(): ?string {
972 global $post, $typenow, $current_screen;
973
974 // Try to get post type from various sources
975 if ($post && !empty($post->post_type)) {
976 return $post->post_type;
977 }
978
979 if (!empty($typenow)) {
980 return $typenow;
981 }
982
983 if ($current_screen && !empty($current_screen->post_type)) {
984 return $current_screen->post_type;
985 }
986
987 // Fallback: check URL parameters for block editor
988 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
989 if (isset($_GET['post_type'])) {
990 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
991 return sanitize_text_field(wp_unslash($_GET['post_type']));
992 }
993
994 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
995 if (isset($_GET['post'])) {
996 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
997 $post_id = absint($_GET['post']);
998 $post_type = get_post_type($post_id);
999 if ($post_type) {
1000 return $post_type;
1001 }
1002 }
1003
1004 return null;
1005 }
1006
1007 /**
1008 * Get supported post types
1009 *
1010 * @return array Supported post types
1011 */
1012 public function get_supported_post_types(): array {
1013 $default_types = ['post', 'page'];
1014
1015 // Add WooCommerce product if available
1016 if (class_exists('WooCommerce')) {
1017 $default_types[] = 'product';
1018 }
1019
1020 // Add other common e-commerce post types
1021 $ecommerce_types = ['product', 'shop_order', 'shop_coupon'];
1022 foreach ($ecommerce_types as $type) {
1023 if (post_type_exists($type) && !in_array($type, $default_types, true)) {
1024 $default_types[] = $type;
1025 }
1026 }
1027
1028 // Add custom post types that are public and have UI
1029 $custom_post_types = get_post_types([
1030 'public' => true,
1031 'show_ui' => true,
1032 '_builtin' => false,
1033 ]);
1034
1035 foreach ($custom_post_types as $post_type) {
1036 // Skip certain post types that shouldn't have SEO metabox
1037 $excluded_types = [
1038 'attachment',
1039 'revision',
1040 'nav_menu_item',
1041 'custom_css',
1042 'customize_changeset',
1043 'oembed_cache',
1044 'user_request',
1045 'wp_block',
1046 'wp_template',
1047 'wp_template_part',
1048 'wp_global_styles',
1049 'wp_navigation',
1050 'acf-field',
1051 'acf-field-group',
1052 ];
1053
1054 if (!in_array($post_type, $excluded_types, true) && !in_array($post_type, $default_types, true)) {
1055 $default_types[] = $post_type;
1056 }
1057 }
1058
1059 return apply_filters('thinkrank_supported_post_types', $default_types);
1060 }
1061
1062 /**
1063 * Get existing post metadata
1064 *
1065 * @param int $post_id Post ID
1066 * @return array Existing metadata
1067 */
1068 public function get_post_metadata(int $post_id): array {
1069 return [
1070 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
1071 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
1072 'focus_keyword' => Focus_Keywords::get_primary($post_id),
1073 'focus_keywords' => Focus_Keywords::get($post_id),
1074 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
1075 'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1076 'pillar_content' => get_post_meta($post_id, '_thinkrank_pillar_content', true),
1077 'canonical_url' => get_post_meta($post_id, '_thinkrank_canonical_url', true),
1078 'robots_meta_enabled' => get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true),
1079 'robots_meta' => get_post_meta($post_id, '_thinkrank_robots_meta', true),
1080 'advanced_robots_meta' => get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true),
1081 'og_title' => get_post_meta($post_id, '_thinkrank_og_title', true),
1082 'og_description' => get_post_meta($post_id, '_thinkrank_og_description', true),
1083 'og_image' => get_post_meta($post_id, '_thinkrank_og_image', true),
1084 'twitter_title' => get_post_meta($post_id, '_thinkrank_twitter_title', true),
1085 'twitter_description' => get_post_meta($post_id, '_thinkrank_twitter_description', true),
1086 'twitter_image' => get_post_meta($post_id, '_thinkrank_twitter_image', true),
1087 ];
1088 }
1089
1090 /**
1091 * Get site favicon URL
1092 *
1093 * Prefers WordPress' native Site Icon (zero HTTP). Only when no Site Icon is
1094 * configured does it probe common favicon locations — and that probe is
1095 * cached (including a "no favicon" sentinel) so the editor never fires
1096 * blocking loopback HEAD requests on every load.
1097 *
1098 * @since 1.16.2 Prefer get_site_icon_url() and cache the fallback probe.
1099 * @return string Favicon URL, or '' when none can be resolved.
1100 */
1101 private function get_site_favicon_url(): string {
1102 // Try the native Site Icon first (WordPress 4.3+) — no HTTP required.
1103 $site_icon_url = get_site_icon_url();
1104 if ($site_icon_url) {
1105 return $site_icon_url;
1106 }
1107
1108 // No Site Icon configured: fall back to probing common favicon paths.
1109 // Cache the resolved value (empty string included) so the blocking
1110 // loopback probe runs at most once per ~12h per site host instead of on
1111 // every editor load.
1112 $cache_key = 'thinkrank_favicon_url_' . md5((string) wp_parse_url(home_url(), PHP_URL_HOST));
1113 $cached = get_transient($cache_key);
1114 if (false !== $cached) {
1115 return (string) $cached;
1116 }
1117
1118 $favicon_url = '';
1119
1120 $favicon_paths = [
1121 '/favicon.ico',
1122 '/favicon.png',
1123 '/apple-touch-icon.png',
1124 ];
1125
1126 foreach ($favicon_paths as $path) {
1127 $candidate = home_url($path);
1128 $response = wp_remote_head($candidate, ['timeout' => 3]);
1129 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
1130 $favicon_url = $candidate;
1131 break;
1132 }
1133 }
1134
1135 set_transient($cache_key, $favicon_url, 12 * HOUR_IN_SECONDS);
1136
1137 return $favicon_url;
1138 }
1139
1140 /**
1141 * Get post featured image URL
1142 *
1143 * @param int $post_id Post ID
1144 * @return string|null Featured image URL or null if not available
1145 */
1146 private function get_post_featured_image_url(int $post_id): ?string {
1147 $thumbnail_id = get_post_thumbnail_id($post_id);
1148 if ($thumbnail_id) {
1149 // Use the full-size image — social share images need a large source
1150 // (the preview scales it down), not a small thumbnail.
1151 $image_url = wp_get_attachment_image_url($thumbnail_id, 'full');
1152 return $image_url ?: null;
1153 }
1154 return null;
1155 }
1156
1157 /**
1158 * Get content preview for AI analysis
1159 *
1160 * @param \WP_Post $post Post object
1161 * @return string Content preview
1162 */
1163 public function get_content_preview(\WP_Post $post): string {
1164 $content = $post->post_title . "\n\n";
1165
1166 if (!empty($post->post_excerpt)) {
1167 $content .= $post->post_excerpt . "\n\n";
1168 }
1169
1170 // Resolve through Builder_Content: a page builder keeps its words
1171 // outside post_content (Oxygen empties it entirely), and the editor
1172 // cannot reach postmeta, so without this the preview handed to the
1173 // browser is just the title and the live panel reports "No content".
1174 if (!class_exists('\\ThinkRank\\SEO\\Builder_Content')) {
1175 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1176 }
1177 $content .= \ThinkRank\SEO\Builder_Content::resolve($post);
1178
1179 // Clean and limit content
1180 $content = wp_strip_all_tags($content);
1181 $content = preg_replace('/\s+/', ' ', $content);
1182
1183 return trim(substr($content, 0, 4000));
1184 }
1185
1186 /**
1187 * AJAX handler for generating post metadata
1188 *
1189 * @return void
1190 */
1191 public function ajax_generate_post_metadata(): void {
1192 // Verify nonce
1193 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1194 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
1195 wp_die('Security check failed');
1196 }
1197
1198 // Check permissions
1199 $post_id = absint($_POST['post_id'] ?? 0);
1200 if (!current_user_can('edit_post', $post_id)) {
1201 wp_die('Insufficient permissions');
1202 }
1203
1204 try {
1205 $options = [
1206 'target_keyword' => sanitize_text_field(wp_unslash($_POST['target_keyword'] ?? '')),
1207 'content_type' => sanitize_text_field(wp_unslash($_POST['content_type'] ?? 'blog_post')),
1208 'tone' => sanitize_text_field(wp_unslash($_POST['tone'] ?? 'professional')),
1209 ];
1210
1211 $metadata = $this->metadata_generator->generate_for_post($post_id, $options);
1212
1213 wp_send_json_success([
1214 'metadata' => $metadata,
1215 'message' => __('SEO metadata generated successfully!', 'thinkrank'),
1216 ]);
1217 } catch (\Exception $e) {
1218 wp_send_json_error([
1219 'message' => $e->getMessage(),
1220 ]);
1221 }
1222 }
1223
1224 /**
1225 * AJAX: persist the full set of metabox fields for a post.
1226 *
1227 * Used by editors that don't submit the #post form (Elementor). Expects the
1228 * same field names the classic/block metabox submits, sent as POST params.
1229 * $_POST is slashed by WordPress, matching what `persist_metadata()` expects.
1230 *
1231 * @return void
1232 */
1233 public function ajax_save_metabox(): void {
1234 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1235 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
1236 wp_send_json_error(['message' => __('Security check failed', 'thinkrank')], 403);
1237 }
1238
1239 $post_id = absint($_POST['post_id'] ?? 0);
1240 if (!$post_id || !current_user_can('edit_post', $post_id)) {
1241 wp_send_json_error(['message' => __('Insufficient permissions', 'thinkrank')], 403);
1242 }
1243
1244 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; each field sanitized inside persist_metadata()
1245 $this->persist_metadata($post_id, wp_unslash($_POST));
1246
1247 wp_send_json_success([
1248 'message' => __('SEO settings saved successfully!', 'thinkrank'),
1249 ]);
1250 }
1251 }
1252