PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 All 58 releases
← All changes | includes/admin/class-metabox-manager.php +383 -24 2.5.0 → 2.14.3 View file →
@@ -18,8 +18,9 @@
18 18 use ThinkRank\Core\Settings;
19 19 use ThinkRank\Core\Database;
20 20 use ThinkRank\Core\Plan_Config;
21 21 use ThinkRank\SEO\Focus_Keywords;
22 +use ThinkRank\SEO\Object_Redirect;
22 23 use ThinkRank\SEO\Pattern_Resolver;
23 24
24 25 // Prevent direct access
25 26 if (!defined('ABSPATH')) {
@@ -77,8 +78,9 @@
77 78 public function init(): void {
78 79 add_action('add_meta_boxes', [$this, 'add_meta_boxes']);
79 80 add_action('save_post', [$this, 'save_meta_boxes'], 10, 2);
80 81 add_action('admin_enqueue_scripts', [$this, 'enqueue_metabox_scripts']);
82 + add_action('admin_notices', [$this, 'render_redirect_notice']);
81 83 add_action('init', [$this, 'register_meta_fields']);
82 84
83 85 // AJAX handlers for meta box functionality
84 86 add_action('wp_ajax_thinkrank_generate_post_metadata', [$this, 'ajax_generate_post_metadata']);
@@ -206,8 +208,26 @@
206 208 return current_user_can('edit_posts') || current_user_can('edit_pages');
207 209 }
208 210 ]);
209 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 +
210 230 register_post_meta('', '_thinkrank_primary_category', [
211 231 'show_in_rest' => true,
212 232 'single' => true,
213 233 'type' => 'integer',
@@ -222,12 +242,21 @@
222 242 *
223 243 * Validates JSON structure and recursively sanitizes all string values
224 244 * to prevent XSS and injection attacks.
225 245 *
226 - * @param string $value Raw JSON string value
246 + * Untyped on purpose: this is a registered `sanitize_callback`, so
247 + * WordPress hands it whatever any writer stored. A `string` parameter made
248 + * an array or null a fatal TypeError instead of reaching the guard below
249 + * (#1014). An array is a decoded payload and is encoded rather than lost.
250 + *
251 + * @param mixed $value Raw JSON string value, or an already-decoded array
227 252 * @return string Sanitized JSON string or empty string if invalid
228 253 */
229 - public function sanitize_json_meta_field(string $value): string {
254 + public function sanitize_json_meta_field($value): string {
255 + if (is_array($value)) {
256 + $value = (string) wp_json_encode($value);
257 + }
258 +
230 259 // Return empty string for non-string values
231 260 if (!is_string($value) || empty($value)) {
232 261 return '';
233 262 }
@@ -296,13 +325,19 @@
296 325 *
297 326 * Validates JSON structure, recursively sanitizes all string values,
298 327 * and preserves @ characters in keys (crucial for JSON-LD).
299 328 *
300 - * @param string $value Raw JSON string value
329 + * Untyped for the same reason as sanitize_json_meta_field() (#1014).
301 330 *
331 + * @param mixed $value Raw JSON string value, or an already-decoded array
332 + *
302 333 * @return string Sanitized JSON string or empty string if invalid
303 334 */
304 - public function sanitize_json_ld_field( string $value ): string {
335 + public function sanitize_json_ld_field( $value ): string {
336 + if ( is_array( $value ) ) {
337 + $value = (string) wp_json_encode( $value );
338 + }
339 +
305 340 // Return empty string for non-string values
306 341 if ( ! is_string( $value ) || empty( $value ) ) {
307 342 return '';
308 343 }
@@ -483,9 +518,22 @@
483 518 <input type="hidden" id="thinkrank_focus_keywords" name="thinkrank_focus_keywords" value="<?php echo esc_attr(wp_json_encode($existing_metadata['focus_keywords'] ?? [])); ?>" />
484 519 <input type="hidden" id="thinkrank_seo_score" name="thinkrank_seo_score" value="<?php echo esc_attr($existing_metadata['seo_score'] ?? '0'); ?>" />
485 520 <input type="hidden" id="thinkrank_generated_at" name="thinkrank_generated_at" value="<?php echo esc_attr($existing_metadata['generated_at'] ?? ''); ?>" />
486 521 <input type="hidden" id="thinkrank_pillar_content" name="thinkrank_pillar_content" value="<?php echo esc_attr($existing_metadata['pillar_content'] ?? ''); ?>" />
522 + <input type="hidden" id="thinkrank_exclude_from_search" name="thinkrank_exclude_from_search" value="<?php echo esc_attr((string) ($existing_metadata['exclude_from_search'] ?? '')); ?>" />
523 + <input type="hidden" id="thinkrank_exclude_from_archives" name="thinkrank_exclude_from_archives" value="<?php echo esc_attr((string) ($existing_metadata['exclude_from_archives'] ?? '')); ?>" />
487 524 <input type="hidden" id="thinkrank_canonical_url" name="thinkrank_canonical_url" value="<?php echo esc_url($existing_metadata['canonical_url'] ?? ''); ?>" />
525 + <?php
526 + // The redirect lives in Pro's rules table, not post meta, so nothing
527 + // else hands it to the React app. Without these the field loads
528 + // empty, and its own hidden input then posts that empty value on the
529 + // next save, which Object_Redirect reads as "remove the redirect".
530 + // Rendered only when a provider can store it, matching MetaboxApp.
531 + if (Object_Redirect::is_supported()) :
532 + ?>
533 + <input type="hidden" id="thinkrank_redirect_url" name="thinkrank_redirect_url" value="<?php echo esc_attr((string) ($existing_metadata['redirect_url'] ?? '')); ?>" />
534 + <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)); ?>" />
535 + <?php endif; ?>
488 536 <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 537 <input type="hidden" id="thinkrank_robots_meta" name="thinkrank_robots_meta" value="<?php echo esc_attr((string) ($existing_metadata['robots_meta'] ?? '')); ?>" />
490 538 <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 539 <input type="hidden" id="thinkrank_og_title" name="thinkrank_og_title" value="<?php echo esc_attr((string) ($existing_metadata['og_title'] ?? '')); ?>" />
@@ -532,8 +580,16 @@
532 580 // #post form, so they arrive (slashed) in $_POST. Hand them straight to
533 581 // the shared persistence routine.
534 582 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above
535 583 $this->persist_metadata($post_id, wp_unslash($_POST));
584 +
585 + // The redirect is the one field here that can be refused outright. The
586 + // response to this request is a redirect back to the editor, so the
587 + // reason has to survive one page load to be seen at all.
588 + $redirect_error = $this->get_last_redirect_error();
589 + if (null !== $redirect_error) {
590 + $this->store_redirect_error($redirect_error);
591 + }
536 592 }
537 593
538 594 /**
539 595 * Persist metabox fields for a post from a form-field-name => value map,
@@ -582,8 +638,10 @@
582 638 $fields = [
583 639 'thinkrank_seo_score' => 'absint',
584 640 'thinkrank_generated_at' => 'sanitize_text_field',
585 641 'thinkrank_pillar_content' => 'sanitize_text_field',
642 + 'thinkrank_exclude_from_search' => 'sanitize_text_field',
643 + 'thinkrank_exclude_from_archives' => 'sanitize_text_field',
586 644 ];
587 645
588 646 // Focus keywords: prefer the JSON array field; fall back to the legacy
589 647 // single string. Focus_Keywords::save() normalizes (dedupe, drop empty,
@@ -623,8 +681,10 @@
623 681 update_post_meta($post_id, '_thinkrank_canonical_url', $canonical_url);
624 682 }
625 683 }
626 684
685 + $this->last_redirect_error = $this->save_object_redirect('post', $post_id, $src);
686 +
627 687 foreach ($fields as $field => $sanitize_callback) {
628 688 if (isset($src[$field])) {
629 689 $value = call_user_func($sanitize_callback, $src[$field]);
630 690 update_post_meta($post_id, "_{$field}", $value);
@@ -631,8 +691,9 @@
631 691 }
632 692 }
633 693
634 694 $this->save_robots_meta($post_id, $src);
695 + $this->save_visibility_meta($post_id, $src);
635 696 $this->save_social_meta($post_id, $src);
636 697
637 698 // Update last modified timestamp
638 699 update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
@@ -807,8 +868,43 @@
807 868 }
808 869
809 870
810 871 /**
872 + * Save the per-post listing-visibility switches.
873 + *
874 + * Stored as 1 or deleted rather than 1/0: the excluded set is read with a
875 + * `meta_value = '1'` query, so a row holding 0 would be dead weight on every
876 + * post anyone ever unticked. Deleting keeps the postmeta table proportional
877 + * to the number of posts actually hidden.
878 + *
879 + * @since 2.7.0
880 + *
881 + * @param int $post_id Post being saved.
882 + * @param array $src Submitted fields.
883 + * @return void
884 + */
885 + private function save_visibility_meta(int $post_id, array $src): void {
886 + $fields = [
887 + 'thinkrank_exclude_from_search' => \ThinkRank\SEO\Content_Visibility::SEARCH_META,
888 + 'thinkrank_exclude_from_archives' => \ThinkRank\SEO\Content_Visibility::ARCHIVE_META,
889 + ];
890 +
891 + foreach ($fields as $field => $meta_key) {
892 + if (!isset($src[$field])) {
893 + continue;
894 + }
895 +
896 + if ((bool) $src[$field]) {
897 + update_post_meta($post_id, $meta_key, 1);
898 + } else {
899 + delete_post_meta($post_id, $meta_key);
900 + }
901 + }
902 +
903 + \ThinkRank\SEO\Content_Visibility::flush();
904 + }
905 +
906 + /**
811 907 * Enqueue meta box scripts
812 908 *
813 909 * @param string $hook Current admin page hook
814 910 * @return void
@@ -940,8 +1036,13 @@
940 1036 'postModified' => $post ? get_the_modified_date('c', $post) : '',
941 1037 'linkSuggestionsEnabled' => $this->is_link_suggestions_enabled($post_type),
942 1038 'postStatus' => get_post_status($post_id),
943 1039 'isPro' => Plan_Config::is_pro(),
1040 + // Whether a provider (Pro's Redirections feature) can actually store
1041 + // a redirect. False renders the field as an upsell rather than an
1042 + // input that accepts text nothing will ever act on.
1043 + 'redirectSupported' => Object_Redirect::is_supported(),
1044 + 'redirectTypes' => Object_Redirect::TYPES,
944 1045 /**
945 1046 * Filter the editor SEO panel's post-load refresh behaviour.
946 1047 *
947 1048 * The panel re-checks `/metadata/{id}` after load so values written
@@ -966,16 +1067,13 @@
966 1067 'maxTicks' => 10,
967 1068 ],
968 1069 $post_id
969 1070 ),
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(),
1071 + // Whether the selected AI provider is configured — gates the
1072 + // "Generate with AI" button in the metabox. An OpenAI-compatible
1073 + // endpoint counts once it has a base URL and a model id, with or
1074 + // without a key (#721).
1075 + 'aiConfigured' => $this->settings->has_ai_provider_configured(),
978 1076 // Resolved Global/Bulk SEO variable-tag patterns for this post, shown
979 1077 // as placeholder previews when a field is empty (the frontend applies
980 1078 // these same patterns on output). Typing a value overrides them.
981 1079 'patternPreviews' => Pattern_Resolver::previews($post_id),
@@ -981,8 +1079,12 @@
981 1079 'patternPreviews' => Pattern_Resolver::previews($post_id),
982 1080 // Token => value map (keys without %), for live client-side preview of
983 1081 // a custom pattern typed into a metabox field.
984 1082 'patternVariables' => Pattern_Resolver::variables($post_id),
1083 + // The uncapped copy the Content Analysis panel measures. Localized
1084 + // here so all six editors get it, rather than each builder adding
1085 + // its own (#778). `contentPreview` stays as the AI budget.
1086 + 'contentFull' => $post instanceof \WP_Post ? $this->get_analysis_content($post) : '',
985 1087 ];
986 1088 }
987 1089
988 1090 /**
@@ -1136,8 +1238,130 @@
1136 1238 return apply_filters('thinkrank_supported_post_types', $default_types);
1137 1239 }
1138 1240
1139 1241 /**
1242 + * Transient holding the last redirect error for the current user.
1243 + */
1244 + private const REDIRECT_ERROR_TRANSIENT = 'thinkrank_redirect_error_';
1245 +
1246 + /**
1247 + * Why the redirect field was refused on the most recent persist, if it was.
1248 + *
1249 + * @var \WP_Error|null
1250 + */
1251 + private ?\WP_Error $last_redirect_error = null;
1252 +
1253 + /**
1254 + * Persist the edit-screen redirect field.
1255 + *
1256 + * Absent keys are left alone, so a caller that never rendered the field
1257 + * (the AJAX save from an editor that submits a subset, an import) cannot
1258 + * clear a redirect by omission.
1259 + *
1260 + * The destination is not post meta — Pro's rules table holds it — so unlike
1261 + * every other field here this save can fail for reasons the editor needs to
1262 + * hear about: no Pro, plain permalinks, a destination that is the page's own
1263 + * URL. Failing silently would be the worst of both, since the field would
1264 + * redisplay empty on the next load with no explanation, so the reason is
1265 + * stashed for the notice rendered on the next screen.
1266 + *
1267 + * @param string $object_type 'post' or 'term'.
1268 + * @param int $object_id Object ID.
1269 + * @param array $src Field name => raw value map.
1270 + * @return void
1271 + */
1272 + private function save_object_redirect(string $object_type, int $object_id, array $src): ?\WP_Error {
1273 + if (!array_key_exists('thinkrank_redirect_url', $src)) {
1274 + return null;
1275 + }
1276 +
1277 + $url = (string) $src['thinkrank_redirect_url'];
1278 +
1279 + // With no provider there is nothing to store and nothing to clear.
1280 + // Staying quiet when the field was submitted empty keeps every ordinary
1281 + // save on a free site from raising an error about a field the editor
1282 + // never touched.
1283 + if (!Object_Redirect::is_supported()) {
1284 + if ('' !== trim($url)) {
1285 + return new \WP_Error(
1286 + 'thinkrank_redirect_unsupported',
1287 + __('Redirects require ThinkRank Pro with the Redirections feature active.', 'thinkrank')
1288 + );
1289 + }
1290 + return null;
1291 + }
1292 +
1293 + $type = array_key_exists('thinkrank_redirect_type', $src)
1294 + ? $src['thinkrank_redirect_type']
1295 + : Object_Redirect::DEFAULT_TYPE;
1296 +
1297 + $result = Object_Redirect::save($object_type, $object_id, $url, $type);
1298 +
1299 + return is_wp_error($result) ? $result : null;
1300 + }
1301 +
1302 + /**
1303 + * Why the last persist_metadata() call could not store the redirect.
1304 + *
1305 + * Every other metabox field either saves or is sanitized into something
1306 + * that does; this one can be refused, and each caller reports that
1307 + * differently — a notice for the form post, a JSON field for the AJAX save,
1308 + * an error message for the MCP ability.
1309 + *
1310 + * @return \WP_Error|null
1311 + */
1312 + public function get_last_redirect_error(): ?\WP_Error {
1313 + return $this->last_redirect_error;
1314 + }
1315 +
1316 + /**
1317 + * Remember why a redirect could not be saved, for the next admin screen.
1318 + *
1319 + * @param \WP_Error $error Failure.
1320 + * @return void
1321 + */
1322 + private function store_redirect_error(\WP_Error $error): void {
1323 + $user_id = get_current_user_id();
1324 + if ($user_id <= 0) {
1325 + return;
1326 + }
1327 +
1328 + set_transient(self::REDIRECT_ERROR_TRANSIENT . $user_id, $error->get_error_message(), MINUTE_IN_SECONDS);
1329 + }
1330 +
1331 + /**
1332 + * Show, once, why the last redirect save failed.
1333 + *
1334 + * @return void
1335 + */
1336 + public function render_redirect_notice(): void {
1337 + $user_id = get_current_user_id();
1338 + if ($user_id <= 0) {
1339 + return;
1340 + }
1341 +
1342 + $key = self::REDIRECT_ERROR_TRANSIENT . $user_id;
1343 + $message = get_transient($key);
1344 +
1345 + if (!is_string($message) || '' === $message) {
1346 + return;
1347 + }
1348 +
1349 + delete_transient($key);
1350 +
1351 + printf(
1352 + '<div class="notice notice-error is-dismissible"><p>%s</p></div>',
1353 + esc_html(
1354 + sprintf(
1355 + /* translators: %s: reason the redirect was not saved. */
1356 + __('ThinkRank could not save the redirect: %s', 'thinkrank'),
1357 + $message
1358 + )
1359 + )
1360 + );
1361 + }
1362 +
1363 + /**
1140 1364 * Get existing post metadata
1141 1365 *
1142 1366 * @param int $post_id Post ID
1143 1367 * @return array Existing metadata
@@ -1142,8 +1366,11 @@
1142 1366 * @param int $post_id Post ID
1143 1367 * @return array Existing metadata
1144 1368 */
1145 1369 public function get_post_metadata(int $post_id): array {
1370 + // One lookup: get() goes through a filter Pro answers from the database.
1371 + $redirect = Object_Redirect::get('post', $post_id);
1372 +
1146 1373 return [
1147 1374 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
1148 1375 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
1149 1376 'focus_keyword' => Focus_Keywords::get_primary($post_id),
@@ -1150,9 +1377,15 @@
1150 1377 'focus_keywords' => Focus_Keywords::get($post_id),
1151 1378 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
1152 1379 'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1153 1380 'pillar_content' => get_post_meta($post_id, '_thinkrank_pillar_content', true),
1381 + 'exclude_from_search' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::SEARCH_META, true),
1382 + 'exclude_from_archives' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::ARCHIVE_META, true),
1154 1383 'canonical_url' => get_post_meta($post_id, '_thinkrank_canonical_url', true),
1384 + // Not post meta: the rule in Pro's redirections table is the value.
1385 + // See ThinkRank\SEO\Object_Redirect.
1386 + 'redirect_url' => $redirect['url'],
1387 + 'redirect_type' => $redirect['type'],
1155 1388 'robots_meta_enabled' => get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true),
1156 1389 'robots_meta' => get_post_meta($post_id, '_thinkrank_robots_meta', true),
1157 1390 'advanced_robots_meta' => get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true),
1158 1391 'og_title' => get_post_meta($post_id, '_thinkrank_og_title', true),
@@ -1231,18 +1464,68 @@
1231 1464 return null;
1232 1465 }
1233 1466
1234 1467 /**
1235 - * Get content preview for AI analysis
1236 - *
1237 - * @param \WP_Post $post Post object
1238 - * @return string Content preview
1468 + * Characters of page text an AI caller is given.
1469 + *
1470 + * A prompt budget, not a description of the page — one caller forwards this
1471 + * straight into an AI payload, so it stays capped.
1472 + *
1473 + * @since 2.10.0
1474 + * @var int
1239 1475 */
1240 - public function get_content_preview(\WP_Post $post): string {
1241 - $content = $post->post_title . "\n\n";
1476 + private const AI_PREVIEW_LENGTH = 4000;
1242 1477
1243 - if (!empty($post->post_excerpt)) {
1244 - $content .= $post->post_excerpt . "\n\n";
1478 + /**
1479 + * Safety ceiling for the analysis copy.
1480 + *
1481 + * High enough that no real article reaches it, low enough that a runaway
1482 + * builder tree cannot put a megabyte of text through wp_localize_script
1483 + * into every editor page load.
1484 + *
1485 + * @since 2.10.0
1486 + * @var int
1487 + */
1488 + private const ANALYSIS_MAX_LENGTH = 200000;
1489 +
1490 + /**
1491 + * Resolved body text per post, for this request.
1492 + *
1493 + * Every builder integration localizes get_localized_data() (which carries
1494 + * the analysis copy) and then adds get_content_preview() on top, and the
1495 + * classic screen renders the preview into the form as well as localizing
1496 + * the analysis copy. Each of those resolved the post through
1497 + * Builder_Content from scratch, so a builder page walked its whole tree
1498 + * twice on every editor load. Both now derive from one resolution.
1499 + *
1500 + * Keyed on the post's ID and modified time, so a post saved and re-read
1501 + * within the same request is resolved again.
1502 + *
1503 + * @since 2.10.0
1504 + * @var array<string,string>
1505 + */
1506 + private array $resolved_bodies = [];
1507 +
1508 + /**
1509 + * The post's body text, resolved through whatever built it.
1510 + *
1511 + * Body only. The title and excerpt used to be prepended here, which is
1512 + * right for an AI prompt but wrong for a measurement: the Analysis panel
1513 + * counted them as body copy, so Word Count and Keyword Density in a
1514 + * builder included the title and excerpt, while the same panel in the
1515 + * block editor (which reads the editor's content) did not. They are added
1516 + * back for the AI preview only.
1517 + *
1518 + * @since 2.10.0
1519 + *
1520 + * @param \WP_Post $post Post object.
1521 + * @return string Plain text, whitespace collapsed, uncapped.
1522 + */
1523 + private function resolve_post_body(\WP_Post $post): string {
1524 + $key = $post->ID . '|' . (string) ($post->post_modified_gmt ?? '');
1525 +
1526 + if (isset($this->resolved_bodies[$key])) {
1527 + return $this->resolved_bodies[$key];
1245 1528 }
1246 1529
1247 1530 // Resolve through Builder_Content: a page builder keeps its words
1248 1531 // outside post_content (Oxygen empties it entirely), and the editor
@@ -1250,18 +1533,80 @@
1250 1533 // browser is just the title and the live panel reports "No content".
1251 1534 if (!class_exists('\\ThinkRank\\SEO\\Builder_Content')) {
1252 1535 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1253 1536 }
1254 - $content .= \ThinkRank\SEO\Builder_Content::resolve($post);
1255 1537
1256 - // Clean and limit content
1538 + $this->resolved_bodies[$key] = self::collapse_text(\ThinkRank\SEO\Builder_Content::resolve($post));
1539 +
1540 + return $this->resolved_bodies[$key];
1541 + }
1542 +
1543 + /**
1544 + * Strip tags and collapse whitespace.
1545 + *
1546 + * @since 2.10.0
1547 + *
1548 + * @param string $content Markup or text.
1549 + * @return string
1550 + */
1551 + private static function collapse_text(string $content): string {
1257 1552 $content = wp_strip_all_tags($content);
1258 - $content = preg_replace('/\s+/', ' ', $content);
1259 1553
1260 - return trim(substr($content, 0, 4000));
1554 + return trim((string) preg_replace('/\s+/', ' ', $content));
1261 1555 }
1262 1556
1263 1557 /**
1558 + * The post's text for the editor's Content Analysis panel.
1559 + *
1560 + * Uncapped, because this is a measurement rather than a budget. In a page
1561 + * builder there is no `core/editor` store, no TinyMCE instance and no
1562 + * `#content` textarea, so the localized string is the ONLY thing the panel
1563 + * can count — and it was being handed the 4,000-character AI preview. Word
1564 + * Count, Keyword Density, Readability and Content Quality therefore
1565 + * described the first 4,000 characters and nothing after, freezing at
1566 + * roughly 600-700 words however long the page grew (#778).
1567 + *
1568 + * Still bounded, at a ceiling no real article reaches: this rides along on
1569 + * every editor page load.
1570 + *
1571 + * The body only, like the editor content it stands in for; the title and
1572 + * excerpt belong to the AI preview (see resolve_post_body()).
1573 + *
1574 + * @since 2.10.0
1575 + *
1576 + * @param \WP_Post $post Post object.
1577 + * @return string
1578 + */
1579 + public function get_analysis_content(\WP_Post $post): string {
1580 + return \ThinkRank\Core\Seo_Text::trim_to_length(
1581 + $this->resolve_post_body($post),
1582 + self::ANALYSIS_MAX_LENGTH
1583 + );
1584 + }
1585 +
1586 + /**
1587 + * Get content preview for AI analysis
1588 + *
1589 + * @param \WP_Post $post Post object
1590 + * @return string Content preview
1591 + */
1592 + public function get_content_preview(\WP_Post $post): string {
1593 + // The title and excerpt lead the preview: they are context an AI
1594 + // caller wants first, and they are not part of the measured body.
1595 + $lead = $post->post_title . "\n\n";
1596 +
1597 + if (!empty($post->post_excerpt)) {
1598 + $lead .= $post->post_excerpt . "\n\n";
1599 + }
1600 +
1601 + $preview = trim(self::collapse_text($lead) . ' ' . $this->resolve_post_body($post));
1602 +
1603 + // substr() counts BYTES: on Thai or CJK this handed the model a third
1604 + // of the intended content and cut the last character in half (#687).
1605 + return \ThinkRank\Core\Seo_Text::trim_to_length($preview, self::AI_PREVIEW_LENGTH);
1606 + }
1607 +
1608 + /**
1264 1609 * AJAX handler for generating post metadata
1265 1610 *
1266 1611 * @return void
1267 1612 */
@@ -1319,8 +1664,22 @@
1319 1664 }
1320 1665
1321 1666 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; each field sanitized inside persist_metadata()
1322 1667 $this->persist_metadata($post_id, wp_unslash($_POST));
1668 +
1669 + // Everything else saved; only the redirect can have been refused. Report
1670 + // it in this response rather than as a notice on some later screen —
1671 + // this caller never reloads the page.
1672 + $redirect_error = $this->get_last_redirect_error();
1673 + if (null !== $redirect_error) {
1674 + wp_send_json_error([
1675 + 'message' => sprintf(
1676 + /* translators: %s: reason the redirect was not saved. */
1677 + __('Saved, except the redirect: %s', 'thinkrank'),
1678 + $redirect_error->get_error_message()
1679 + ),
1680 + ], 400);
1681 + }
1323 1682
1324 1683 wp_send_json_success([
1325 1684 'message' => __('SEO settings saved successfully!', 'thinkrank'),
1326 1685 ]);