PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.0.2 All 50 releases
← All changes | includes/admin/class-metabox-manager.php +239 -9 2.5.0 → 2.9.0 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',
@@ -483,9 +503,22 @@
483 503 <input type="hidden" id="thinkrank_focus_keywords" name="thinkrank_focus_keywords" value="<?php echo esc_attr(wp_json_encode($existing_metadata['focus_keywords'] ?? [])); ?>" />
484 504 <input type="hidden" id="thinkrank_seo_score" name="thinkrank_seo_score" value="<?php echo esc_attr($existing_metadata['seo_score'] ?? '0'); ?>" />
485 505 <input type="hidden" id="thinkrank_generated_at" name="thinkrank_generated_at" value="<?php echo esc_attr($existing_metadata['generated_at'] ?? ''); ?>" />
486 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'] ?? '')); ?>" />
487 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; ?>
488 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')); ?>" />
489 522 <input type="hidden" id="thinkrank_robots_meta" name="thinkrank_robots_meta" value="<?php echo esc_attr((string) ($existing_metadata['robots_meta'] ?? '')); ?>" />
490 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'] ?? '')); ?>" />
491 524 <input type="hidden" id="thinkrank_og_title" name="thinkrank_og_title" value="<?php echo esc_attr((string) ($existing_metadata['og_title'] ?? '')); ?>" />
@@ -532,8 +565,16 @@
532 565 // #post form, so they arrive (slashed) in $_POST. Hand them straight to
533 566 // the shared persistence routine.
534 567 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above
535 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 + }
536 577 }
537 578
538 579 /**
539 580 * Persist metabox fields for a post from a form-field-name => value map,
@@ -582,8 +623,10 @@
582 623 $fields = [
583 624 'thinkrank_seo_score' => 'absint',
584 625 'thinkrank_generated_at' => 'sanitize_text_field',
585 626 'thinkrank_pillar_content' => 'sanitize_text_field',
627 + 'thinkrank_exclude_from_search' => 'sanitize_text_field',
628 + 'thinkrank_exclude_from_archives' => 'sanitize_text_field',
586 629 ];
587 630
588 631 // Focus keywords: prefer the JSON array field; fall back to the legacy
589 632 // single string. Focus_Keywords::save() normalizes (dedupe, drop empty,
@@ -623,8 +666,10 @@
623 666 update_post_meta($post_id, '_thinkrank_canonical_url', $canonical_url);
624 667 }
625 668 }
626 669
670 + $this->last_redirect_error = $this->save_object_redirect('post', $post_id, $src);
671 +
627 672 foreach ($fields as $field => $sanitize_callback) {
628 673 if (isset($src[$field])) {
629 674 $value = call_user_func($sanitize_callback, $src[$field]);
630 675 update_post_meta($post_id, "_{$field}", $value);
@@ -631,8 +676,9 @@
631 676 }
632 677 }
633 678
634 679 $this->save_robots_meta($post_id, $src);
680 + $this->save_visibility_meta($post_id, $src);
635 681 $this->save_social_meta($post_id, $src);
636 682
637 683 // Update last modified timestamp
638 684 update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
@@ -807,8 +853,43 @@
807 853 }
808 854
809 855
810 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 + /**
811 892 * Enqueue meta box scripts
812 893 *
813 894 * @param string $hook Current admin page hook
814 895 * @return void
@@ -940,8 +1021,13 @@
940 1021 'postModified' => $post ? get_the_modified_date('c', $post) : '',
941 1022 'linkSuggestionsEnabled' => $this->is_link_suggestions_enabled($post_type),
942 1023 'postStatus' => get_post_status($post_id),
943 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,
944 1030 /**
945 1031 * Filter the editor SEO panel's post-load refresh behaviour.
946 1032 *
947 1033 * The panel re-checks `/metadata/{id}` after load so values written
@@ -966,16 +1052,13 @@
966 1052 'maxTicks' => 10,
967 1053 ],
968 1054 $post_id
969 1055 ),
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(),
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(),
978 1061 // Resolved Global/Bulk SEO variable-tag patterns for this post, shown
979 1062 // as placeholder previews when a field is empty (the frontend applies
980 1063 // these same patterns on output). Typing a value overrides them.
981 1064 'patternPreviews' => Pattern_Resolver::previews($post_id),
@@ -1136,8 +1219,130 @@
1136 1219 return apply_filters('thinkrank_supported_post_types', $default_types);
1137 1220 }
1138 1221
1139 1222 /**
1223 + * Transient holding the last redirect error for the current user.
1224 + */
1225 + private const REDIRECT_ERROR_TRANSIENT = 'thinkrank_redirect_error_';
1226 +
1227 + /**
1228 + * Why the redirect field was refused on the most recent persist, if it was.
1229 + *
1230 + * @var \WP_Error|null
1231 + */
1232 + private ?\WP_Error $last_redirect_error = null;
1233 +
1234 + /**
1235 + * Persist the edit-screen redirect field.
1236 + *
1237 + * Absent keys are left alone, so a caller that never rendered the field
1238 + * (the AJAX save from an editor that submits a subset, an import) cannot
1239 + * clear a redirect by omission.
1240 + *
1241 + * The destination is not post meta — Pro's rules table holds it — so unlike
1242 + * every other field here this save can fail for reasons the editor needs to
1243 + * hear about: no Pro, plain permalinks, a destination that is the page's own
1244 + * URL. Failing silently would be the worst of both, since the field would
1245 + * redisplay empty on the next load with no explanation, so the reason is
1246 + * stashed for the notice rendered on the next screen.
1247 + *
1248 + * @param string $object_type 'post' or 'term'.
1249 + * @param int $object_id Object ID.
1250 + * @param array $src Field name => raw value map.
1251 + * @return void
1252 + */
1253 + private function save_object_redirect(string $object_type, int $object_id, array $src): ?\WP_Error {
1254 + if (!array_key_exists('thinkrank_redirect_url', $src)) {
1255 + return null;
1256 + }
1257 +
1258 + $url = (string) $src['thinkrank_redirect_url'];
1259 +
1260 + // With no provider there is nothing to store and nothing to clear.
1261 + // Staying quiet when the field was submitted empty keeps every ordinary
1262 + // save on a free site from raising an error about a field the editor
1263 + // never touched.
1264 + if (!Object_Redirect::is_supported()) {
1265 + if ('' !== trim($url)) {
1266 + return new \WP_Error(
1267 + 'thinkrank_redirect_unsupported',
1268 + __('Redirects require ThinkRank Pro with the Redirections feature active.', 'thinkrank')
1269 + );
1270 + }
1271 + return null;
1272 + }
1273 +
1274 + $type = array_key_exists('thinkrank_redirect_type', $src)
1275 + ? $src['thinkrank_redirect_type']
1276 + : Object_Redirect::DEFAULT_TYPE;
1277 +
1278 + $result = Object_Redirect::save($object_type, $object_id, $url, $type);
1279 +
1280 + return is_wp_error($result) ? $result : null;
1281 + }
1282 +
1283 + /**
1284 + * Why the last persist_metadata() call could not store the redirect.
1285 + *
1286 + * Every other metabox field either saves or is sanitized into something
1287 + * that does; this one can be refused, and each caller reports that
1288 + * differently — a notice for the form post, a JSON field for the AJAX save,
1289 + * an error message for the MCP ability.
1290 + *
1291 + * @return \WP_Error|null
1292 + */
1293 + public function get_last_redirect_error(): ?\WP_Error {
1294 + return $this->last_redirect_error;
1295 + }
1296 +
1297 + /**
1298 + * Remember why a redirect could not be saved, for the next admin screen.
1299 + *
1300 + * @param \WP_Error $error Failure.
1301 + * @return void
1302 + */
1303 + private function store_redirect_error(\WP_Error $error): void {
1304 + $user_id = get_current_user_id();
1305 + if ($user_id <= 0) {
1306 + return;
1307 + }
1308 +
1309 + set_transient(self::REDIRECT_ERROR_TRANSIENT . $user_id, $error->get_error_message(), MINUTE_IN_SECONDS);
1310 + }
1311 +
1312 + /**
1313 + * Show, once, why the last redirect save failed.
1314 + *
1315 + * @return void
1316 + */
1317 + public function render_redirect_notice(): void {
1318 + $user_id = get_current_user_id();
1319 + if ($user_id <= 0) {
1320 + return;
1321 + }
1322 +
1323 + $key = self::REDIRECT_ERROR_TRANSIENT . $user_id;
1324 + $message = get_transient($key);
1325 +
1326 + if (!is_string($message) || '' === $message) {
1327 + return;
1328 + }
1329 +
1330 + delete_transient($key);
1331 +
1332 + printf(
1333 + '<div class="notice notice-error is-dismissible"><p>%s</p></div>',
1334 + esc_html(
1335 + sprintf(
1336 + /* translators: %s: reason the redirect was not saved. */
1337 + __('ThinkRank could not save the redirect: %s', 'thinkrank'),
1338 + $message
1339 + )
1340 + )
1341 + );
1342 + }
1343 +
1344 + /**
1140 1345 * Get existing post metadata
1141 1346 *
1142 1347 * @param int $post_id Post ID
1143 1348 * @return array Existing metadata
@@ -1142,8 +1347,11 @@
1142 1347 * @param int $post_id Post ID
1143 1348 * @return array Existing metadata
1144 1349 */
1145 1350 public function get_post_metadata(int $post_id): array {
1351 + // One lookup: get() goes through a filter Pro answers from the database.
1352 + $redirect = Object_Redirect::get('post', $post_id);
1353 +
1146 1354 return [
1147 1355 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
1148 1356 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
1149 1357 'focus_keyword' => Focus_Keywords::get_primary($post_id),
@@ -1150,9 +1358,15 @@
1150 1358 'focus_keywords' => Focus_Keywords::get($post_id),
1151 1359 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
1152 1360 'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1153 1361 'pillar_content' => get_post_meta($post_id, '_thinkrank_pillar_content', true),
1362 + 'exclude_from_search' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::SEARCH_META, true),
1363 + 'exclude_from_archives' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::ARCHIVE_META, true),
1154 1364 'canonical_url' => get_post_meta($post_id, '_thinkrank_canonical_url', true),
1365 + // Not post meta: the rule in Pro's redirections table is the value.
1366 + // See ThinkRank\SEO\Object_Redirect.
1367 + 'redirect_url' => $redirect['url'],
1368 + 'redirect_type' => $redirect['type'],
1155 1369 'robots_meta_enabled' => get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true),
1156 1370 'robots_meta' => get_post_meta($post_id, '_thinkrank_robots_meta', true),
1157 1371 'advanced_robots_meta' => get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true),
1158 1372 'og_title' => get_post_meta($post_id, '_thinkrank_og_title', true),
@@ -1256,9 +1470,11 @@
1256 1470 // Clean and limit content
1257 1471 $content = wp_strip_all_tags($content);
1258 1472 $content = preg_replace('/\s+/', ' ', $content);
1259 1473
1260 - return trim(substr($content, 0, 4000));
1474 + // substr() counts BYTES: on Thai or CJK this handed the model a third
1475 + // of the intended content and cut the last character in half (#687).
1476 + return trim(\ThinkRank\Core\Seo_Text::trim_to_length($content, 4000));
1261 1477 }
1262 1478
1263 1479 /**
1264 1480 * AJAX handler for generating post metadata
@@ -1319,8 +1535,22 @@
1319 1535 }
1320 1536
1321 1537 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; each field sanitized inside persist_metadata()
1322 1538 $this->persist_metadata($post_id, wp_unslash($_POST));
1539 +
1540 + // Everything else saved; only the redirect can have been refused. Report
1541 + // it in this response rather than as a notice on some later screen —
1542 + // this caller never reloads the page.
1543 + $redirect_error = $this->get_last_redirect_error();
1544 + if (null !== $redirect_error) {
1545 + wp_send_json_error([
1546 + 'message' => sprintf(
1547 + /* translators: %s: reason the redirect was not saved. */
1548 + __('Saved, except the redirect: %s', 'thinkrank'),
1549 + $redirect_error->get_error_message()
1550 + ),
1551 + ], 400);
1552 + }
1323 1553
1324 1554 wp_send_json_success([
1325 1555 'message' => __('SEO settings saved successfully!', 'thinkrank'),
1326 1556 ]);