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

1,278 lines 50.2 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) {
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 ) {
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, 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) and imports write the SEO title / meta description
629 * 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 /**
911 * Filter the editor SEO panel's post-load refresh behaviour.
912 *
913 * The panel re-checks `/metadata/{id}` after load so values written
914 * by a background writer (Auto AI on publish, imports) appear
915 * without a reload. It only polls while the server
916 * reports a write in flight, but the poll lives in JavaScript, so
917 * the switch has to be localized into the bundle rather than being
918 * a PHP-side filter alone (#329).
919 *
920 * Set `enabled` to false to switch the refresh off entirely.
921 *
922 * @since 1.30.0
923 *
924 * @param array $config enabled (bool), intervalMs (int), maxTicks (int).
925 * @param int $post_id Post being edited.
926 */
927 'seoRefresh' => apply_filters(
928 'thinkrank_metabox_seo_refresh',
929 [
930 'enabled' => true,
931 'intervalMs' => 4000,
932 'maxTicks' => 10,
933 ],
934 $post_id
935 ),
936 // Whether any AI provider API key is configured — gates the
937 // "Generate with AI" button in the metabox
938 'aiConfigured' => !empty($this->settings->get('openai_api_key', ''))
939 || !empty($this->settings->get('claude_api_key', ''))
940 || !empty($this->settings->get('gemini_api_key', ''))
941 || !empty($this->settings->get('openrouter_api_key', '')),
942 // Focus keywords plan limits (max_keywords; 0 = unlimited).
943 'focusKeywords' => Plan_Config::focus_keywords(),
944 // Resolved Global/Bulk SEO variable-tag patterns for this post, shown
945 // as placeholder previews when a field is empty (the frontend applies
946 // these same patterns on output). Typing a value overrides them.
947 'patternPreviews' => Pattern_Resolver::previews($post_id),
948 // Token => value map (keys without %), for live client-side preview of
949 // a custom pattern typed into a metabox field.
950 'patternVariables' => Pattern_Resolver::variables($post_id),
951 ];
952 }
953
954 /**
955 * Latest persisted SEO score for a post.
956 *
957 * Read from the scores table — the same source the posts list column and the
958 * Analysis panel use — so the editor badge agrees with them on load rather
959 * than showing 0 until the Analysis panel mounts and fetches the score.
960 *
961 * The `thinkrank_seo_score` form field is unusable for this: it is only
962 * written by the AI "Analyze Content" flow and is reset to 0 on every save.
963 *
964 * @param int $post_id Post ID
965 * @return int|null Score, or null when the post has never been analyzed.
966 */
967 private function get_persisted_seo_score(int $post_id): ?int {
968 $existing = $this->seo_calculator->get_existing_score_data($post_id);
969 $score = $existing['overall_score'] ?? null;
970
971 return $score !== null ? (int) $score : null;
972 }
973
974 /**
975 * Check if link suggestions are enabled for a post type
976 *
977 * @param string $post_type Post type to check
978 * @return bool True if enabled, false otherwise
979 */
980 private function is_link_suggestions_enabled(string $post_type): bool {
981 $settings = get_option('thinkrank_global_seo_settings', []);
982
983 if (isset($settings[$post_type]['link_suggestions'])) {
984 return (bool) $settings[$post_type]['link_suggestions'];
985 }
986
987 return true;
988 }
989
990 /**
991 * Get current post type in admin context
992 *
993 * Handles both classic editor and block editor contexts
994 *
995 * @return string|null Current post type or null if not found
996 */
997 private function get_current_post_type(): ?string {
998 global $post, $typenow, $current_screen;
999
1000 // Try to get post type from various sources
1001 if ($post && !empty($post->post_type)) {
1002 return $post->post_type;
1003 }
1004
1005 if (!empty($typenow)) {
1006 return $typenow;
1007 }
1008
1009 if ($current_screen && !empty($current_screen->post_type)) {
1010 return $current_screen->post_type;
1011 }
1012
1013 // Fallback: check URL parameters for block editor
1014 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1015 if (isset($_GET['post_type'])) {
1016 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1017 return sanitize_text_field(wp_unslash($_GET['post_type']));
1018 }
1019
1020 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1021 if (isset($_GET['post'])) {
1022 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1023 $post_id = absint($_GET['post']);
1024 $post_type = get_post_type($post_id);
1025 if ($post_type) {
1026 return $post_type;
1027 }
1028 }
1029
1030 return null;
1031 }
1032
1033 /**
1034 * Get supported post types
1035 *
1036 * @return array Supported post types
1037 */
1038 public function get_supported_post_types(): array {
1039 $default_types = ['post', 'page'];
1040
1041 // Add WooCommerce product if available
1042 if (class_exists('WooCommerce')) {
1043 $default_types[] = 'product';
1044 }
1045
1046 // Add other common e-commerce post types
1047 $ecommerce_types = ['product', 'shop_order', 'shop_coupon'];
1048 foreach ($ecommerce_types as $type) {
1049 if (post_type_exists($type) && !in_array($type, $default_types, true)) {
1050 $default_types[] = $type;
1051 }
1052 }
1053
1054 // Add custom post types that are public and have UI
1055 $custom_post_types = get_post_types([
1056 'public' => true,
1057 'show_ui' => true,
1058 '_builtin' => false,
1059 ]);
1060
1061 foreach ($custom_post_types as $post_type) {
1062 // Skip certain post types that shouldn't have SEO metabox
1063 $excluded_types = [
1064 'attachment',
1065 'revision',
1066 'nav_menu_item',
1067 'custom_css',
1068 'customize_changeset',
1069 'oembed_cache',
1070 'user_request',
1071 'wp_block',
1072 'wp_template',
1073 'wp_template_part',
1074 'wp_global_styles',
1075 'wp_navigation',
1076 'acf-field',
1077 'acf-field-group',
1078 ];
1079
1080 if (!in_array($post_type, $excluded_types, true) && !in_array($post_type, $default_types, true)) {
1081 $default_types[] = $post_type;
1082 }
1083 }
1084
1085 return apply_filters('thinkrank_supported_post_types', $default_types);
1086 }
1087
1088 /**
1089 * Get existing post metadata
1090 *
1091 * @param int $post_id Post ID
1092 * @return array Existing metadata
1093 */
1094 public function get_post_metadata(int $post_id): array {
1095 return [
1096 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
1097 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
1098 'focus_keyword' => Focus_Keywords::get_primary($post_id),
1099 'focus_keywords' => Focus_Keywords::get($post_id),
1100 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
1101 'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1102 'pillar_content' => get_post_meta($post_id, '_thinkrank_pillar_content', true),
1103 'canonical_url' => get_post_meta($post_id, '_thinkrank_canonical_url', true),
1104 'robots_meta_enabled' => get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true),
1105 'robots_meta' => get_post_meta($post_id, '_thinkrank_robots_meta', true),
1106 'advanced_robots_meta' => get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true),
1107 'og_title' => get_post_meta($post_id, '_thinkrank_og_title', true),
1108 'og_description' => get_post_meta($post_id, '_thinkrank_og_description', true),
1109 'og_image' => get_post_meta($post_id, '_thinkrank_og_image', true),
1110 'twitter_title' => get_post_meta($post_id, '_thinkrank_twitter_title', true),
1111 'twitter_description' => get_post_meta($post_id, '_thinkrank_twitter_description', true),
1112 'twitter_image' => get_post_meta($post_id, '_thinkrank_twitter_image', true),
1113 ];
1114 }
1115
1116 /**
1117 * Get site favicon URL
1118 *
1119 * Prefers WordPress' native Site Icon (zero HTTP). Only when no Site Icon is
1120 * configured does it probe common favicon locations — and that probe is
1121 * cached (including a "no favicon" sentinel) so the editor never fires
1122 * blocking loopback HEAD requests on every load.
1123 *
1124 * @since 1.16.2 Prefer get_site_icon_url() and cache the fallback probe.
1125 * @return string Favicon URL, or '' when none can be resolved.
1126 */
1127 private function get_site_favicon_url(): string {
1128 // Try the native Site Icon first (WordPress 4.3+) — no HTTP required.
1129 $site_icon_url = get_site_icon_url();
1130 if ($site_icon_url) {
1131 return $site_icon_url;
1132 }
1133
1134 // No Site Icon configured: fall back to probing common favicon paths.
1135 // Cache the resolved value (empty string included) so the blocking
1136 // loopback probe runs at most once per ~12h per site host instead of on
1137 // every editor load.
1138 $cache_key = 'thinkrank_favicon_url_' . md5((string) wp_parse_url(home_url(), PHP_URL_HOST));
1139 $cached = get_transient($cache_key);
1140 if (false !== $cached) {
1141 return (string) $cached;
1142 }
1143
1144 $favicon_url = '';
1145
1146 $favicon_paths = [
1147 '/favicon.ico',
1148 '/favicon.png',
1149 '/apple-touch-icon.png',
1150 ];
1151
1152 foreach ($favicon_paths as $path) {
1153 $candidate = home_url($path);
1154 $response = wp_remote_head($candidate, ['timeout' => 3]);
1155 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
1156 $favicon_url = $candidate;
1157 break;
1158 }
1159 }
1160
1161 set_transient($cache_key, $favicon_url, 12 * HOUR_IN_SECONDS);
1162
1163 return $favicon_url;
1164 }
1165
1166 /**
1167 * Get post featured image URL
1168 *
1169 * @param int $post_id Post ID
1170 * @return string|null Featured image URL or null if not available
1171 */
1172 private function get_post_featured_image_url(int $post_id): ?string {
1173 $thumbnail_id = get_post_thumbnail_id($post_id);
1174 if ($thumbnail_id) {
1175 // Use the full-size image — social share images need a large source
1176 // (the preview scales it down), not a small thumbnail.
1177 $image_url = wp_get_attachment_image_url($thumbnail_id, 'full');
1178 return $image_url ?: null;
1179 }
1180 return null;
1181 }
1182
1183 /**
1184 * Get content preview for AI analysis
1185 *
1186 * @param \WP_Post $post Post object
1187 * @return string Content preview
1188 */
1189 public function get_content_preview(\WP_Post $post): string {
1190 $content = $post->post_title . "\n\n";
1191
1192 if (!empty($post->post_excerpt)) {
1193 $content .= $post->post_excerpt . "\n\n";
1194 }
1195
1196 // Resolve through Builder_Content: a page builder keeps its words
1197 // outside post_content (Oxygen empties it entirely), and the editor
1198 // cannot reach postmeta, so without this the preview handed to the
1199 // browser is just the title and the live panel reports "No content".
1200 if (!class_exists('\\ThinkRank\\SEO\\Builder_Content')) {
1201 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1202 }
1203 $content .= \ThinkRank\SEO\Builder_Content::resolve($post);
1204
1205 // Clean and limit content
1206 $content = wp_strip_all_tags($content);
1207 $content = preg_replace('/\s+/', ' ', $content);
1208
1209 return trim(substr($content, 0, 4000));
1210 }
1211
1212 /**
1213 * AJAX handler for generating post metadata
1214 *
1215 * @return void
1216 */
1217 public function ajax_generate_post_metadata(): void {
1218 // Verify nonce
1219 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1220 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
1221 wp_die('Security check failed');
1222 }
1223
1224 // Check permissions
1225 $post_id = absint($_POST['post_id'] ?? 0);
1226 if (!current_user_can('edit_post', $post_id)) {
1227 wp_die('Insufficient permissions');
1228 }
1229
1230 try {
1231 $options = [
1232 'target_keyword' => sanitize_text_field(wp_unslash($_POST['target_keyword'] ?? '')),
1233 'content_type' => sanitize_text_field(wp_unslash($_POST['content_type'] ?? 'blog_post')),
1234 'tone' => sanitize_text_field(wp_unslash($_POST['tone'] ?? 'professional')),
1235 ];
1236
1237 $metadata = $this->metadata_generator->generate_for_post($post_id, $options);
1238
1239 wp_send_json_success([
1240 'metadata' => $metadata,
1241 'message' => __('SEO metadata generated successfully!', 'thinkrank'),
1242 ]);
1243 } catch (\Exception $e) {
1244 wp_send_json_error([
1245 'message' => $e->getMessage(),
1246 ]);
1247 }
1248 }
1249
1250 /**
1251 * AJAX: persist the full set of metabox fields for a post.
1252 *
1253 * Used by editors that don't submit the #post form (Elementor). Expects the
1254 * same field names the classic/block metabox submits, sent as POST params.
1255 * $_POST is slashed by WordPress, matching what `persist_metadata()` expects.
1256 *
1257 * @return void
1258 */
1259 public function ajax_save_metabox(): void {
1260 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1261 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
1262 wp_send_json_error(['message' => __('Security check failed', 'thinkrank')], 403);
1263 }
1264
1265 $post_id = absint($_POST['post_id'] ?? 0);
1266 if (!$post_id || !current_user_can('edit_post', $post_id)) {
1267 wp_send_json_error(['message' => __('Insufficient permissions', 'thinkrank')], 403);
1268 }
1269
1270 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; each field sanitized inside persist_metadata()
1271 $this->persist_metadata($post_id, wp_unslash($_POST));
1272
1273 wp_send_json_success([
1274 'message' => __('SEO settings saved successfully!', 'thinkrank'),
1275 ]);
1276 }
1277 }
1278