PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 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.10.0, at includes/admin/class-metabox-manager.php

1,673 lines 66.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\Object_Redirect;
23 use ThinkRank\SEO\Pattern_Resolver;
24
25 // Prevent direct access
26 if (!defined('ABSPATH')) {
27 exit;
28 }
29
30 /**
31 * Meta Box Manager Class
32 *
33 * Single Responsibility: Manage post/page meta boxes
34 *
35 * @since 1.0.0
36 */
37 class Metabox_Manager {
38
39 /**
40 * Settings instance
41 *
42 * @var Settings
43 */
44 private Settings $settings;
45
46 /**
47 * Metadata generator instance
48 *
49 * @var Metadata_Generator
50 */
51 private Metadata_Generator $metadata_generator;
52
53 /**
54 * SEO Score Calculator instance
55 *
56 * @var SEOScoreCalculator
57 */
58 private SEOScoreCalculator $seo_calculator;
59
60 /**
61 * Constructor
62 *
63 * @param Settings|null $settings Settings instance
64 * @param Metadata_Generator|null $metadata_generator Metadata generator instance
65 * @param SEOScoreCalculator|null $seo_calculator SEO Score Calculator instance
66 */
67 public function __construct(?Settings $settings = null, ?Metadata_Generator $metadata_generator = null, ?SEOScoreCalculator $seo_calculator = null) {
68 $this->settings = $settings ?? Settings::instance();
69 $this->metadata_generator = $metadata_generator ?? new Metadata_Generator();
70 $this->seo_calculator = $seo_calculator ?? new SEOScoreCalculator(new Database());
71 }
72
73 /**
74 * Initialize meta box manager
75 *
76 * @return void
77 */
78 public function init(): void {
79 add_action('add_meta_boxes', [$this, 'add_meta_boxes']);
80 add_action('save_post', [$this, 'save_meta_boxes'], 10, 2);
81 add_action('admin_enqueue_scripts', [$this, 'enqueue_metabox_scripts']);
82 add_action('admin_notices', [$this, 'render_redirect_notice']);
83 add_action('init', [$this, 'register_meta_fields']);
84
85 // AJAX handlers for meta box functionality
86 add_action('wp_ajax_thinkrank_generate_post_metadata', [$this, 'ajax_generate_post_metadata']);
87
88 // Full metabox save used by editors that don't submit the #post form
89 // (e.g. the Elementor editor). Persists every metabox field at once.
90 add_action('wp_ajax_thinkrank_save_metabox', [$this, 'ajax_save_metabox']);
91
92 // Removed debug hooks
93 }
94
95 /**
96 * Register meta fields for REST API access
97 *
98 * @return void
99 */
100 public function register_meta_fields(): void {
101 // Register schema form data meta fields
102 register_post_meta('', '_thinkrank_schema_form_data', [
103 'show_in_rest' => true,
104 'single' => true,
105 'type' => 'string',
106 'sanitize_callback' => [$this, 'sanitize_json_meta_field'],
107 'auth_callback' => function () {
108 return current_user_can('edit_posts') || current_user_can('edit_pages');
109 }
110 ]);
111
112 register_post_meta('', '_thinkrank_selected_schema_type', [
113 'show_in_rest' => true,
114 'single' => true,
115 'type' => 'string',
116 'auth_callback' => function () {
117 return current_user_can('edit_posts') || current_user_can('edit_pages');
118 }
119 ]);
120
121 register_post_meta('', '_thinkrank_additional_schemas', [
122 'show_in_rest' => true,
123 'single' => true,
124 'type' => 'string',
125 'sanitize_callback' => [$this, 'sanitize_json_ld_field'],
126 'auth_callback' => function() {
127 return current_user_can('edit_posts') || current_user_can('edit_pages');
128 }
129 ]);
130
131 // SEO meta fields for import support
132 $string_meta_fields = [
133 '_thinkrank_canonical_url',
134 '_thinkrank_og_title',
135 '_thinkrank_og_description',
136 '_thinkrank_og_image',
137 '_thinkrank_twitter_title',
138 '_thinkrank_twitter_description',
139 '_thinkrank_twitter_image',
140 '_thinkrank_imported_from',
141 ];
142
143 foreach ($string_meta_fields as $meta_key) {
144 register_post_meta('', $meta_key, [
145 'show_in_rest' => true,
146 'single' => true,
147 'type' => 'string',
148 'auth_callback' => function () {
149 return current_user_can('edit_posts') || current_user_can('edit_pages');
150 }
151 ]);
152 }
153
154 // Multiple focus keywords (array). The legacy single-value
155 // `_thinkrank_focus_keyword` is kept in sync by Focus_Keywords for
156 // backward compatibility and registered for REST as a string elsewhere.
157 register_post_meta('', Focus_Keywords::META_KEY, [
158 'show_in_rest' => [
159 'schema' => [
160 'type' => 'array',
161 'items' => ['type' => 'string'],
162 ],
163 ],
164 'single' => true,
165 'type' => 'array',
166 'sanitize_callback' => function ($value) {
167 return Focus_Keywords::normalize($value);
168 },
169 'auth_callback' => function () {
170 return current_user_can('edit_posts') || current_user_can('edit_pages');
171 }
172 ]);
173
174 register_post_meta('', Focus_Keywords::LEGACY_META_KEY, [
175 'show_in_rest' => true,
176 'single' => true,
177 'type' => 'string',
178 'auth_callback' => function () {
179 return current_user_can('edit_posts') || current_user_can('edit_pages');
180 }
181 ]);
182
183 register_post_meta('', '_thinkrank_robots_meta_enabled', [
184 'show_in_rest' => true,
185 'single' => true,
186 'type' => 'integer',
187 'auth_callback' => function () {
188 return current_user_can('edit_posts') || current_user_can('edit_pages');
189 }
190 ]);
191
192 register_post_meta('', '_thinkrank_robots_meta', [
193 'show_in_rest' => true,
194 'single' => true,
195 'type' => 'string',
196 'sanitize_callback' => [$this, 'sanitize_json_meta_field'],
197 'auth_callback' => function () {
198 return current_user_can('edit_posts') || current_user_can('edit_pages');
199 }
200 ]);
201
202 register_post_meta('', '_thinkrank_advanced_robots_meta', [
203 'show_in_rest' => true,
204 'single' => true,
205 'type' => 'string',
206 'sanitize_callback' => [$this, 'sanitize_json_meta_field'],
207 'auth_callback' => function () {
208 return current_user_can('edit_posts') || current_user_can('edit_pages');
209 }
210 ]);
211
212 register_post_meta('', \ThinkRank\SEO\Content_Visibility::SEARCH_META, [
213 'show_in_rest' => true,
214 'single' => true,
215 'type' => 'integer',
216 'auth_callback' => function () {
217 return current_user_can('edit_posts') || current_user_can('edit_pages');
218 }
219 ]);
220
221 register_post_meta('', \ThinkRank\SEO\Content_Visibility::ARCHIVE_META, [
222 'show_in_rest' => true,
223 'single' => true,
224 'type' => 'integer',
225 'auth_callback' => function () {
226 return current_user_can('edit_posts') || current_user_can('edit_pages');
227 }
228 ]);
229
230 register_post_meta('', '_thinkrank_primary_category', [
231 'show_in_rest' => true,
232 'single' => true,
233 'type' => 'integer',
234 'auth_callback' => function () {
235 return current_user_can('edit_posts') || current_user_can('edit_pages');
236 }
237 ]);
238 }
239
240 /**
241 * Sanitize JSON meta field data
242 *
243 * Validates JSON structure and recursively sanitizes all string values
244 * to prevent XSS and injection attacks.
245 *
246 * @param string $value Raw JSON string value
247 * @return string Sanitized JSON string or empty string if invalid
248 */
249 public function sanitize_json_meta_field(string $value): string {
250 // Return empty string for non-string values
251 if (!is_string($value) || empty($value)) {
252 return '';
253 }
254
255 // Validate JSON structure
256 $decoded = json_decode($value, true);
257 if (json_last_error() !== JSON_ERROR_NONE) {
258 // Invalid JSON - return empty string
259 return '';
260 }
261
262 // Check for reasonable data size (prevent JSON bombs)
263 if (strlen($value) > 50000) { // 50KB limit
264 return '';
265 }
266
267 // Recursively sanitize all values
268 $sanitized = $this->sanitize_json_recursively($decoded);
269
270 // Re-encode as JSON
271 $result = wp_json_encode($sanitized);
272 return $result !== false ? $result : '';
273 }
274
275 /**
276 * Recursively sanitize JSON data
277 *
278 * @param mixed $data Data to sanitize
279 * @param int $depth Current recursion depth
280 * @return mixed Sanitized data
281 */
282 private function sanitize_json_recursively($data, int $depth = 0) {
283 // Prevent deep recursion attacks
284 if ($depth > 10) {
285 return null;
286 }
287
288 if (is_array($data)) {
289 $sanitized = [];
290 foreach ($data as $key => $value) {
291 $clean_key = sanitize_key($key);
292 $sanitized[$clean_key] = $this->sanitize_json_recursively($value, $depth + 1);
293 }
294 return $sanitized;
295 }
296
297 if (is_string($data)) {
298 // Sanitize string data to prevent XSS
299 return sanitize_textarea_field($data);
300 }
301
302 if (is_numeric($data)) {
303 return $data;
304 }
305
306 if (is_bool($data)) {
307 return $data;
308 }
309
310 // For any other data type, return null
311 return null;
312 }
313
314 /**
315 * Sanitize JSON-LD meta field data
316 *
317 * Validates JSON structure, recursively sanitizes all string values,
318 * and preserves @ characters in keys (crucial for JSON-LD).
319 *
320 * @param string $value Raw JSON string value
321 *
322 * @return string Sanitized JSON string or empty string if invalid
323 */
324 public function sanitize_json_ld_field( string $value ): string {
325 // Return empty string for non-string values
326 if ( ! is_string( $value ) || empty( $value ) ) {
327 return '';
328 }
329
330 // Validate JSON structure
331 $decoded = json_decode( $value, true );
332 if ( json_last_error() !== JSON_ERROR_NONE ) {
333 // Invalid JSON - return empty string
334 return '';
335 }
336
337 // Check for reasonable data size (prevent JSON bombs)
338 if ( strlen( $value ) > 200000 ) { // Limit to 200KB for larger schemas
339 return '';
340 }
341
342 // Recursively sanitize all values
343 $sanitized = $this->sanitize_json_ld_recursively( $decoded );
344
345 // Re-encode as JSON
346 $result = wp_json_encode( $sanitized );
347
348 return $result !== false ? $result : '';
349 }
350
351 /**
352 * Recursively sanitize JSON-LD data
353 *
354 * Similar to sanitize_json_recursively but preserves @ symbol and case in keys.
355 *
356 * @param mixed $data Data to sanitize
357 * @param int $depth Current recursion depth
358 *
359 * @return mixed Sanitized data
360 */
361 private function sanitize_json_ld_recursively( $data, int $depth = 0 ) {
362 // Prevent deep recursion attacks
363 if ( $depth > 10 ) {
364 return null;
365 }
366
367 if ( is_array( $data ) ) {
368 $sanitized = [];
369 foreach ( $data as $key => $value ) {
370 // Allow alphanumeric, underscore, dash, and @ (crucial for JSON-LD)
371 // Also preserve case as JSON-LD keys are case-sensitive
372 $clean_key = preg_replace( '/[^a-zA-Z0-9_\-@]/', '', (string) $key );
373 $sanitized[ $clean_key ] = $this->sanitize_json_ld_recursively( $value, $depth + 1 );
374 }
375
376 return $sanitized;
377 }
378
379 if ( is_string( $data ) ) {
380 // Sanitize string data to prevent XSS
381 return sanitize_textarea_field( $data );
382 }
383
384 if ( is_numeric( $data ) ) {
385 return $data;
386 }
387
388 if ( is_bool( $data ) ) {
389 return $data;
390 }
391
392 // For any other data type, return null
393 return null;
394 }
395
396 // Removed debug methods
397
398 /**
399 * Add ThinkRank meta boxes
400 *
401 * @return void
402 */
403 public function add_meta_boxes(): void {
404 $post_types = $this->get_supported_post_types();
405
406 $is_block_editor = $this->is_block_editor_screen();
407
408 foreach ($post_types as $post_type) {
409 add_meta_box(
410 'thinkrank-seo-metabox',
411 __('ThinkRank SEO', 'thinkrank'),
412 [$this, 'render_seo_metabox'],
413 $post_type,
414 'normal',
415 'high'
416 );
417
418 // Classic Editor sidebar quick-access widget (mirrors SureRank's
419 // "Manage your SEO" sidebar box). Skipped in the Block Editor, which
420 // surfaces the full panel below the content instead.
421 if (!$is_block_editor) {
422 add_meta_box(
423 'thinkrank-seo-sidebar',
424 __('ThinkRank', 'thinkrank'),
425 [$this, 'render_sidebar_meta_box'],
426 $post_type,
427 'side',
428 'high'
429 );
430 }
431 }
432 }
433
434 /**
435 * Whether the current edit screen is using the Block Editor.
436 *
437 * @return bool True when the Block Editor is active, false for Classic Editor.
438 */
439 private function is_block_editor_screen(): bool {
440 if (!function_exists('get_current_screen')) {
441 return false;
442 }
443
444 $screen = get_current_screen();
445
446 return $screen instanceof \WP_Screen
447 && method_exists($screen, 'is_block_editor')
448 && $screen->is_block_editor();
449 }
450
451 /**
452 * Render the Classic Editor sidebar quick-access widget.
453 *
454 * Shows a short label plus a button that scrolls to (and expands) the full
455 * "ThinkRank SEO" panel in the main column.
456 *
457 * @param \WP_Post $post Post object.
458 * @return void
459 */
460 public function render_sidebar_meta_box(\WP_Post $post): void {
461 $box_title = apply_filters('thinkrank_seo_sidebar_box_title', __('Optimize this content for search & AI with ThinkRank.', 'thinkrank'), $post);
462 $cta_label = apply_filters('thinkrank_seo_sidebar_cta_label', __('Open ThinkRank SEO', 'thinkrank'), $post);
463 ?>
464 <div class="thinkrank-classic-sidebar-box">
465 <p class="thinkrank-classic-sidebar-box-title"><?php echo esc_html($box_title); ?></p>
466 <button
467 type="button"
468 class="button button-primary thinkrank-classic-sidebar-box-cta"
469 >
470 <?php echo esc_html($cta_label); ?>
471 </button>
472 </div>
473 <?php
474 }
475
476 /**
477 * Render SEO meta box
478 *
479 * @param \WP_Post $post Post object
480 * @return void
481 */
482 public function render_seo_metabox(\WP_Post $post): void {
483 // Add nonce for security
484 wp_nonce_field('thinkrank_metabox_nonce', 'thinkrank_metabox_nonce');
485
486 // Get existing metadata
487 $existing_metadata = $this->get_post_metadata($post->ID);
488
489 // Get post content for AI analysis
490 $content_preview = $this->get_content_preview($post);
491
492 // Render React metabox container with hidden form fields for data
493 ?>
494 <div id="thinkrank-metabox-container" class="thinkrank-metabox">
495
496 <!-- Hidden form fields for React to read initial data -->
497 <input type="hidden" id="thinkrank_seo_title" name="thinkrank_seo_title" value="<?php echo esc_attr($existing_metadata['title'] ?? ''); ?>" />
498 <input type="hidden" id="thinkrank_meta_description" name="thinkrank_meta_description" value="<?php echo esc_attr($existing_metadata['description'] ?? ''); ?>" />
499 <?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. ?>
500 <input type="hidden" id="thinkrank_seo_title__orig" name="thinkrank_seo_title__orig" value="<?php echo esc_attr($existing_metadata['title'] ?? ''); ?>" />
501 <input type="hidden" id="thinkrank_meta_description__orig" name="thinkrank_meta_description__orig" value="<?php echo esc_attr($existing_metadata['description'] ?? ''); ?>" />
502 <input type="hidden" id="thinkrank_focus_keyword" name="thinkrank_focus_keyword" value="<?php echo esc_attr($existing_metadata['focus_keyword'] ?? ''); ?>" />
503 <input type="hidden" id="thinkrank_focus_keywords" name="thinkrank_focus_keywords" value="<?php echo esc_attr(wp_json_encode($existing_metadata['focus_keywords'] ?? [])); ?>" />
504 <input type="hidden" id="thinkrank_seo_score" name="thinkrank_seo_score" value="<?php echo esc_attr($existing_metadata['seo_score'] ?? '0'); ?>" />
505 <input type="hidden" id="thinkrank_generated_at" name="thinkrank_generated_at" value="<?php echo esc_attr($existing_metadata['generated_at'] ?? ''); ?>" />
506 <input type="hidden" id="thinkrank_pillar_content" name="thinkrank_pillar_content" value="<?php echo esc_attr($existing_metadata['pillar_content'] ?? ''); ?>" />
507 <input type="hidden" id="thinkrank_exclude_from_search" name="thinkrank_exclude_from_search" value="<?php echo esc_attr((string) ($existing_metadata['exclude_from_search'] ?? '')); ?>" />
508 <input type="hidden" id="thinkrank_exclude_from_archives" name="thinkrank_exclude_from_archives" value="<?php echo esc_attr((string) ($existing_metadata['exclude_from_archives'] ?? '')); ?>" />
509 <input type="hidden" id="thinkrank_canonical_url" name="thinkrank_canonical_url" value="<?php echo esc_url($existing_metadata['canonical_url'] ?? ''); ?>" />
510 <?php
511 // The redirect lives in Pro's rules table, not post meta, so nothing
512 // else hands it to the React app. Without these the field loads
513 // empty, and its own hidden input then posts that empty value on the
514 // next save, which Object_Redirect reads as "remove the redirect".
515 // Rendered only when a provider can store it, matching MetaboxApp.
516 if (Object_Redirect::is_supported()) :
517 ?>
518 <input type="hidden" id="thinkrank_redirect_url" name="thinkrank_redirect_url" value="<?php echo esc_attr((string) ($existing_metadata['redirect_url'] ?? '')); ?>" />
519 <input type="hidden" id="thinkrank_redirect_type" name="thinkrank_redirect_type" value="<?php echo esc_attr((string) ($existing_metadata['redirect_type'] ?? Object_Redirect::DEFAULT_TYPE)); ?>" />
520 <?php endif; ?>
521 <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')); ?>" />
522 <input type="hidden" id="thinkrank_robots_meta" name="thinkrank_robots_meta" value="<?php echo esc_attr((string) ($existing_metadata['robots_meta'] ?? '')); ?>" />
523 <input type="hidden" id="thinkrank_advanced_robots_meta" name="thinkrank_advanced_robots_meta" value="<?php echo esc_attr((string) ($existing_metadata['advanced_robots_meta'] ?? '')); ?>" />
524 <input type="hidden" id="thinkrank_og_title" name="thinkrank_og_title" value="<?php echo esc_attr((string) ($existing_metadata['og_title'] ?? '')); ?>" />
525 <input type="hidden" id="thinkrank_og_description" name="thinkrank_og_description" value="<?php echo esc_attr((string) ($existing_metadata['og_description'] ?? '')); ?>" />
526 <input type="hidden" id="thinkrank_og_image" name="thinkrank_og_image" value="<?php echo esc_url((string) ($existing_metadata['og_image'] ?? '')); ?>" />
527 <input type="hidden" id="thinkrank_twitter_title" name="thinkrank_twitter_title" value="<?php echo esc_attr((string) ($existing_metadata['twitter_title'] ?? '')); ?>" />
528 <input type="hidden" id="thinkrank_twitter_description" name="thinkrank_twitter_description" value="<?php echo esc_attr((string) ($existing_metadata['twitter_description'] ?? '')); ?>" />
529 <input type="hidden" id="thinkrank_twitter_image" name="thinkrank_twitter_image" value="<?php echo esc_url((string) ($existing_metadata['twitter_image'] ?? '')); ?>" />
530 <textarea id="thinkrank_content_preview" style="display: none;"><?php echo esc_textarea($content_preview); ?></textarea>
531 </div>
532 <?php
533
534 }
535
536 /**
537 * Save meta box data
538 *
539 * @param int $post_id Post ID
540 * @param \WP_Post $post Post object
541 * @return void
542 */
543 public function save_meta_boxes(int $post_id, \WP_Post $post): void {
544 // Verify nonce
545 if (!isset($_POST['thinkrank_metabox_nonce'])) {
546 return;
547 }
548
549 $nonce = sanitize_text_field(wp_unslash($_POST['thinkrank_metabox_nonce']));
550 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_nonce')) {
551 return;
552 }
553
554 // Check permissions
555 if (!current_user_can('edit_post', $post_id)) {
556 return;
557 }
558
559 // Skip autosave
560 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
561 return;
562 }
563
564 // The classic/block editor submits the metabox fields as part of the
565 // #post form, so they arrive (slashed) in $_POST. Hand them straight to
566 // the shared persistence routine.
567 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above
568 $this->persist_metadata($post_id, wp_unslash($_POST));
569
570 // The redirect is the one field here that can be refused outright. The
571 // response to this request is a redirect back to the editor, so the
572 // reason has to survive one page load to be seen at all.
573 $redirect_error = $this->get_last_redirect_error();
574 if (null !== $redirect_error) {
575 $this->store_redirect_error($redirect_error);
576 }
577 }
578
579 /**
580 * Persist metabox fields for a post from a form-field-name => value map,
581 * reusing the shared metabox persistence (sanitization, JSON encoding,
582 * focus-keyword normalization, empty-value deletion).
583 *
584 * Intended for non-form callers such as the MCP abilities layer. The
585 * caller is responsible for authorization; keys use the same
586 * `thinkrank_*` field names accepted by the metabox form (e.g.
587 * `thinkrank_seo_title`, `thinkrank_meta_description`, `thinkrank_robots_meta`).
588 *
589 * @param int $post_id Post to update.
590 * @param array $fields Field name => value map.
591 * @return void
592 */
593 public function save_seo_fields(int $post_id, array $fields): void {
594 $this->persist_metadata($post_id, $fields);
595 }
596
597 /**
598 * Persist all metabox fields for a post from a $_POST-shaped (already
599 * unslashed) source array.
600 *
601 * Shared by `save_meta_boxes()` (classic/block editor form submit) and
602 * `ajax_save_metabox()` (editors like Elementor that don't submit the #post
603 * form). Nonce/permission checks are the caller's responsibility. Each field
604 * is independently sanitized; missing keys are left untouched.
605 *
606 * @param int $post_id Post to update.
607 * @param array $src Field name => raw value map (unslashed).
608 * @return void
609 */
610 private function persist_metadata(int $post_id, array $src): void {
611 // Title & meta description are handled separately below: they can be
612 // written out-of-band (Auto AI on publish, imports)
613 // after an editor was opened, so a plain save from that now-stale editor
614 // would clobber the generated value with a blank. Focus keywords are
615 // likewise handled separately (array meta) via Focus_Keywords below.
616 //
617 // Both fields may hold variable tags, so they are sanitized as templates:
618 // sanitize_text_field()/sanitize_textarea_field() strip %date% and
619 // %category% as percent-encoding and store "te%" / "tegory%" (#521).
620 $this->persist_seo_text_field($post_id, $src, 'thinkrank_seo_title', '_thinkrank_seo_title', [Pattern_Resolver::class, 'sanitize_template']);
621 $this->persist_seo_text_field($post_id, $src, 'thinkrank_meta_description', '_thinkrank_meta_description', [Pattern_Resolver::class, 'sanitize_template_textarea']);
622
623 $fields = [
624 'thinkrank_seo_score' => 'absint',
625 'thinkrank_generated_at' => 'sanitize_text_field',
626 'thinkrank_pillar_content' => 'sanitize_text_field',
627 'thinkrank_exclude_from_search' => 'sanitize_text_field',
628 'thinkrank_exclude_from_archives' => 'sanitize_text_field',
629 ];
630
631 // Focus keywords: prefer the JSON array field; fall back to the legacy
632 // single string. Focus_Keywords::save() normalizes (dedupe, drop empty,
633 // cap at MAX) and keeps the legacy single-value meta in sync.
634 if (isset($src['thinkrank_focus_keywords'])) {
635 $raw_keywords = $src['thinkrank_focus_keywords'];
636 $decoded = is_string($raw_keywords) ? json_decode($raw_keywords, true) : $raw_keywords;
637 Focus_Keywords::save($post_id, is_array($decoded) ? $decoded : []);
638 } elseif (isset($src['thinkrank_focus_keyword'])) {
639 Focus_Keywords::save($post_id, $src['thinkrank_focus_keyword']);
640 }
641
642 // Update the post slug (post_name) when the metabox permalink field
643 // was edited. This touches the WP post itself, not post meta.
644 //
645 // The baseline is what the field was RENDERED with. Without it the
646 // guard here was a bare isset(), and the hidden input is always
647 // posted — so a user who edited WordPress's own permalink field in
648 // the Classic Editor had their new slug written by core and then
649 // overwritten by this page-load snapshot (#441).
650 if (isset($src['thinkrank_post_slug'])) {
651 $this->maybe_update_slug(
652 $post_id,
653 (string) $src['thinkrank_post_slug'],
654 isset($src['thinkrank_post_slug_baseline'])
655 ? (string) $src['thinkrank_post_slug_baseline']
656 : null
657 );
658 }
659
660 // Save canonical URL separately with URL sanitization
661 if (isset($src['thinkrank_canonical_url'])) {
662 $canonical_url = esc_url_raw((string) $src['thinkrank_canonical_url']);
663 if (empty($canonical_url)) {
664 delete_post_meta($post_id, '_thinkrank_canonical_url');
665 } else {
666 update_post_meta($post_id, '_thinkrank_canonical_url', $canonical_url);
667 }
668 }
669
670 $this->last_redirect_error = $this->save_object_redirect('post', $post_id, $src);
671
672 foreach ($fields as $field => $sanitize_callback) {
673 if (isset($src[$field])) {
674 $value = call_user_func($sanitize_callback, $src[$field]);
675 update_post_meta($post_id, "_{$field}", $value);
676 }
677 }
678
679 $this->save_robots_meta($post_id, $src);
680 $this->save_visibility_meta($post_id, $src);
681 $this->save_social_meta($post_id, $src);
682
683 // Update last modified timestamp
684 update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
685 }
686
687 /**
688 * Persist one SEO text field with an out-of-band-write guard.
689 *
690 * Auto AI (on publish) and imports write the SEO title / meta description
691 * directly to post meta. When that happens after an editor
692 * was opened, the editor's hidden input is a stale blank; a normal save
693 * would overwrite the freshly generated value with that blank. This guard
694 * skips the write only when the submitted value is empty AND it was also
695 * empty when the form was rendered (the `<field>__orig` mirror), yet the
696 * stored value is now non-empty — i.e. a background writer won the race.
697 *
698 * A deliberate clear still applies: if the field held a value at render and
699 * is submitted empty, `$orig` is non-empty so the guard does not trigger.
700 * Callers that don't send the `__orig` mirror (e.g. the MCP/Elementor
701 * paths) keep the plain write behavior.
702 *
703 * @param int $post_id Post being saved.
704 * @param array $src Unslashed field map.
705 * @param string $field POST field name (e.g. thinkrank_seo_title).
706 * @param string $meta_key Target post meta key.
707 * @param callable $sanitize Sanitizer applied to the submitted value.
708 * @return void
709 */
710 private function persist_seo_text_field(int $post_id, array $src, string $field, string $meta_key, callable $sanitize): void {
711 if (!isset($src[$field])) {
712 return; // Field absent from this submit → leave the stored value untouched.
713 }
714
715 $submitted = (string) call_user_func($sanitize, (string) $src[$field]);
716
717 if ('' === $submitted && isset($src[$field . '__orig'])) {
718 $orig = (string) $src[$field . '__orig'];
719 $stored = (string) get_post_meta($post_id, $meta_key, true);
720 // Blank now, blank at render, but populated in storage → a background
721 // write landed after this editor loaded; don't clobber it.
722 if ('' === $orig && '' !== $stored) {
723 return;
724 }
725 }
726
727 update_post_meta($post_id, $meta_key, $submitted);
728 }
729
730 /**
731 * Update the post slug (post_name) from the metabox permalink field.
732 *
733 * Runs inside the save_post cycle, so wp_update_post() would recurse — a
734 * static guard prevents re-entry. WordPress applies wp_unique_post_slug(),
735 * so a colliding slug is de-duplicated automatically. Empty input is left
736 * alone (WP keeps/auto-generates the slug); auto-drafts and revisions are
737 * skipped so we don't fight the editor's own slug generation.
738 *
739 * @param int $post_id Post to update.
740 * @param string $raw_slug Desired slug from the metabox.
741 * @return void
742 */
743 private function maybe_update_slug(int $post_id, string $raw_slug, ?string $baseline = null): void {
744 static $updating = false;
745 if ($updating) {
746 return;
747 }
748
749 $post = get_post($post_id);
750 if (!$post || wp_is_post_revision($post_id)) {
751 return;
752 }
753
754 if (in_array($post->post_status, ['auto-draft', 'trash'], true)) {
755 return;
756 }
757
758 $desired = sanitize_title($raw_slug);
759 if ($desired === '') {
760 return;
761 }
762
763 // Unchanged from what the form was rendered with, so the user did not
764 // choose this value — they left it alone. Writing it back would undo
765 // whatever core already saved from WordPress's own permalink field a
766 // moment ago, on the same save_post priority (#441).
767 //
768 // Compared against the BASELINE rather than the current post_name on
769 // purpose: by the time this runs core has already updated post_name,
770 // so that comparison cannot tell a deliberate edit from a stale one.
771 if ($baseline !== null && $desired === sanitize_title($baseline)) {
772 return;
773 }
774
775 if ($desired === $post->post_name) {
776 return;
777 }
778
779 $updating = true;
780 wp_update_post([
781 'ID' => $post_id,
782 'post_name' => $desired,
783 ]);
784 $updating = false;
785 }
786
787 /**
788 * Persist per-post Open Graph and Twitter Card overrides.
789 *
790 * Empty values delete the meta entry so the frontend falls back to the
791 * SEO title / meta description / featured image chain.
792 */
793 private function save_social_meta(int $post_id, array $src): void {
794 $text_fields = [
795 'thinkrank_og_title' => '_thinkrank_og_title',
796 'thinkrank_og_description' => '_thinkrank_og_description',
797 'thinkrank_twitter_title' => '_thinkrank_twitter_title',
798 'thinkrank_twitter_description' => '_thinkrank_twitter_description',
799 ];
800 foreach ($text_fields as $field => $meta_key) {
801 if (!isset($src[$field])) {
802 continue;
803 }
804 // Template fields: the frontend resolves their variable tags, so the
805 // %tokens% have to survive the save (#521).
806 $value = Pattern_Resolver::sanitize_template_textarea((string) $src[$field]);
807 if ($value === '') {
808 delete_post_meta($post_id, $meta_key);
809 } else {
810 update_post_meta($post_id, $meta_key, $value);
811 }
812 }
813
814 $url_fields = [
815 'thinkrank_og_image' => '_thinkrank_og_image',
816 'thinkrank_twitter_image' => '_thinkrank_twitter_image',
817 ];
818 foreach ($url_fields as $field => $meta_key) {
819 if (!isset($src[$field])) {
820 continue;
821 }
822 $value = esc_url_raw((string) $src[$field]);
823 if ($value === '') {
824 delete_post_meta($post_id, $meta_key);
825 } else {
826 update_post_meta($post_id, $meta_key, $value);
827 }
828 }
829 }
830
831 /**
832 * Save per-post robots meta and advanced robots meta from the metabox.
833 *
834 * Stores the robots payloads as JSON-encoded strings, sanitized via
835 * sanitize_json_meta_field. The toggle plus the two JSON blobs are the
836 * single source of truth for per-post robots overrides.
837 */
838 private function save_robots_meta(int $post_id, array $src): void {
839 if (!isset($src['thinkrank_robots_meta_enabled'])) {
840 return;
841 }
842
843 $enabled = (int) (bool) $src['thinkrank_robots_meta_enabled'];
844 update_post_meta($post_id, '_thinkrank_robots_meta_enabled', $enabled);
845
846 if (isset($src['thinkrank_robots_meta'])) {
847 update_post_meta($post_id, '_thinkrank_robots_meta', $this->sanitize_json_meta_field((string) $src['thinkrank_robots_meta']));
848 }
849
850 if (isset($src['thinkrank_advanced_robots_meta'])) {
851 update_post_meta($post_id, '_thinkrank_advanced_robots_meta', $this->sanitize_json_meta_field((string) $src['thinkrank_advanced_robots_meta']));
852 }
853 }
854
855
856 /**
857 * Save the per-post listing-visibility switches.
858 *
859 * Stored as 1 or deleted rather than 1/0: the excluded set is read with a
860 * `meta_value = '1'` query, so a row holding 0 would be dead weight on every
861 * post anyone ever unticked. Deleting keeps the postmeta table proportional
862 * to the number of posts actually hidden.
863 *
864 * @since 2.7.0
865 *
866 * @param int $post_id Post being saved.
867 * @param array $src Submitted fields.
868 * @return void
869 */
870 private function save_visibility_meta(int $post_id, array $src): void {
871 $fields = [
872 'thinkrank_exclude_from_search' => \ThinkRank\SEO\Content_Visibility::SEARCH_META,
873 'thinkrank_exclude_from_archives' => \ThinkRank\SEO\Content_Visibility::ARCHIVE_META,
874 ];
875
876 foreach ($fields as $field => $meta_key) {
877 if (!isset($src[$field])) {
878 continue;
879 }
880
881 if ((bool) $src[$field]) {
882 update_post_meta($post_id, $meta_key, 1);
883 } else {
884 delete_post_meta($post_id, $meta_key);
885 }
886 }
887
888 \ThinkRank\SEO\Content_Visibility::flush();
889 }
890
891 /**
892 * Enqueue meta box scripts
893 *
894 * @param string $hook Current admin page hook
895 * @return void
896 */
897 public function enqueue_metabox_scripts(string $hook): void {
898 // Only load on post edit screens (including block editor)
899 if (!in_array($hook, ['post.php', 'post-new.php'], true)) {
900 return;
901 }
902
903 // Get current post type - handle both classic and block editor contexts
904 $current_post_type = $this->get_current_post_type();
905 if (!$current_post_type || !in_array($current_post_type, $this->get_supported_post_types(), true)) {
906 return;
907 }
908
909 // Get post object for additional data
910 global $post;
911
912 // Ensure wp.media is available for the social-image media picker.
913 wp_enqueue_media();
914
915 // No chunk dependencies needed - all bundled into main metabox.js
916 // Enqueue React metabox script with direct dependencies
917 $asset_file = THINKRANK_PLUGIN_DIR . 'assets/metabox.asset.php';
918 $asset = file_exists($asset_file) ? include $asset_file : [
919 'dependencies' => ['react', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-components'],
920 'version' => THINKRANK_VERSION
921 ];
922
923 // Use dependencies directly from the asset file. The pinned "Configure
924 // SEO" launcher needs wp-plugins (already listed by the build) and
925 // resolves PinnedItems from wp.editor / wp.interface at runtime, so we do
926 // NOT add wp-interface here: it is not a registered script handle on
927 // WP 7.x, and an unmet dependency would drop the whole metabox script.
928 $dependencies = $asset['dependencies'];
929
930 wp_enqueue_script(
931 'thinkrank-metabox',
932 THINKRANK_PLUGIN_URL . 'assets/metabox.js',
933 $dependencies,
934 $asset['version'],
935 true
936 );
937
938 // Localize script data (shared builder; reused by the Elementor editor
939 // integration, which has no #post form / hidden inputs of its own).
940 wp_localize_script('thinkrank-metabox', 'thinkrankMetabox', $this->get_localized_data($post->ID));
941
942 // Add defer attribute for non-blocking script loading
943 wp_script_add_data('thinkrank-metabox', 'defer', true);
944
945 // Enqueue metabox styles
946 wp_enqueue_style(
947 'thinkrank-metabox',
948 THINKRANK_PLUGIN_URL . 'assets/metabox.css',
949 ['wp-components'],
950 THINKRANK_VERSION
951 );
952
953 // Classic Editor sidebar widget: lightweight styles + a vanilla-JS
954 // handler so the "Optimize Here" button works without depending on the
955 // React bundle. The box itself is only registered in the Classic Editor.
956 $sidebar_css = <<<'CSS'
957 .thinkrank-classic-sidebar-box-title{margin:0 0 12px;font-size:13px;line-height:1.5;color:#1e1e1e;}
958 .thinkrank-classic-sidebar-box-cta{width:100%;text-align:center;justify-content:center;}
959 CSS;
960 wp_add_inline_style('thinkrank-metabox', $sidebar_css);
961
962 $sidebar_js = <<<'JS'
963 (function(){
964 document.addEventListener('click', function(e){
965 var btn = e.target.closest && e.target.closest('.thinkrank-classic-sidebar-box-cta');
966 if(!btn){return;}
967 e.preventDefault();
968 // Open the ThinkRank SEO drawer mounted by the React metabox app.
969 window.dispatchEvent(new CustomEvent('thinkrank:toggle-seo-drawer'));
970 });
971 })();
972 JS;
973 wp_add_inline_script('thinkrank-metabox', $sidebar_js);
974 }
975
976 /**
977 * Build the data object localized into the metabox script (`thinkrankMetabox`).
978 *
979 * Extracted so the Elementor editor integration can reuse the exact same
980 * configuration. Callers that run outside the #post form (Elementor) also
981 * read `existingMetadata`/`contentPreview`, which they add on top of this.
982 *
983 * @param int $post_id Post being edited.
984 * @return array Localized config consumed by the React metabox.
985 */
986 public function get_localized_data(int $post_id): array {
987 $post = get_post($post_id);
988 $post_type = $post ? $post->post_type : 'post';
989
990 // Resolve the correct REST API base for the current post type.
991 // Falls back to the post type slug for any type without a rest_base.
992 $post_type_obj = get_post_type_object($post_type);
993 $post_rest_base = ($post_type_obj && !empty($post_type_obj->rest_base))
994 ? $post_type_obj->rest_base
995 : $post_type;
996
997 return [
998 'ajaxUrl' => admin_url('admin-ajax.php'),
999 'nonce' => wp_create_nonce('thinkrank_metabox_ajax'),
1000 'postId' => $post_id,
1001 'postType' => $post_type,
1002 'postRestBase' => $post_rest_base,
1003 'postPermalink' => get_permalink($post_id),
1004 'postSlug' => $post ? $post->post_name : '',
1005 'restUrl' => rest_url('thinkrank/v1/'),
1006 'restNonce' => wp_create_nonce('wp_rest'),
1007 'homeUrl' => home_url(),
1008 'siteName' => get_bloginfo('name'),
1009 'faviconUrl' => $this->get_site_favicon_url(),
1010 'featuredImageUrl' => $this->get_post_featured_image_url($post_id),
1011 'strings' => [
1012 'generating' => __('Generating...', 'thinkrank'),
1013 'analyzing' => __('Analyzing...', 'thinkrank'),
1014 'error' => __('Error occurred', 'thinkrank'),
1015 'success' => __('Success!', 'thinkrank'),
1016 'generated' => __('Metadata generated successfully', 'thinkrank'),
1017 'contentTooShort' => __('Please add some content before generating SEO metadata.', 'thinkrank'),
1018 'apiError' => __('Failed to connect to AI service. Please check your API settings.', 'thinkrank'),
1019 ],
1020 'seoScore' => $this->get_persisted_seo_score($post_id),
1021 'postModified' => $post ? get_the_modified_date('c', $post) : '',
1022 'linkSuggestionsEnabled' => $this->is_link_suggestions_enabled($post_type),
1023 'postStatus' => get_post_status($post_id),
1024 'isPro' => Plan_Config::is_pro(),
1025 // Whether a provider (Pro's Redirections feature) can actually store
1026 // a redirect. False renders the field as an upsell rather than an
1027 // input that accepts text nothing will ever act on.
1028 'redirectSupported' => Object_Redirect::is_supported(),
1029 'redirectTypes' => Object_Redirect::TYPES,
1030 /**
1031 * Filter the editor SEO panel's post-load refresh behaviour.
1032 *
1033 * The panel re-checks `/metadata/{id}` after load so values written
1034 * by a background writer (Auto AI on publish, imports) appear
1035 * without a reload. It only polls while the server
1036 * reports a write in flight, but the poll lives in JavaScript, so
1037 * the switch has to be localized into the bundle rather than being
1038 * a PHP-side filter alone (#329).
1039 *
1040 * Set `enabled` to false to switch the refresh off entirely.
1041 *
1042 * @since 1.30.0
1043 *
1044 * @param array $config enabled (bool), intervalMs (int), maxTicks (int).
1045 * @param int $post_id Post being edited.
1046 */
1047 'seoRefresh' => apply_filters(
1048 'thinkrank_metabox_seo_refresh',
1049 [
1050 'enabled' => true,
1051 'intervalMs' => 4000,
1052 'maxTicks' => 10,
1053 ],
1054 $post_id
1055 ),
1056 // Whether the selected AI provider is configured — gates the
1057 // "Generate with AI" button in the metabox. An OpenAI-compatible
1058 // endpoint counts once it has a base URL and a model id, with or
1059 // without a key (#721).
1060 'aiConfigured' => $this->settings->has_ai_provider_configured(),
1061 // Resolved Global/Bulk SEO variable-tag patterns for this post, shown
1062 // as placeholder previews when a field is empty (the frontend applies
1063 // these same patterns on output). Typing a value overrides them.
1064 'patternPreviews' => Pattern_Resolver::previews($post_id),
1065 // Token => value map (keys without %), for live client-side preview of
1066 // a custom pattern typed into a metabox field.
1067 'patternVariables' => Pattern_Resolver::variables($post_id),
1068 // The uncapped copy the Content Analysis panel measures. Localized
1069 // here so all six editors get it, rather than each builder adding
1070 // its own (#778). `contentPreview` stays as the AI budget.
1071 'contentFull' => $post instanceof \WP_Post ? $this->get_analysis_content($post) : '',
1072 ];
1073 }
1074
1075 /**
1076 * Latest persisted SEO score for a post.
1077 *
1078 * Read from the scores table — the same source the posts list column and the
1079 * Analysis panel use — so the editor badge agrees with them on load rather
1080 * than showing 0 until the Analysis panel mounts and fetches the score.
1081 *
1082 * The `thinkrank_seo_score` form field is unusable for this: it is only
1083 * written by the AI "Analyze Content" flow and is reset to 0 on every save.
1084 *
1085 * @param int $post_id Post ID
1086 * @return int|null Score, or null when the post has never been analyzed.
1087 */
1088 private function get_persisted_seo_score(int $post_id): ?int {
1089 $existing = $this->seo_calculator->get_existing_score_data($post_id);
1090 $score = $existing['overall_score'] ?? null;
1091
1092 return $score !== null ? (int) $score : null;
1093 }
1094
1095 /**
1096 * Check if link suggestions are enabled for a post type
1097 *
1098 * @param string $post_type Post type to check
1099 * @return bool True if enabled, false otherwise
1100 */
1101 private function is_link_suggestions_enabled(string $post_type): bool {
1102 $settings = get_option('thinkrank_global_seo_settings', []);
1103
1104 if (isset($settings[$post_type]['link_suggestions'])) {
1105 return (bool) $settings[$post_type]['link_suggestions'];
1106 }
1107
1108 return true;
1109 }
1110
1111 /**
1112 * Get current post type in admin context
1113 *
1114 * Handles both classic editor and block editor contexts
1115 *
1116 * @return string|null Current post type or null if not found
1117 */
1118 private function get_current_post_type(): ?string {
1119 global $post, $typenow, $current_screen;
1120
1121 // Try to get post type from various sources
1122 if ($post && !empty($post->post_type)) {
1123 return $post->post_type;
1124 }
1125
1126 if (!empty($typenow)) {
1127 return $typenow;
1128 }
1129
1130 if ($current_screen && !empty($current_screen->post_type)) {
1131 return $current_screen->post_type;
1132 }
1133
1134 // Fallback: check URL parameters for block editor
1135 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1136 if (isset($_GET['post_type'])) {
1137 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1138 return sanitize_text_field(wp_unslash($_GET['post_type']));
1139 }
1140
1141 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1142 if (isset($_GET['post'])) {
1143 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data
1144 $post_id = absint($_GET['post']);
1145 $post_type = get_post_type($post_id);
1146 if ($post_type) {
1147 return $post_type;
1148 }
1149 }
1150
1151 return null;
1152 }
1153
1154 /**
1155 * Get supported post types
1156 *
1157 * @return array Supported post types
1158 */
1159 public function get_supported_post_types(): array {
1160 $default_types = ['post', 'page'];
1161
1162 // Add WooCommerce product if available
1163 if (class_exists('WooCommerce')) {
1164 $default_types[] = 'product';
1165 }
1166
1167 // Add other common e-commerce post types
1168 $ecommerce_types = ['product', 'shop_order', 'shop_coupon'];
1169 foreach ($ecommerce_types as $type) {
1170 if (post_type_exists($type) && !in_array($type, $default_types, true)) {
1171 $default_types[] = $type;
1172 }
1173 }
1174
1175 // Add custom post types that are public and have UI
1176 $custom_post_types = get_post_types([
1177 'public' => true,
1178 'show_ui' => true,
1179 '_builtin' => false,
1180 ]);
1181
1182 // WordPress internals that should never carry an SEO metabox. Fixed,
1183 // so it is built once rather than per post type.
1184 $wp_internal_types = [
1185 'attachment',
1186 'revision',
1187 'nav_menu_item',
1188 'custom_css',
1189 'customize_changeset',
1190 'oembed_cache',
1191 'user_request',
1192 'wp_block',
1193 'wp_template',
1194 'wp_template_part',
1195 'wp_global_styles',
1196 'wp_navigation',
1197 'acf-field',
1198 'acf-field-group',
1199 ];
1200
1201 // Builder template CPTs (Bricks, Elementor, Divi, Beaver Builder) are
1202 // layout fragments, not pages with their own SEO. Global SEO already
1203 // refuses them; this list is shared with that policy so the two cannot
1204 // drift apart again (#621).
1205 if (!class_exists('\ThinkRank\SEO\Global_SEO_Post_Types')) {
1206 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-global-seo-post-types.php';
1207 }
1208
1209 foreach ($custom_post_types as $post_type) {
1210 // Resolved per post type, not hoisted: the shared list runs through
1211 // a public filter that receives the post-type object, so an
1212 // integrator can answer differently for different post types.
1213 $excluded_types = array_merge(
1214 $wp_internal_types,
1215 \ThinkRank\SEO\Global_SEO_Post_Types::excluded_post_types(get_post_type_object($post_type))
1216 );
1217
1218 if (!in_array($post_type, $excluded_types, true) && !in_array($post_type, $default_types, true)) {
1219 $default_types[] = $post_type;
1220 }
1221 }
1222
1223 return apply_filters('thinkrank_supported_post_types', $default_types);
1224 }
1225
1226 /**
1227 * Transient holding the last redirect error for the current user.
1228 */
1229 private const REDIRECT_ERROR_TRANSIENT = 'thinkrank_redirect_error_';
1230
1231 /**
1232 * Why the redirect field was refused on the most recent persist, if it was.
1233 *
1234 * @var \WP_Error|null
1235 */
1236 private ?\WP_Error $last_redirect_error = null;
1237
1238 /**
1239 * Persist the edit-screen redirect field.
1240 *
1241 * Absent keys are left alone, so a caller that never rendered the field
1242 * (the AJAX save from an editor that submits a subset, an import) cannot
1243 * clear a redirect by omission.
1244 *
1245 * The destination is not post meta — Pro's rules table holds it — so unlike
1246 * every other field here this save can fail for reasons the editor needs to
1247 * hear about: no Pro, plain permalinks, a destination that is the page's own
1248 * URL. Failing silently would be the worst of both, since the field would
1249 * redisplay empty on the next load with no explanation, so the reason is
1250 * stashed for the notice rendered on the next screen.
1251 *
1252 * @param string $object_type 'post' or 'term'.
1253 * @param int $object_id Object ID.
1254 * @param array $src Field name => raw value map.
1255 * @return void
1256 */
1257 private function save_object_redirect(string $object_type, int $object_id, array $src): ?\WP_Error {
1258 if (!array_key_exists('thinkrank_redirect_url', $src)) {
1259 return null;
1260 }
1261
1262 $url = (string) $src['thinkrank_redirect_url'];
1263
1264 // With no provider there is nothing to store and nothing to clear.
1265 // Staying quiet when the field was submitted empty keeps every ordinary
1266 // save on a free site from raising an error about a field the editor
1267 // never touched.
1268 if (!Object_Redirect::is_supported()) {
1269 if ('' !== trim($url)) {
1270 return new \WP_Error(
1271 'thinkrank_redirect_unsupported',
1272 __('Redirects require ThinkRank Pro with the Redirections feature active.', 'thinkrank')
1273 );
1274 }
1275 return null;
1276 }
1277
1278 $type = array_key_exists('thinkrank_redirect_type', $src)
1279 ? $src['thinkrank_redirect_type']
1280 : Object_Redirect::DEFAULT_TYPE;
1281
1282 $result = Object_Redirect::save($object_type, $object_id, $url, $type);
1283
1284 return is_wp_error($result) ? $result : null;
1285 }
1286
1287 /**
1288 * Why the last persist_metadata() call could not store the redirect.
1289 *
1290 * Every other metabox field either saves or is sanitized into something
1291 * that does; this one can be refused, and each caller reports that
1292 * differently — a notice for the form post, a JSON field for the AJAX save,
1293 * an error message for the MCP ability.
1294 *
1295 * @return \WP_Error|null
1296 */
1297 public function get_last_redirect_error(): ?\WP_Error {
1298 return $this->last_redirect_error;
1299 }
1300
1301 /**
1302 * Remember why a redirect could not be saved, for the next admin screen.
1303 *
1304 * @param \WP_Error $error Failure.
1305 * @return void
1306 */
1307 private function store_redirect_error(\WP_Error $error): void {
1308 $user_id = get_current_user_id();
1309 if ($user_id <= 0) {
1310 return;
1311 }
1312
1313 set_transient(self::REDIRECT_ERROR_TRANSIENT . $user_id, $error->get_error_message(), MINUTE_IN_SECONDS);
1314 }
1315
1316 /**
1317 * Show, once, why the last redirect save failed.
1318 *
1319 * @return void
1320 */
1321 public function render_redirect_notice(): void {
1322 $user_id = get_current_user_id();
1323 if ($user_id <= 0) {
1324 return;
1325 }
1326
1327 $key = self::REDIRECT_ERROR_TRANSIENT . $user_id;
1328 $message = get_transient($key);
1329
1330 if (!is_string($message) || '' === $message) {
1331 return;
1332 }
1333
1334 delete_transient($key);
1335
1336 printf(
1337 '<div class="notice notice-error is-dismissible"><p>%s</p></div>',
1338 esc_html(
1339 sprintf(
1340 /* translators: %s: reason the redirect was not saved. */
1341 __('ThinkRank could not save the redirect: %s', 'thinkrank'),
1342 $message
1343 )
1344 )
1345 );
1346 }
1347
1348 /**
1349 * Get existing post metadata
1350 *
1351 * @param int $post_id Post ID
1352 * @return array Existing metadata
1353 */
1354 public function get_post_metadata(int $post_id): array {
1355 // One lookup: get() goes through a filter Pro answers from the database.
1356 $redirect = Object_Redirect::get('post', $post_id);
1357
1358 return [
1359 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
1360 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
1361 'focus_keyword' => Focus_Keywords::get_primary($post_id),
1362 'focus_keywords' => Focus_Keywords::get($post_id),
1363 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
1364 'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1365 'pillar_content' => get_post_meta($post_id, '_thinkrank_pillar_content', true),
1366 'exclude_from_search' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::SEARCH_META, true),
1367 'exclude_from_archives' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::ARCHIVE_META, true),
1368 'canonical_url' => get_post_meta($post_id, '_thinkrank_canonical_url', true),
1369 // Not post meta: the rule in Pro's redirections table is the value.
1370 // See ThinkRank\SEO\Object_Redirect.
1371 'redirect_url' => $redirect['url'],
1372 'redirect_type' => $redirect['type'],
1373 'robots_meta_enabled' => get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true),
1374 'robots_meta' => get_post_meta($post_id, '_thinkrank_robots_meta', true),
1375 'advanced_robots_meta' => get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true),
1376 'og_title' => get_post_meta($post_id, '_thinkrank_og_title', true),
1377 'og_description' => get_post_meta($post_id, '_thinkrank_og_description', true),
1378 'og_image' => get_post_meta($post_id, '_thinkrank_og_image', true),
1379 'twitter_title' => get_post_meta($post_id, '_thinkrank_twitter_title', true),
1380 'twitter_description' => get_post_meta($post_id, '_thinkrank_twitter_description', true),
1381 'twitter_image' => get_post_meta($post_id, '_thinkrank_twitter_image', true),
1382 ];
1383 }
1384
1385 /**
1386 * Get site favicon URL
1387 *
1388 * Prefers WordPress' native Site Icon (zero HTTP). Only when no Site Icon is
1389 * configured does it probe common favicon locations — and that probe is
1390 * cached (including a "no favicon" sentinel) so the editor never fires
1391 * blocking loopback HEAD requests on every load.
1392 *
1393 * @since 1.16.2 Prefer get_site_icon_url() and cache the fallback probe.
1394 * @return string Favicon URL, or '' when none can be resolved.
1395 */
1396 private function get_site_favicon_url(): string {
1397 // Try the native Site Icon first (WordPress 4.3+) — no HTTP required.
1398 $site_icon_url = get_site_icon_url();
1399 if ($site_icon_url) {
1400 return $site_icon_url;
1401 }
1402
1403 // No Site Icon configured: fall back to probing common favicon paths.
1404 // Cache the resolved value (empty string included) so the blocking
1405 // loopback probe runs at most once per ~12h per site host instead of on
1406 // every editor load.
1407 $cache_key = 'thinkrank_favicon_url_' . md5((string) wp_parse_url(home_url(), PHP_URL_HOST));
1408 $cached = get_transient($cache_key);
1409 if (false !== $cached) {
1410 return (string) $cached;
1411 }
1412
1413 $favicon_url = '';
1414
1415 $favicon_paths = [
1416 '/favicon.ico',
1417 '/favicon.png',
1418 '/apple-touch-icon.png',
1419 ];
1420
1421 foreach ($favicon_paths as $path) {
1422 $candidate = home_url($path);
1423 $response = wp_remote_head($candidate, ['timeout' => 3]);
1424 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
1425 $favicon_url = $candidate;
1426 break;
1427 }
1428 }
1429
1430 set_transient($cache_key, $favicon_url, 12 * HOUR_IN_SECONDS);
1431
1432 return $favicon_url;
1433 }
1434
1435 /**
1436 * Get post featured image URL
1437 *
1438 * @param int $post_id Post ID
1439 * @return string|null Featured image URL or null if not available
1440 */
1441 private function get_post_featured_image_url(int $post_id): ?string {
1442 $thumbnail_id = get_post_thumbnail_id($post_id);
1443 if ($thumbnail_id) {
1444 // Use the full-size image — social share images need a large source
1445 // (the preview scales it down), not a small thumbnail.
1446 $image_url = wp_get_attachment_image_url($thumbnail_id, 'full');
1447 return $image_url ?: null;
1448 }
1449 return null;
1450 }
1451
1452 /**
1453 * Characters of page text an AI caller is given.
1454 *
1455 * A prompt budget, not a description of the page — one caller forwards this
1456 * straight into an AI payload, so it stays capped.
1457 *
1458 * @since 2.10.0
1459 * @var int
1460 */
1461 private const AI_PREVIEW_LENGTH = 4000;
1462
1463 /**
1464 * Safety ceiling for the analysis copy.
1465 *
1466 * High enough that no real article reaches it, low enough that a runaway
1467 * builder tree cannot put a megabyte of text through wp_localize_script
1468 * into every editor page load.
1469 *
1470 * @since 2.10.0
1471 * @var int
1472 */
1473 private const ANALYSIS_MAX_LENGTH = 200000;
1474
1475 /**
1476 * Resolved body text per post, for this request.
1477 *
1478 * Every builder integration localizes get_localized_data() (which carries
1479 * the analysis copy) and then adds get_content_preview() on top, and the
1480 * classic screen renders the preview into the form as well as localizing
1481 * the analysis copy. Each of those resolved the post through
1482 * Builder_Content from scratch, so a builder page walked its whole tree
1483 * twice on every editor load. Both now derive from one resolution.
1484 *
1485 * Keyed on the post's ID and modified time, so a post saved and re-read
1486 * within the same request is resolved again.
1487 *
1488 * @since 2.10.0
1489 * @var array<string,string>
1490 */
1491 private array $resolved_bodies = [];
1492
1493 /**
1494 * The post's body text, resolved through whatever built it.
1495 *
1496 * Body only. The title and excerpt used to be prepended here, which is
1497 * right for an AI prompt but wrong for a measurement: the Analysis panel
1498 * counted them as body copy, so Word Count and Keyword Density in a
1499 * builder included the title and excerpt, while the same panel in the
1500 * block editor (which reads the editor's content) did not. They are added
1501 * back for the AI preview only.
1502 *
1503 * @since 2.10.0
1504 *
1505 * @param \WP_Post $post Post object.
1506 * @return string Plain text, whitespace collapsed, uncapped.
1507 */
1508 private function resolve_post_body(\WP_Post $post): string {
1509 $key = $post->ID . '|' . (string) ($post->post_modified_gmt ?? '');
1510
1511 if (isset($this->resolved_bodies[$key])) {
1512 return $this->resolved_bodies[$key];
1513 }
1514
1515 // Resolve through Builder_Content: a page builder keeps its words
1516 // outside post_content (Oxygen empties it entirely), and the editor
1517 // cannot reach postmeta, so without this the preview handed to the
1518 // browser is just the title and the live panel reports "No content".
1519 if (!class_exists('\\ThinkRank\\SEO\\Builder_Content')) {
1520 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1521 }
1522
1523 $this->resolved_bodies[$key] = self::collapse_text(\ThinkRank\SEO\Builder_Content::resolve($post));
1524
1525 return $this->resolved_bodies[$key];
1526 }
1527
1528 /**
1529 * Strip tags and collapse whitespace.
1530 *
1531 * @since 2.10.0
1532 *
1533 * @param string $content Markup or text.
1534 * @return string
1535 */
1536 private static function collapse_text(string $content): string {
1537 $content = wp_strip_all_tags($content);
1538
1539 return trim((string) preg_replace('/\s+/', ' ', $content));
1540 }
1541
1542 /**
1543 * The post's text for the editor's Content Analysis panel.
1544 *
1545 * Uncapped, because this is a measurement rather than a budget. In a page
1546 * builder there is no `core/editor` store, no TinyMCE instance and no
1547 * `#content` textarea, so the localized string is the ONLY thing the panel
1548 * can count — and it was being handed the 4,000-character AI preview. Word
1549 * Count, Keyword Density, Readability and Content Quality therefore
1550 * described the first 4,000 characters and nothing after, freezing at
1551 * roughly 600-700 words however long the page grew (#778).
1552 *
1553 * Still bounded, at a ceiling no real article reaches: this rides along on
1554 * every editor page load.
1555 *
1556 * The body only, like the editor content it stands in for; the title and
1557 * excerpt belong to the AI preview (see resolve_post_body()).
1558 *
1559 * @since 2.10.0
1560 *
1561 * @param \WP_Post $post Post object.
1562 * @return string
1563 */
1564 public function get_analysis_content(\WP_Post $post): string {
1565 return \ThinkRank\Core\Seo_Text::trim_to_length(
1566 $this->resolve_post_body($post),
1567 self::ANALYSIS_MAX_LENGTH
1568 );
1569 }
1570
1571 /**
1572 * Get content preview for AI analysis
1573 *
1574 * @param \WP_Post $post Post object
1575 * @return string Content preview
1576 */
1577 public function get_content_preview(\WP_Post $post): string {
1578 // The title and excerpt lead the preview: they are context an AI
1579 // caller wants first, and they are not part of the measured body.
1580 $lead = $post->post_title . "\n\n";
1581
1582 if (!empty($post->post_excerpt)) {
1583 $lead .= $post->post_excerpt . "\n\n";
1584 }
1585
1586 $preview = trim(self::collapse_text($lead) . ' ' . $this->resolve_post_body($post));
1587
1588 // substr() counts BYTES: on Thai or CJK this handed the model a third
1589 // of the intended content and cut the last character in half (#687).
1590 return \ThinkRank\Core\Seo_Text::trim_to_length($preview, self::AI_PREVIEW_LENGTH);
1591 }
1592
1593 /**
1594 * AJAX handler for generating post metadata
1595 *
1596 * @return void
1597 */
1598 public function ajax_generate_post_metadata(): void {
1599 // Verify nonce
1600 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1601 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
1602 wp_die('Security check failed');
1603 }
1604
1605 // Check permissions
1606 $post_id = absint($_POST['post_id'] ?? 0);
1607 if (!current_user_can('edit_post', $post_id)) {
1608 wp_die('Insufficient permissions');
1609 }
1610
1611 try {
1612 $options = [
1613 'target_keyword' => sanitize_text_field(wp_unslash($_POST['target_keyword'] ?? '')),
1614 'content_type' => sanitize_text_field(wp_unslash($_POST['content_type'] ?? 'blog_post')),
1615 'tone' => sanitize_text_field(wp_unslash($_POST['tone'] ?? 'professional')),
1616 ];
1617
1618 $metadata = $this->metadata_generator->generate_for_post($post_id, $options);
1619
1620 wp_send_json_success([
1621 'metadata' => $metadata,
1622 'message' => __('SEO metadata generated successfully!', 'thinkrank'),
1623 ]);
1624 } catch (\Exception $e) {
1625 wp_send_json_error([
1626 'message' => $e->getMessage(),
1627 ]);
1628 }
1629 }
1630
1631 /**
1632 * AJAX: persist the full set of metabox fields for a post.
1633 *
1634 * Used by editors that don't submit the #post form (Elementor). Expects the
1635 * same field names the classic/block metabox submits, sent as POST params.
1636 * $_POST is slashed by WordPress, matching what `persist_metadata()` expects.
1637 *
1638 * @return void
1639 */
1640 public function ajax_save_metabox(): void {
1641 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1642 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
1643 wp_send_json_error(['message' => __('Security check failed', 'thinkrank')], 403);
1644 }
1645
1646 $post_id = absint($_POST['post_id'] ?? 0);
1647 if (!$post_id || !current_user_can('edit_post', $post_id)) {
1648 wp_send_json_error(['message' => __('Insufficient permissions', 'thinkrank')], 403);
1649 }
1650
1651 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; each field sanitized inside persist_metadata()
1652 $this->persist_metadata($post_id, wp_unslash($_POST));
1653
1654 // Everything else saved; only the redirect can have been refused. Report
1655 // it in this response rather than as a notice on some later screen —
1656 // this caller never reloads the page.
1657 $redirect_error = $this->get_last_redirect_error();
1658 if (null !== $redirect_error) {
1659 wp_send_json_error([
1660 'message' => sprintf(
1661 /* translators: %s: reason the redirect was not saved. */
1662 __('Saved, except the redirect: %s', 'thinkrank'),
1663 $redirect_error->get_error_message()
1664 ),
1665 ], 400);
1666 }
1667
1668 wp_send_json_success([
1669 'message' => __('SEO settings saved successfully!', 'thinkrank'),
1670 ]);
1671 }
1672 }
1673