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

1,329 lines 52.7 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 //
576 // Both fields may hold variable tags, so they are sanitized as templates:
577 // sanitize_text_field()/sanitize_textarea_field() strip %date% and
578 // %category% as percent-encoding and store "te%" / "tegory%" (#521).
579 $this->persist_seo_text_field($post_id, $src, 'thinkrank_seo_title', '_thinkrank_seo_title', [Pattern_Resolver::class, 'sanitize_template']);
580 $this->persist_seo_text_field($post_id, $src, 'thinkrank_meta_description', '_thinkrank_meta_description', [Pattern_Resolver::class, 'sanitize_template_textarea']);
581
582 $fields = [
583 'thinkrank_seo_score' => 'absint',
584 'thinkrank_generated_at' => 'sanitize_text_field',
585 'thinkrank_pillar_content' => 'sanitize_text_field',
586 ];
587
588 // Focus keywords: prefer the JSON array field; fall back to the legacy
589 // single string. Focus_Keywords::save() normalizes (dedupe, drop empty,
590 // cap at MAX) and keeps the legacy single-value meta in sync.
591 if (isset($src['thinkrank_focus_keywords'])) {
592 $raw_keywords = $src['thinkrank_focus_keywords'];
593 $decoded = is_string($raw_keywords) ? json_decode($raw_keywords, true) : $raw_keywords;
594 Focus_Keywords::save($post_id, is_array($decoded) ? $decoded : []);
595 } elseif (isset($src['thinkrank_focus_keyword'])) {
596 Focus_Keywords::save($post_id, $src['thinkrank_focus_keyword']);
597 }
598
599 // Update the post slug (post_name) when the metabox permalink field
600 // was edited. This touches the WP post itself, not post meta.
601 //
602 // The baseline is what the field was RENDERED with. Without it the
603 // guard here was a bare isset(), and the hidden input is always
604 // posted — so a user who edited WordPress's own permalink field in
605 // the Classic Editor had their new slug written by core and then
606 // overwritten by this page-load snapshot (#441).
607 if (isset($src['thinkrank_post_slug'])) {
608 $this->maybe_update_slug(
609 $post_id,
610 (string) $src['thinkrank_post_slug'],
611 isset($src['thinkrank_post_slug_baseline'])
612 ? (string) $src['thinkrank_post_slug_baseline']
613 : null
614 );
615 }
616
617 // Save canonical URL separately with URL sanitization
618 if (isset($src['thinkrank_canonical_url'])) {
619 $canonical_url = esc_url_raw((string) $src['thinkrank_canonical_url']);
620 if (empty($canonical_url)) {
621 delete_post_meta($post_id, '_thinkrank_canonical_url');
622 } else {
623 update_post_meta($post_id, '_thinkrank_canonical_url', $canonical_url);
624 }
625 }
626
627 foreach ($fields as $field => $sanitize_callback) {
628 if (isset($src[$field])) {
629 $value = call_user_func($sanitize_callback, $src[$field]);
630 update_post_meta($post_id, "_{$field}", $value);
631 }
632 }
633
634 $this->save_robots_meta($post_id, $src);
635 $this->save_social_meta($post_id, $src);
636
637 // Update last modified timestamp
638 update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
639 }
640
641 /**
642 * Persist one SEO text field with an out-of-band-write guard.
643 *
644 * Auto AI (on publish) and imports write the SEO title / meta description
645 * directly to post meta. When that happens after an editor
646 * was opened, the editor's hidden input is a stale blank; a normal save
647 * would overwrite the freshly generated value with that blank. This guard
648 * skips the write only when the submitted value is empty AND it was also
649 * empty when the form was rendered (the `<field>__orig` mirror), yet the
650 * stored value is now non-empty — i.e. a background writer won the race.
651 *
652 * A deliberate clear still applies: if the field held a value at render and
653 * is submitted empty, `$orig` is non-empty so the guard does not trigger.
654 * Callers that don't send the `__orig` mirror (e.g. the MCP/Elementor
655 * paths) keep the plain write behavior.
656 *
657 * @param int $post_id Post being saved.
658 * @param array $src Unslashed field map.
659 * @param string $field POST field name (e.g. thinkrank_seo_title).
660 * @param string $meta_key Target post meta key.
661 * @param callable $sanitize Sanitizer applied to the submitted value.
662 * @return void
663 */
664 private function persist_seo_text_field(int $post_id, array $src, string $field, string $meta_key, callable $sanitize): void {
665 if (!isset($src[$field])) {
666 return; // Field absent from this submit → leave the stored value untouched.
667 }
668
669 $submitted = (string) call_user_func($sanitize, (string) $src[$field]);
670
671 if ('' === $submitted && isset($src[$field . '__orig'])) {
672 $orig = (string) $src[$field . '__orig'];
673 $stored = (string) get_post_meta($post_id, $meta_key, true);
674 // Blank now, blank at render, but populated in storage → a background
675 // write landed after this editor loaded; don't clobber it.
676 if ('' === $orig && '' !== $stored) {
677 return;
678 }
679 }
680
681 update_post_meta($post_id, $meta_key, $submitted);
682 }
683
684 /**
685 * Update the post slug (post_name) from the metabox permalink field.
686 *
687 * Runs inside the save_post cycle, so wp_update_post() would recurse — a
688 * static guard prevents re-entry. WordPress applies wp_unique_post_slug(),
689 * so a colliding slug is de-duplicated automatically. Empty input is left
690 * alone (WP keeps/auto-generates the slug); auto-drafts and revisions are
691 * skipped so we don't fight the editor's own slug generation.
692 *
693 * @param int $post_id Post to update.
694 * @param string $raw_slug Desired slug from the metabox.
695 * @return void
696 */
697 private function maybe_update_slug(int $post_id, string $raw_slug, ?string $baseline = null): void {
698 static $updating = false;
699 if ($updating) {
700 return;
701 }
702
703 $post = get_post($post_id);
704 if (!$post || wp_is_post_revision($post_id)) {
705 return;
706 }
707
708 if (in_array($post->post_status, ['auto-draft', 'trash'], true)) {
709 return;
710 }
711
712 $desired = sanitize_title($raw_slug);
713 if ($desired === '') {
714 return;
715 }
716
717 // Unchanged from what the form was rendered with, so the user did not
718 // choose this value — they left it alone. Writing it back would undo
719 // whatever core already saved from WordPress's own permalink field a
720 // moment ago, on the same save_post priority (#441).
721 //
722 // Compared against the BASELINE rather than the current post_name on
723 // purpose: by the time this runs core has already updated post_name,
724 // so that comparison cannot tell a deliberate edit from a stale one.
725 if ($baseline !== null && $desired === sanitize_title($baseline)) {
726 return;
727 }
728
729 if ($desired === $post->post_name) {
730 return;
731 }
732
733 $updating = true;
734 wp_update_post([
735 'ID' => $post_id,
736 'post_name' => $desired,
737 ]);
738 $updating = false;
739 }
740
741 /**
742 * Persist per-post Open Graph and Twitter Card overrides.
743 *
744 * Empty values delete the meta entry so the frontend falls back to the
745 * SEO title / meta description / featured image chain.
746 */
747 private function save_social_meta(int $post_id, array $src): void {
748 $text_fields = [
749 'thinkrank_og_title' => '_thinkrank_og_title',
750 'thinkrank_og_description' => '_thinkrank_og_description',
751 'thinkrank_twitter_title' => '_thinkrank_twitter_title',
752 'thinkrank_twitter_description' => '_thinkrank_twitter_description',
753 ];
754 foreach ($text_fields as $field => $meta_key) {
755 if (!isset($src[$field])) {
756 continue;
757 }
758 // Template fields: the frontend resolves their variable tags, so the
759 // %tokens% have to survive the save (#521).
760 $value = Pattern_Resolver::sanitize_template_textarea((string) $src[$field]);
761 if ($value === '') {
762 delete_post_meta($post_id, $meta_key);
763 } else {
764 update_post_meta($post_id, $meta_key, $value);
765 }
766 }
767
768 $url_fields = [
769 'thinkrank_og_image' => '_thinkrank_og_image',
770 'thinkrank_twitter_image' => '_thinkrank_twitter_image',
771 ];
772 foreach ($url_fields as $field => $meta_key) {
773 if (!isset($src[$field])) {
774 continue;
775 }
776 $value = esc_url_raw((string) $src[$field]);
777 if ($value === '') {
778 delete_post_meta($post_id, $meta_key);
779 } else {
780 update_post_meta($post_id, $meta_key, $value);
781 }
782 }
783 }
784
785 /**
786 * Save per-post robots meta and advanced robots meta from the metabox.
787 *
788 * Stores the robots payloads as JSON-encoded strings, sanitized via
789 * sanitize_json_meta_field. The toggle plus the two JSON blobs are the
790 * single source of truth for per-post robots overrides.
791 */
792 private function save_robots_meta(int $post_id, array $src): void {
793 if (!isset($src['thinkrank_robots_meta_enabled'])) {
794 return;
795 }
796
797 $enabled = (int) (bool) $src['thinkrank_robots_meta_enabled'];
798 update_post_meta($post_id, '_thinkrank_robots_meta_enabled', $enabled);
799
800 if (isset($src['thinkrank_robots_meta'])) {
801 update_post_meta($post_id, '_thinkrank_robots_meta', $this->sanitize_json_meta_field((string) $src['thinkrank_robots_meta']));
802 }
803
804 if (isset($src['thinkrank_advanced_robots_meta'])) {
805 update_post_meta($post_id, '_thinkrank_advanced_robots_meta', $this->sanitize_json_meta_field((string) $src['thinkrank_advanced_robots_meta']));
806 }
807 }
808
809
810 /**
811 * Enqueue meta box scripts
812 *
813 * @param string $hook Current admin page hook
814 * @return void
815 */
816 public function enqueue_metabox_scripts(string $hook): void {
817 // Only load on post edit screens (including block editor)
818 if (!in_array($hook, ['post.php', 'post-new.php'], true)) {
819 return;
820 }
821
822 // Get current post type - handle both classic and block editor contexts
823 $current_post_type = $this->get_current_post_type();
824 if (!$current_post_type || !in_array($current_post_type, $this->get_supported_post_types(), true)) {
825 return;
826 }
827
828 // Get post object for additional data
829 global $post;
830
831 // Ensure wp.media is available for the social-image media picker.
832 wp_enqueue_media();
833
834 // No chunk dependencies needed - all bundled into main metabox.js
835 // Enqueue React metabox script with direct dependencies
836 $asset_file = THINKRANK_PLUGIN_DIR . 'assets/metabox.asset.php';
837 $asset = file_exists($asset_file) ? include $asset_file : [
838 'dependencies' => ['react', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-components'],
839 'version' => THINKRANK_VERSION
840 ];
841
842 // Use dependencies directly from the asset file. The pinned "Configure
843 // SEO" launcher needs wp-plugins (already listed by the build) and
844 // resolves PinnedItems from wp.editor / wp.interface at runtime, so we do
845 // NOT add wp-interface here: it is not a registered script handle on
846 // WP 7.x, and an unmet dependency would drop the whole metabox script.
847 $dependencies = $asset['dependencies'];
848
849 wp_enqueue_script(
850 'thinkrank-metabox',
851 THINKRANK_PLUGIN_URL . 'assets/metabox.js',
852 $dependencies,
853 $asset['version'],
854 true
855 );
856
857 // Localize script data (shared builder; reused by the Elementor editor
858 // integration, which has no #post form / hidden inputs of its own).
859 wp_localize_script('thinkrank-metabox', 'thinkrankMetabox', $this->get_localized_data($post->ID));
860
861 // Add defer attribute for non-blocking script loading
862 wp_script_add_data('thinkrank-metabox', 'defer', true);
863
864 // Enqueue metabox styles
865 wp_enqueue_style(
866 'thinkrank-metabox',
867 THINKRANK_PLUGIN_URL . 'assets/metabox.css',
868 ['wp-components'],
869 THINKRANK_VERSION
870 );
871
872 // Classic Editor sidebar widget: lightweight styles + a vanilla-JS
873 // handler so the "Optimize Here" button works without depending on the
874 // React bundle. The box itself is only registered in the Classic Editor.
875 $sidebar_css = <<<'CSS'
876 .thinkrank-classic-sidebar-box-title{margin:0 0 12px;font-size:13px;line-height:1.5;color:#1e1e1e;}
877 .thinkrank-classic-sidebar-box-cta{width:100%;text-align:center;justify-content:center;}
878 CSS;
879 wp_add_inline_style('thinkrank-metabox', $sidebar_css);
880
881 $sidebar_js = <<<'JS'
882 (function(){
883 document.addEventListener('click', function(e){
884 var btn = e.target.closest && e.target.closest('.thinkrank-classic-sidebar-box-cta');
885 if(!btn){return;}
886 e.preventDefault();
887 // Open the ThinkRank SEO drawer mounted by the React metabox app.
888 window.dispatchEvent(new CustomEvent('thinkrank:toggle-seo-drawer'));
889 });
890 })();
891 JS;
892 wp_add_inline_script('thinkrank-metabox', $sidebar_js);
893 }
894
895 /**
896 * Build the data object localized into the metabox script (`thinkrankMetabox`).
897 *
898 * Extracted so the Elementor editor integration can reuse the exact same
899 * configuration. Callers that run outside the #post form (Elementor) also
900 * read `existingMetadata`/`contentPreview`, which they add on top of this.
901 *
902 * @param int $post_id Post being edited.
903 * @return array Localized config consumed by the React metabox.
904 */
905 public function get_localized_data(int $post_id): array {
906 $post = get_post($post_id);
907 $post_type = $post ? $post->post_type : 'post';
908
909 // Resolve the correct REST API base for the current post type.
910 // Falls back to the post type slug for any type without a rest_base.
911 $post_type_obj = get_post_type_object($post_type);
912 $post_rest_base = ($post_type_obj && !empty($post_type_obj->rest_base))
913 ? $post_type_obj->rest_base
914 : $post_type;
915
916 return [
917 'ajaxUrl' => admin_url('admin-ajax.php'),
918 'nonce' => wp_create_nonce('thinkrank_metabox_ajax'),
919 'postId' => $post_id,
920 'postType' => $post_type,
921 'postRestBase' => $post_rest_base,
922 'postPermalink' => get_permalink($post_id),
923 'postSlug' => $post ? $post->post_name : '',
924 'restUrl' => rest_url('thinkrank/v1/'),
925 'restNonce' => wp_create_nonce('wp_rest'),
926 'homeUrl' => home_url(),
927 'siteName' => get_bloginfo('name'),
928 'faviconUrl' => $this->get_site_favicon_url(),
929 'featuredImageUrl' => $this->get_post_featured_image_url($post_id),
930 'strings' => [
931 'generating' => __('Generating...', 'thinkrank'),
932 'analyzing' => __('Analyzing...', 'thinkrank'),
933 'error' => __('Error occurred', 'thinkrank'),
934 'success' => __('Success!', 'thinkrank'),
935 'generated' => __('Metadata generated successfully', 'thinkrank'),
936 'contentTooShort' => __('Please add some content before generating SEO metadata.', 'thinkrank'),
937 'apiError' => __('Failed to connect to AI service. Please check your API settings.', 'thinkrank'),
938 ],
939 'seoScore' => $this->get_persisted_seo_score($post_id),
940 'postModified' => $post ? get_the_modified_date('c', $post) : '',
941 'linkSuggestionsEnabled' => $this->is_link_suggestions_enabled($post_type),
942 'postStatus' => get_post_status($post_id),
943 'isPro' => Plan_Config::is_pro(),
944 /**
945 * Filter the editor SEO panel's post-load refresh behaviour.
946 *
947 * The panel re-checks `/metadata/{id}` after load so values written
948 * by a background writer (Auto AI on publish, imports) appear
949 * without a reload. It only polls while the server
950 * reports a write in flight, but the poll lives in JavaScript, so
951 * the switch has to be localized into the bundle rather than being
952 * a PHP-side filter alone (#329).
953 *
954 * Set `enabled` to false to switch the refresh off entirely.
955 *
956 * @since 1.30.0
957 *
958 * @param array $config enabled (bool), intervalMs (int), maxTicks (int).
959 * @param int $post_id Post being edited.
960 */
961 'seoRefresh' => apply_filters(
962 'thinkrank_metabox_seo_refresh',
963 [
964 'enabled' => true,
965 'intervalMs' => 4000,
966 'maxTicks' => 10,
967 ],
968 $post_id
969 ),
970 // Whether any AI provider API key is configured — gates the
971 // "Generate with AI" button in the metabox
972 'aiConfigured' => !empty($this->settings->get('openai_api_key', ''))
973 || !empty($this->settings->get('claude_api_key', ''))
974 || !empty($this->settings->get('gemini_api_key', ''))
975 || !empty($this->settings->get('openrouter_api_key', '')),
976 // Focus keywords plan limits (max_keywords; 0 = unlimited).
977 'focusKeywords' => Plan_Config::focus_keywords(),
978 // Resolved Global/Bulk SEO variable-tag patterns for this post, shown
979 // as placeholder previews when a field is empty (the frontend applies
980 // these same patterns on output). Typing a value overrides them.
981 'patternPreviews' => Pattern_Resolver::previews($post_id),
982 // Token => value map (keys without %), for live client-side preview of
983 // a custom pattern typed into a metabox field.
984 'patternVariables' => Pattern_Resolver::variables($post_id),
985 ];
986 }
987
988 /**
989 * Latest persisted SEO score for a post.
990 *
991 * Read from the scores table — the same source the posts list column and the
992 * Analysis panel use — so the editor badge agrees with them on load rather
993 * than showing 0 until the Analysis panel mounts and fetches the score.
994 *
995 * The `thinkrank_seo_score` form field is unusable for this: it is only
996 * written by the AI "Analyze Content" flow and is reset to 0 on every save.
997 *
998 * @param int $post_id Post ID
999 * @return int|null Score, or null when the post has never been analyzed.
1000 */
1001 private function get_persisted_seo_score(int $post_id): ?int {
1002 $existing = $this->seo_calculator->get_existing_score_data($post_id);
1003 $score = $existing['overall_score'] ?? null;
1004
1005 return $score !== null ? (int) $score : null;
1006 }
1007
1008 /**
1009 * Check if link suggestions are enabled for a post type
1010 *
1011 * @param string $post_type Post type to check
1012 * @return bool True if enabled, false otherwise
1013 */
1014 private function is_link_suggestions_enabled(string $post_type): bool {
1015 $settings = get_option('thinkrank_global_seo_settings', []);
1016
1017 if (isset($settings[$post_type]['link_suggestions'])) {
1018 return (bool) $settings[$post_type]['link_suggestions'];
1019 }
1020
1021 return true;
1022 }
1023
1024 /**
1025 * Get current post type in admin context
1026 *
1027 * Handles both classic editor and block editor contexts
1028 *
1029 * @return string|null Current post type or null if not found
1030 */
1031 private function get_current_post_type(): ?string {
1032 global $post, $typenow, $current_screen;
1033
1034 // Try to get post type from various sources
1035 if ($post && !empty($post->post_type)) {
1036 return $post->post_type;
1037 }
1038
1039 if (!empty($typenow)) {
1040 return $typenow;
1041 }
1042
1043 if ($current_screen && !empty($current_screen->post_type)) {
1044 return $current_screen->post_type;
1045 }
1046
1047 // Fallback: check URL parameters for block editor
1048 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1049 if (isset($_GET['post_type'])) {
1050 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1051 return sanitize_text_field(wp_unslash($_GET['post_type']));
1052 }
1053
1054 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1055 if (isset($_GET['post'])) {
1056 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1057 $post_id = absint($_GET['post']);
1058 $post_type = get_post_type($post_id);
1059 if ($post_type) {
1060 return $post_type;
1061 }
1062 }
1063
1064 return null;
1065 }
1066
1067 /**
1068 * Get supported post types
1069 *
1070 * @return array Supported post types
1071 */
1072 public function get_supported_post_types(): array {
1073 $default_types = ['post', 'page'];
1074
1075 // Add WooCommerce product if available
1076 if (class_exists('WooCommerce')) {
1077 $default_types[] = 'product';
1078 }
1079
1080 // Add other common e-commerce post types
1081 $ecommerce_types = ['product', 'shop_order', 'shop_coupon'];
1082 foreach ($ecommerce_types as $type) {
1083 if (post_type_exists($type) && !in_array($type, $default_types, true)) {
1084 $default_types[] = $type;
1085 }
1086 }
1087
1088 // Add custom post types that are public and have UI
1089 $custom_post_types = get_post_types([
1090 'public' => true,
1091 'show_ui' => true,
1092 '_builtin' => false,
1093 ]);
1094
1095 // WordPress internals that should never carry an SEO metabox. Fixed,
1096 // so it is built once rather than per post type.
1097 $wp_internal_types = [
1098 'attachment',
1099 'revision',
1100 'nav_menu_item',
1101 'custom_css',
1102 'customize_changeset',
1103 'oembed_cache',
1104 'user_request',
1105 'wp_block',
1106 'wp_template',
1107 'wp_template_part',
1108 'wp_global_styles',
1109 'wp_navigation',
1110 'acf-field',
1111 'acf-field-group',
1112 ];
1113
1114 // Builder template CPTs (Bricks, Elementor, Divi, Beaver Builder) are
1115 // layout fragments, not pages with their own SEO. Global SEO already
1116 // refuses them; this list is shared with that policy so the two cannot
1117 // drift apart again (#621).
1118 if (!class_exists('\ThinkRank\SEO\Global_SEO_Post_Types')) {
1119 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-global-seo-post-types.php';
1120 }
1121
1122 foreach ($custom_post_types as $post_type) {
1123 // Resolved per post type, not hoisted: the shared list runs through
1124 // a public filter that receives the post-type object, so an
1125 // integrator can answer differently for different post types.
1126 $excluded_types = array_merge(
1127 $wp_internal_types,
1128 \ThinkRank\SEO\Global_SEO_Post_Types::excluded_post_types(get_post_type_object($post_type))
1129 );
1130
1131 if (!in_array($post_type, $excluded_types, true) && !in_array($post_type, $default_types, true)) {
1132 $default_types[] = $post_type;
1133 }
1134 }
1135
1136 return apply_filters('thinkrank_supported_post_types', $default_types);
1137 }
1138
1139 /**
1140 * Get existing post metadata
1141 *
1142 * @param int $post_id Post ID
1143 * @return array Existing metadata
1144 */
1145 public function get_post_metadata(int $post_id): array {
1146 return [
1147 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
1148 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
1149 'focus_keyword' => Focus_Keywords::get_primary($post_id),
1150 'focus_keywords' => Focus_Keywords::get($post_id),
1151 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
1152 'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1153 'pillar_content' => get_post_meta($post_id, '_thinkrank_pillar_content', true),
1154 'canonical_url' => get_post_meta($post_id, '_thinkrank_canonical_url', true),
1155 'robots_meta_enabled' => get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true),
1156 'robots_meta' => get_post_meta($post_id, '_thinkrank_robots_meta', true),
1157 'advanced_robots_meta' => get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true),
1158 'og_title' => get_post_meta($post_id, '_thinkrank_og_title', true),
1159 'og_description' => get_post_meta($post_id, '_thinkrank_og_description', true),
1160 'og_image' => get_post_meta($post_id, '_thinkrank_og_image', true),
1161 'twitter_title' => get_post_meta($post_id, '_thinkrank_twitter_title', true),
1162 'twitter_description' => get_post_meta($post_id, '_thinkrank_twitter_description', true),
1163 'twitter_image' => get_post_meta($post_id, '_thinkrank_twitter_image', true),
1164 ];
1165 }
1166
1167 /**
1168 * Get site favicon URL
1169 *
1170 * Prefers WordPress' native Site Icon (zero HTTP). Only when no Site Icon is
1171 * configured does it probe common favicon locations — and that probe is
1172 * cached (including a "no favicon" sentinel) so the editor never fires
1173 * blocking loopback HEAD requests on every load.
1174 *
1175 * @since 1.16.2 Prefer get_site_icon_url() and cache the fallback probe.
1176 * @return string Favicon URL, or '' when none can be resolved.
1177 */
1178 private function get_site_favicon_url(): string {
1179 // Try the native Site Icon first (WordPress 4.3+) — no HTTP required.
1180 $site_icon_url = get_site_icon_url();
1181 if ($site_icon_url) {
1182 return $site_icon_url;
1183 }
1184
1185 // No Site Icon configured: fall back to probing common favicon paths.
1186 // Cache the resolved value (empty string included) so the blocking
1187 // loopback probe runs at most once per ~12h per site host instead of on
1188 // every editor load.
1189 $cache_key = 'thinkrank_favicon_url_' . md5((string) wp_parse_url(home_url(), PHP_URL_HOST));
1190 $cached = get_transient($cache_key);
1191 if (false !== $cached) {
1192 return (string) $cached;
1193 }
1194
1195 $favicon_url = '';
1196
1197 $favicon_paths = [
1198 '/favicon.ico',
1199 '/favicon.png',
1200 '/apple-touch-icon.png',
1201 ];
1202
1203 foreach ($favicon_paths as $path) {
1204 $candidate = home_url($path);
1205 $response = wp_remote_head($candidate, ['timeout' => 3]);
1206 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
1207 $favicon_url = $candidate;
1208 break;
1209 }
1210 }
1211
1212 set_transient($cache_key, $favicon_url, 12 * HOUR_IN_SECONDS);
1213
1214 return $favicon_url;
1215 }
1216
1217 /**
1218 * Get post featured image URL
1219 *
1220 * @param int $post_id Post ID
1221 * @return string|null Featured image URL or null if not available
1222 */
1223 private function get_post_featured_image_url(int $post_id): ?string {
1224 $thumbnail_id = get_post_thumbnail_id($post_id);
1225 if ($thumbnail_id) {
1226 // Use the full-size image — social share images need a large source
1227 // (the preview scales it down), not a small thumbnail.
1228 $image_url = wp_get_attachment_image_url($thumbnail_id, 'full');
1229 return $image_url ?: null;
1230 }
1231 return null;
1232 }
1233
1234 /**
1235 * Get content preview for AI analysis
1236 *
1237 * @param \WP_Post $post Post object
1238 * @return string Content preview
1239 */
1240 public function get_content_preview(\WP_Post $post): string {
1241 $content = $post->post_title . "\n\n";
1242
1243 if (!empty($post->post_excerpt)) {
1244 $content .= $post->post_excerpt . "\n\n";
1245 }
1246
1247 // Resolve through Builder_Content: a page builder keeps its words
1248 // outside post_content (Oxygen empties it entirely), and the editor
1249 // cannot reach postmeta, so without this the preview handed to the
1250 // browser is just the title and the live panel reports "No content".
1251 if (!class_exists('\\ThinkRank\\SEO\\Builder_Content')) {
1252 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1253 }
1254 $content .= \ThinkRank\SEO\Builder_Content::resolve($post);
1255
1256 // Clean and limit content
1257 $content = wp_strip_all_tags($content);
1258 $content = preg_replace('/\s+/', ' ', $content);
1259
1260 return trim(substr($content, 0, 4000));
1261 }
1262
1263 /**
1264 * AJAX handler for generating post metadata
1265 *
1266 * @return void
1267 */
1268 public function ajax_generate_post_metadata(): void {
1269 // Verify nonce
1270 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1271 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
1272 wp_die('Security check failed');
1273 }
1274
1275 // Check permissions
1276 $post_id = absint($_POST['post_id'] ?? 0);
1277 if (!current_user_can('edit_post', $post_id)) {
1278 wp_die('Insufficient permissions');
1279 }
1280
1281 try {
1282 $options = [
1283 'target_keyword' => sanitize_text_field(wp_unslash($_POST['target_keyword'] ?? '')),
1284 'content_type' => sanitize_text_field(wp_unslash($_POST['content_type'] ?? 'blog_post')),
1285 'tone' => sanitize_text_field(wp_unslash($_POST['tone'] ?? 'professional')),
1286 ];
1287
1288 $metadata = $this->metadata_generator->generate_for_post($post_id, $options);
1289
1290 wp_send_json_success([
1291 'metadata' => $metadata,
1292 'message' => __('SEO metadata generated successfully!', 'thinkrank'),
1293 ]);
1294 } catch (\Exception $e) {
1295 wp_send_json_error([
1296 'message' => $e->getMessage(),
1297 ]);
1298 }
1299 }
1300
1301 /**
1302 * AJAX: persist the full set of metabox fields for a post.
1303 *
1304 * Used by editors that don't submit the #post form (Elementor). Expects the
1305 * same field names the classic/block metabox submits, sent as POST params.
1306 * $_POST is slashed by WordPress, matching what `persist_metadata()` expects.
1307 *
1308 * @return void
1309 */
1310 public function ajax_save_metabox(): void {
1311 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1312 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
1313 wp_send_json_error(['message' => __('Security check failed', 'thinkrank')], 403);
1314 }
1315
1316 $post_id = absint($_POST['post_id'] ?? 0);
1317 if (!$post_id || !current_user_can('edit_post', $post_id)) {
1318 wp_send_json_error(['message' => __('Insufficient permissions', 'thinkrank')], 403);
1319 }
1320
1321 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; each field sanitized inside persist_metadata()
1322 $this->persist_metadata($post_id, wp_unslash($_POST));
1323
1324 wp_send_json_success([
1325 'message' => __('SEO settings saved successfully!', 'thinkrank'),
1326 ]);
1327 }
1328 }
1329