PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.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 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 All 52 releases
thinkrank / includes / admin / importers / class-rankmath-exporter.php

class-rankmath-exporter.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.11.0, at includes/admin/importers/class-rankmath-exporter.php

1,810 lines 76.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Rank Math Exporter
5 *
6 * Reads Rank Math data from postmeta/termmeta/options and normalizes
7 * into the canonical snapshot format.
8 *
9 * CRITICAL: rank_math_robots is a serialized array — use Safe_Unserializer
10 * then in_array() to check for 'noindex'/'nofollow'.
11 *
12 * @package ThinkRank\Admin\Importers
13 * @since 2.0.0
14 */
15
16 declare(strict_types=1);
17
18 namespace ThinkRank\Admin\Importers;
19
20 if (!defined('ABSPATH')) {
21 exit;
22 }
23
24 /**
25 * Rankmath Exporter Class
26 *
27 * @since 2.0.0
28 */
29 class Rankmath_Exporter extends Abstract_Plugin_Exporter {
30
31 /**
32 * Rank Math rich-snippet slug => ThinkRank schema-type vocabulary.
33 *
34 * ThinkRank's supported types come from Schema_Settings_Config::
35 * get_supported_schema_types() (PascalCase). Rank Math stores lowercase
36 * slugs and uses 'off' for "no schema". Slugs with no ThinkRank equivalent
37 * (book, course, recipe, service, music, video, jobposting) and 'off'/'none'
38 * map to '' so the migrator's empty-skip leaves no invalid schema-type value
39 * behind. Review's rating fields are carried in the record's `extended`
40 * (review_schema) and migrated into the post's schema form data.
41 */
42 private const SCHEMA_TYPE_MAP = [
43 'article' => 'Article',
44 'product' => 'Product',
45 'woocommerce' => 'Product',
46 'software' => 'SoftwareApplication',
47 'event' => 'Event',
48 'howto' => 'HowTo',
49 'faq' => 'FAQPage',
50 'person' => 'Person',
51 'restaurant' => 'LocalBusiness',
52 'review' => 'Review',
53 'video' => 'VideoObject',
54 ];
55
56 /**
57 * Rank Math MODERN schema @type => ThinkRank schema-type vocabulary.
58 *
59 * Current Rank Math stores per-post schema under `rank_math_schema_{Type}`
60 * meta (a serialized block carrying an `@type` and a `metadata.isPrimary`
61 * flag) rather than the legacy `rank_math_rich_snippet` slug. These keys are
62 * already PascalCase schema.org types. Subtypes with no distinct ThinkRank
63 * equivalent fold onto their nearest supported parent (e.g. BlogPosting →
64 * Article); types ThinkRank does not model (Recipe, …) are absent and
65 * resolve to '' (no schema). VideoObject maps through to ThinkRank's
66 * VideoObject and its block fields are migrated into the schema form data.
67 */
68 private const MODERN_SCHEMA_TYPE_MAP = [
69 'article' => 'Article',
70 'blogposting' => 'Article',
71 'newsarticle' => 'Article',
72 'product' => 'Product',
73 'woocommerceproduct' => 'Product',
74 'event' => 'Event',
75 'howto' => 'HowTo',
76 'faqpage' => 'FAQPage',
77 'person' => 'Person',
78 'localbusiness' => 'LocalBusiness',
79 'restaurant' => 'LocalBusiness',
80 'softwareapplication' => 'SoftwareApplication',
81 'review' => 'Review',
82 'organization' => 'Organization',
83 'videoobject' => 'VideoObject',
84 ];
85
86 /**
87 * Deny-list of sensitive option-key fragments stripped from the raw option
88 * capture (see capture_raw_options()). Account-bound secrets must never be
89 * persisted into our wp_options snapshot — Search Console / Analytics are
90 * always a fresh connect in ThinkRank, never a migrated token.
91 *
92 * Matches: tokens, secrets, credentials, api keys, connected-account emails
93 * (console_email*), OAuth material (console_authorization_code, oauth_*)
94 * and authentication fields. `auth` is matched via `authoriz|authenticat|
95 * (^|[_-])auth([_-]|$)` rather than a bare `auth` so legitimate `author_*`
96 * keys (author_custom_robots, authors_sitemap, …) are NOT stripped.
97 */
98 private const SENSITIVE_KEY_PATTERN =
99 '/token|secret|credential|api_key|console_email|oauth|authoriz|authenticat|(^|[_-])auth([_-]|$)/i';
100
101 /**
102 * Upper bound on IndexNow history entries carried into the snapshot, so a
103 * runaway source log cannot bloat the settings chunk. Overflow is reported
104 * via the record's `truncated` count, never dropped silently.
105 */
106 private const MAX_INDEXNOW_LOG_ENTRIES = 1000;
107
108 /**
109 * Constructor
110 */
111 public function __construct() {
112 $this->plugin_slug = 'rankmath';
113 $this->plugin_name = 'Rank Math';
114 $this->plugin_file = 'seo-by-rank-math/rank-math.php';
115 $this->meta_key_prefix = 'rank_math_';
116 $this->option_keys = ['rank-math-options-general', 'rank-math-options-titles'];
117 }
118
119 /**
120 * {@inheritDoc}
121 */
122 public function detect(): bool {
123 global $wpdb;
124
125 $count = (int) $wpdb->get_var(
126 $wpdb->prepare(
127 "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key LIKE %s LIMIT 1",
128 $wpdb->esc_like($this->meta_key_prefix) . '%'
129 )
130 );
131
132 return $count > 0;
133 }
134
135 /**
136 * {@inheritDoc}
137 */
138 public function get_available_types(): array {
139 global $wpdb;
140
141 $types = [];
142
143 $post_count = (int) $wpdb->get_var(
144 $wpdb->prepare(
145 "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key LIKE %s",
146 $wpdb->esc_like($this->meta_key_prefix) . '%'
147 )
148 );
149 if ($post_count > 0) {
150 $types['postmeta'] = $post_count;
151 }
152
153 $term_count = (int) $wpdb->get_var(
154 $wpdb->prepare(
155 "SELECT COUNT(DISTINCT term_id) FROM {$wpdb->termmeta} WHERE meta_key LIKE %s",
156 $wpdb->esc_like($this->meta_key_prefix) . '%'
157 )
158 );
159 if ($term_count > 0) {
160 $types['termmeta'] = $term_count;
161 }
162
163 $user_count = (int) $wpdb->get_var(
164 $wpdb->prepare(
165 "SELECT COUNT(DISTINCT user_id) FROM {$wpdb->usermeta} WHERE meta_key LIKE %s",
166 $wpdb->esc_like($this->meta_key_prefix) . '%'
167 )
168 );
169 if ($user_count > 0) {
170 $types['usermeta'] = $user_count;
171 }
172
173 // Redirection rules and 404 hits live in Rank Math's own tables. Both
174 // have a ThinkRank Pro home (Redirections & 404 Monitor), so they are
175 // offered as exportable types whenever the source table holds rows.
176 $redirection_count = $this->count_source_table_rows('rank_math_redirections');
177 if ($redirection_count > 0) {
178 $types['redirections'] = $redirection_count;
179 }
180
181 $log_count = $this->count_source_table_rows('rank_math_404_logs');
182 if ($log_count > 0) {
183 $types['404_logs'] = $log_count;
184 }
185
186 // FAQ / HowTo blocks live in post_content, not in any meta table, so
187 // they are invisible to every count above. Without this type they were
188 // never migrated at all and simply broke when Rank Math went away
189 // (#777).
190 $block_count = Block_Converter::count_posts();
191 if ($block_count > 0) {
192 $types[Block_Converter::TYPE] = $block_count;
193 }
194
195 foreach ($this->option_keys as $key) {
196 if (get_option($key, null) !== null) {
197 $types['settings'] = 1;
198 break;
199 }
200 }
201
202 return $types;
203 }
204
205 /**
206 * Count rows in one of Rank Math's own tables, tolerating its absence
207 * (modules can be disabled, and the standalone plugin ships fewer tables).
208 *
209 * @param string $unprefixed Table name without the `$wpdb->prefix`
210 * @return int Row count, or 0 when the table does not exist
211 */
212 private function count_source_table_rows(string $unprefixed): int {
213 global $wpdb;
214
215 $table = $wpdb->prefix . $unprefixed;
216 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
217 $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
218 if (!$exists) {
219 return 0;
220 }
221
222 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
223 return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
224 }
225
226 /**
227 * {@inheritDoc}
228 */
229 protected function export_postmeta_page(int $page): array {
230 $post_ids = $this->get_post_ids_with_meta($page);
231
232 if (empty($post_ids)) {
233 return [];
234 }
235
236 $records = [];
237 foreach ($post_ids as $post_id) {
238 $post_id = (int) $post_id;
239 $meta = $this->get_all_plugin_meta($post_id);
240
241 if (empty($meta)) {
242 continue;
243 }
244
245 // CRITICAL: rank_math_robots is a serialized indexed array of
246 // directive strings (noindex, nofollow, noarchive, noimageindex,
247 // nosnippet). The max-* directives live in a SEPARATE meta key,
248 // rank_math_advanced_robots, stored as an associative array
249 // (['max-snippet' => length|false, ...]).
250 $robots_raw = $meta['rank_math_robots'] ?? '';
251 $robots = $this->normalize_robots($robots_raw);
252 $robots_flags = $this->extract_robots_flags($robots_raw);
253 $advanced_robots = $this->parse_advanced_robots_meta($meta['rank_math_advanced_robots'] ?? '');
254
255 // Focus keyword may be comma-separated; first = primary
256 $focus_kw_raw = $meta['rank_math_focus_keyword'] ?? '';
257 $focus_keywords = array_map('trim', explode(',', $focus_kw_raw));
258 $primary_keyword = $focus_keywords[0] ?? '';
259 $additional_keywords = array_slice($focus_keywords, 1);
260
261 $records[] = [
262 'object_id' => $post_id,
263 'object_type' => 'post',
264 'source_plugin' => $this->plugin_slug,
265 'data' => [
266 'seo_title' => $this->convert_template_variables($meta['rank_math_title'] ?? '', $post_id),
267 'meta_description' => $this->convert_template_variables($meta['rank_math_description'] ?? '', $post_id),
268 'focus_keyword' => $primary_keyword,
269 // Full keyword list; the migrator dedupes, drops empties and
270 // caps at the ThinkRank maximum via Focus_Keywords.
271 'focus_keywords' => $focus_keywords,
272 'canonical_url' => $meta['rank_math_canonical_url'] ?? '',
273 'noindex' => $robots['noindex'],
274 'nofollow' => $robots['nofollow'],
275 'noarchive' => isset($robots_flags['noarchive']) ? 1 : 0,
276 'noimageindex' => isset($robots_flags['noimageindex']) ? 1 : 0,
277 'nosnippet' => isset($robots_flags['nosnippet']) ? 1 : 0,
278 'max_snippet' => $this->advanced_robot_int($advanced_robots, 'max-snippet'),
279 'max_video_preview' => $this->advanced_robot_int($advanced_robots, 'max-video-preview'),
280 'max_image_preview' => $this->advanced_robot_string($advanced_robots, 'max-image-preview'),
281 'og_title' => $this->convert_template_variables($meta['rank_math_facebook_title'] ?? '', $post_id),
282 'og_description' => $this->convert_template_variables($meta['rank_math_facebook_description'] ?? '', $post_id),
283 'og_image' => $meta['rank_math_facebook_image'] ?? '',
284 'twitter_title' => $this->convert_template_variables($meta['rank_math_twitter_title'] ?? '', $post_id),
285 'twitter_description' => $this->convert_template_variables($meta['rank_math_twitter_description'] ?? '', $post_id),
286 'twitter_image' => $meta['rank_math_twitter_image'] ?? '',
287 'primary_category' => (int) ($meta['rank_math_primary_category'] ?? 0),
288 'schema_type' => $this->resolve_schema_type($meta),
289 // WooCommerce product identifier. Rank Math keeps it in its
290 // own per-post meta; ThinkRank Pro reads the canonical field
291 // this maps to, so a migrating store keeps the identifier
292 // Google asks for instead of collecting a "missing
293 // identifier" warning on every product (#715).
294 'product_identifier' => (string) ($meta['rank_math_gtin_code'] ?? ''),
295 // Rank Math pillar content maps directly to ThinkRank pillar content.
296 'pillar_content' => $this->normalize_pillar_content($meta['rank_math_pillar_content'] ?? ''),
297 ],
298 'extended' => [
299 'focus_keywords_additional' => $additional_keywords,
300 'pillar_content' => (bool) ($meta['rank_math_pillar_content'] ?? false),
301 'breadcrumb_title' => $meta['rank_math_breadcrumb_title'] ?? '',
302 'schema_details' => $this->extract_schema_details($meta),
303 'review_schema' => $this->extract_review_schema($meta),
304 'video_schema' => $this->extract_video_schema($meta, $post_id),
305 'facebook_image_id' => $meta['rank_math_facebook_image_id'] ?? '',
306 'twitter_image_id' => $meta['rank_math_twitter_image_id'] ?? '',
307 'twitter_card_type' => $meta['rank_math_twitter_card_type'] ?? '',
308 'twitter_use_facebook' => $meta['rank_math_twitter_use_facebook'] ?? '',
309 'seo_score' => $meta['rank_math_seo_score'] ?? '',
310 'advanced_robots' => $advanced_robots,
311 // Rank Math's per-post "Exclude from sitemap" toggle. ThinkRank
312 // has no per-post meta for this — the migrator folds these IDs
313 // into the sitemap's exclude_posts list.
314 'exclude_sitemap' => !empty($meta['rank_math_exclude_sitemap']),
315 ],
316 ];
317 }
318
319 return $records;
320 }
321
322 /**
323 * {@inheritDoc}
324 */
325 protected function export_termmeta_page(int $page): array {
326 $term_ids = $this->get_term_ids_with_meta($page);
327
328 if (empty($term_ids)) {
329 return [];
330 }
331
332 $records = [];
333 foreach ($term_ids as $term_id) {
334 $term_id = (int) $term_id;
335 $meta = $this->get_all_plugin_term_meta($term_id);
336
337 if (empty($meta)) {
338 continue;
339 }
340
341 $robots_raw = $meta['rank_math_robots'] ?? '';
342 $robots = $this->normalize_robots($robots_raw);
343
344 $focus_kw_raw = $meta['rank_math_focus_keyword'] ?? '';
345 $focus_keywords = array_map('trim', explode(',', $focus_kw_raw));
346 $primary_keyword = $focus_keywords[0] ?? '';
347
348 $records[] = [
349 'object_id' => $term_id,
350 'object_type' => 'term',
351 'source_plugin' => $this->plugin_slug,
352 'data' => [
353 'seo_title' => $this->convert_term_template_variables($meta['rank_math_title'] ?? '', $term_id),
354 'meta_description' => $this->convert_term_template_variables($meta['rank_math_description'] ?? '', $term_id),
355 'focus_keyword' => $primary_keyword,
356 'canonical_url' => $meta['rank_math_canonical_url'] ?? '',
357 'noindex' => $robots['noindex'],
358 'nofollow' => $robots['nofollow'],
359 'og_title' => $this->convert_term_template_variables($meta['rank_math_facebook_title'] ?? '', $term_id),
360 'og_description' => $this->convert_term_template_variables($meta['rank_math_facebook_description'] ?? '', $term_id),
361 ],
362 'extended' => [
363 'og_image' => $meta['rank_math_facebook_image'] ?? '',
364 'twitter_title' => $meta['rank_math_twitter_title'] ?? '',
365 'twitter_description' => $meta['rank_math_twitter_description'] ?? '',
366 ],
367 ];
368 }
369
370 return $records;
371 }
372
373 /**
374 * {@inheritDoc}
375 */
376 protected function export_usermeta_page(int $page): array {
377 $user_ids = $this->get_user_ids_with_meta($page);
378
379 if (empty($user_ids)) {
380 return [];
381 }
382
383 $records = [];
384 foreach ($user_ids as $user_id) {
385 $user_id = (int) $user_id;
386 $meta = $this->get_all_plugin_user_meta($user_id);
387
388 if (empty($meta)) {
389 continue;
390 }
391
392 // Author-archive SEO title/description override (Rank Math stores these
393 // on the user profile). Values are literal text — resolve any stray
394 // template tokens with the site-level resolver (no post context).
395 $seo_title = $this->convert_template_variables($meta['rank_math_title'] ?? '');
396 $meta_description = $this->convert_template_variables($meta['rank_math_description'] ?? '');
397
398 // Rank Math also stores per-user social-overlay, permalink, twitter
399 // card and SEO-score meta; ThinkRank has no equivalent for those, so a
400 // record is only emitted when there is a migratable title/description.
401 if ($seo_title === '' && $meta_description === '') {
402 continue;
403 }
404
405 $records[] = [
406 'object_id' => $user_id,
407 'object_type' => 'user',
408 'source_plugin' => $this->plugin_slug,
409 'data' => [
410 'seo_title' => $seo_title,
411 'meta_description' => $meta_description,
412 ],
413 ];
414 }
415
416 return $records;
417 }
418
419 /**
420 * {@inheritDoc}
421 */
422 protected function export_settings(): array {
423 // get_option()'s [] default only covers a missing row; a row holding a
424 // scalar/false would flow into the array-typed helpers below and throw a
425 // TypeError. Normalize each to an array.
426 $general = get_option('rank-math-options-general', []);
427 $general = is_array($general) ? $general : [];
428 $titles = get_option('rank-math-options-titles', []);
429 $titles = is_array($titles) ? $titles : [];
430 $sitemap = get_option('rank-math-options-sitemap', []);
431 $sitemap = is_array($sitemap) ? $sitemap : [];
432
433 return [
434 [
435 'type' => 'settings',
436 'source_plugin' => $this->plugin_slug,
437 'data' => [
438 'separator' => $titles['title_separator'] ?? '-',
439 'homepage_title' => $this->convert_template_variables($titles['homepage_title'] ?? ''),
440 'homepage_description' => $this->convert_template_variables($titles['homepage_description'] ?? ''),
441 'organization_name' => $titles['knowledgegraph_name'] ?? '',
442 'organization_logo' => $titles['knowledgegraph_logo'] ?? '',
443 // Rank Math's "Alternate Name" (schema.org alternateName) maps
444 // onto ThinkRank's site-identity alternate_name field.
445 'alternate_name' => (string) ($titles['website_name'] ?? ''),
446 'social_profiles' => [
447 'facebook' => $titles['social_url_facebook'] ?? '',
448 'twitter' => $titles['social_url_twitter'] ?? '',
449 'instagram' => $titles['social_url_instagram'] ?? '',
450 'linkedin' => $titles['social_url_linkedin'] ?? '',
451 'youtube' => $titles['social_url_youtube'] ?? '',
452 'pinterest' => $titles['social_url_pinterest'] ?? '',
453 ],
454 // Whether the archive is *noindexed*. Rank Math expresses this
455 // via its per-archive robots arrays (custom robots + 'noindex'),
456 // NOT via disable_*_archives (which removes the archive entirely).
457 'noindex_archives' => [
458 'date' => in_array('noindex', (array) ($titles['date_archive_robots'] ?? []), true),
459 'author' => ($titles['author_custom_robots'] ?? 'off') === 'on'
460 && in_array('noindex', (array) ($titles['author_robots'] ?? []), true),
461 ],
462 'twitter_card_type' => in_array($titles['twitter_card_type'] ?? '', ['summary', 'summary_large_image', 'app', 'player'], true)
463 ? $titles['twitter_card_type']
464 : '',
465 // Site-wide social defaults with direct ThinkRank homes
466 // (Social Meta settings: facebook_app_id / default_image).
467 'social_defaults' => [
468 'facebook_app_id' => (string) ($titles['facebook_app_id'] ?? ''),
469 'og_default_image' => (string) ($titles['open_graph_image'] ?? ''),
470 ],
471 // Rank Math's Knowledge Graph entity: 'company' or 'person',
472 // plus the entity name. Maps onto ThinkRank's schema settings
473 // (organization_name / person_name; organization_type's
474 // default 'Organization' already matches 'company').
475 'knowledge_graph' => [
476 'type' => $this->normalize_knowledgegraph_type($titles['knowledgegraph_type'] ?? ''),
477 'name' => (string) ($titles['knowledgegraph_name'] ?? ''),
478 ],
479 // IndexNow API key from Rank Math's Instant Indexing module.
480 // Unlike OAuth material this is NOT an account secret — it is a
481 // public verification token served at /{key}.txt — so carrying
482 // it over avoids re-verifying the site with IndexNow.
483 'instant_indexing' => [
484 'api_key' => $this->extract_rm_indexnow_key(),
485 ],
486 ],
487 'extended' => [
488 'breadcrumb_settings' => [
489 // Rank Math stores this as the string 'on'/'off'; !empty('off')
490 // is true, so it must be compared explicitly.
491 'enabled' => ($general['breadcrumbs'] ?? 'off') === 'on',
492 'home_label' => $general['breadcrumbs_home_label'] ?? 'Home',
493 'separator' => $general['breadcrumbs_separator'] ?? '»',
494 'prefix' => (string) ($general['breadcrumbs_prefix'] ?? ''),
495 ],
496 // Webmaster-tools verification codes. Pinterest is applied
497 // (the one ThinkRank renders); the rest is preserved and
498 // gates /import/cleanup.
499 'webmaster_tools' => array_filter([
500 'google' => (string) ($general['google_verify'] ?? ''),
501 'bing' => (string) ($general['bing_verify'] ?? ''),
502 'yandex' => (string) ($general['yandex_verify'] ?? ''),
503 'baidu' => (string) ($general['baidu_verify'] ?? ''),
504 'pinterest' => (string) ($general['pinterest_verify'] ?? ''),
505 ]),
506 'local_seo' => [
507 'business_type' => $titles['local_business_type'] ?? '',
508 'business_name' => $titles['local_name'] ?? '',
509 'phone' => $this->extract_rm_local_phone($titles),
510 'address' => $this->extract_rm_local_address($titles),
511 'geo' => $this->extract_rm_local_geo($titles),
512 'price_range' => (string) ($titles['price_range'] ?? ''),
513 'opening_hours' => $this->extract_rm_opening_hours($titles),
514 ],
515 'post_type_settings' => $this->extract_rm_post_type_settings($titles),
516 // Per-context title formats in ThinkRank's SITE IDENTITY token
517 // vocabulary (%site_title%/%post_title%/…), which is a different
518 // dialect from the Global SEO one used by post_type_settings.
519 'title_formats' => $this->extract_rm_title_formats($titles),
520 // Author archive behaviour (Author Archives feature).
521 'author_archives' => $this->extract_rm_author_archives($titles),
522 // Instant Indexing auto-submit post types (the API key travels
523 // in `data.instant_indexing`).
524 'instant_indexing_post_types' => $this->extract_rm_indexnow_post_types(),
525 // Past IndexNow submissions, so the Instant Indexing history
526 // is not blank after switching.
527 'instant_indexing_log' => $this->extract_rm_indexnow_log(),
528 // News/Video sitemap post types (ThinkRank Pro Publisher Sitemaps).
529 'publisher_sitemaps' => $this->extract_rm_publisher_sitemaps($sitemap),
530 // Role Manager: which roles hold which `rank_math_*`
531 // capabilities. These live on the roles themselves
532 // (wp_user_roles), not in any rank-math-options-* blob, so
533 // raw_options does not cover them.
534 'role_capabilities' => $this->extract_role_capabilities('rank_math_'),
535 // Rank Math Pro's Search Console email report schedule.
536 'email_reports' => [
537 'enabled' => !empty($general['console_email_reports']),
538 'frequency_days' => $this->map_rm_email_frequency((string) ($general['console_email_frequency'] ?? '')),
539 ],
540 'image_seo' => [
541 'add_missing_alt' => ($general['add_img_alt'] ?? 'off') === 'on',
542 'alt_format' => $this->convert_image_tokens($general['img_alt_format'] ?? ''),
543 'add_missing_title' => ($general['add_img_title'] ?? 'off') === 'on',
544 'title_format' => $this->convert_image_tokens($general['img_title_format'] ?? ''),
545 ],
546 'analytics_connected' => !empty($general['console_email']),
547 // ThinkRank's sitemap only models posts/pages/categories/tags,
548 // image inclusion, links-per-file and ping-search-engines; Rank
549 // Math's per-CPT / per-taxonomy toggles (and its authors/HTML
550 // sitemaps) have no equivalent and are intentionally not captured.
551 'sitemap_settings' => [
552 'include_posts' => ($sitemap['pt_post_sitemap'] ?? 'off') === 'on',
553 'include_pages' => ($sitemap['pt_page_sitemap'] ?? 'off') === 'on',
554 'include_categories' => ($sitemap['tax_category_sitemap'] ?? 'off') === 'on',
555 'include_tags' => ($sitemap['tax_post_tag_sitemap'] ?? 'off') === 'on',
556 'include_images' => ($sitemap['include_images'] ?? 'off') === 'on',
557 'include_featured_images' => ($sitemap['include_featured_image'] ?? 'off') === 'on',
558 'links_per_sitemap' => (int) ($sitemap['items_per_page'] ?? 1000),
559 // Rank Math defaults ping to 'on'; carry the user's choice so a
560 // disabled ping is not silently reset to ThinkRank's default (on).
561 'ping_search_engines' => ($sitemap['ping_search_engines'] ?? 'on') === 'on',
562 // Rank Math's sitemap is ALWAYS an index (serves
563 // sitemap_index.xml; /sitemap.xml 301s to it) — there is no
564 // toggle to disable it. So migrating from Rank Math enables
565 // ThinkRank's sitemap index to match that structure.
566 'use_sitemap_index' => true,
567 // Rank Math already stores both as comma-separated ID
568 // strings — ThinkRank's exclude format.
569 'exclude_posts' => (string) ($sitemap['exclude_posts'] ?? ''),
570 'exclude_terms' => (string) ($sitemap['exclude_terms'] ?? ''),
571 'has_data' => !empty($sitemap),
572 ],
573 // FULL raw Rank Math option sets, redacted. The curated
574 // data/extended keys above only cover what ThinkRank can
575 // consume today; capturing everything means that when a
576 // matching feature ships (sitemaps detail, role manager,
577 // robots.txt, …) the data can be backfilled from the
578 // snapshot even after /import/cleanup deleted the source.
579 // The migrator ignores unknown extended keys, so this is
580 // inert until a mapping consumes it.
581 'raw_options' => $this->capture_raw_options(),
582 ],
583 ],
584 ];
585 }
586
587 /**
588 * Capture the full raw `rank-math-options-*` blobs into the snapshot,
589 * passed through the sensitive-key redactor.
590 *
591 * @return array Map of option name => redacted option array
592 */
593 private function capture_raw_options(): array {
594 $option_names = [
595 'rank-math-options-general',
596 'rank-math-options-titles',
597 'rank-math-options-sitemap',
598 'rank-math-options-instant-indexing',
599 ];
600
601 $raw = [];
602 foreach ($option_names as $name) {
603 $value = get_option($name, null);
604 if (is_array($value) && !empty($value)) {
605 $raw[$name] = $this->redact_sensitive_keys($value);
606 }
607 }
608
609 return $raw;
610 }
611
612 /**
613 * Recursively strip keys matching SENSITIVE_KEY_PATTERN from an option
614 * array so tokens/credentials (console_authorization_code, console_email*,
615 * analytics/API tokens, …) are never persisted into the snapshot.
616 *
617 * @param array $options Raw option array
618 * @return array Redacted copy
619 */
620 private function redact_sensitive_keys(array $options): array {
621 $clean = [];
622 foreach ($options as $key => $value) {
623 if (is_string($key) && preg_match(self::SENSITIVE_KEY_PATTERN, $key)) {
624 continue;
625 }
626 $clean[$key] = is_array($value) ? $this->redact_sensitive_keys($value) : $value;
627 }
628
629 return $clean;
630 }
631
632 /**
633 * Normalize Rank Math's knowledgegraph_type to ThinkRank's entity vocabulary.
634 *
635 * Rank Math stores 'company' or 'person'; treat 'organization' as a company
636 * alias defensively. Unknown/absent values return '' so the migrator skips.
637 *
638 * @param mixed $value Raw knowledgegraph_type value
639 * @return string 'organization', 'person' or ''
640 */
641 private function normalize_knowledgegraph_type($value): string {
642 $type = strtolower(trim((string) $value));
643 if ($type === 'person') {
644 return 'person';
645 }
646 if ($type === 'company' || $type === 'organization') {
647 return 'organization';
648 }
649
650 return '';
651 }
652
653 /**
654 * Read the IndexNow API key from Rank Math's Instant Indexing storage.
655 *
656 * The module (and the standalone Rank Math "Instant Indexing" plugin) has
657 * stored the key under different option names/keys across versions, so
658 * inspect the known candidates defensively and take the first non-empty.
659 *
660 * @return string API key, or '' when none configured
661 */
662 private function extract_rm_indexnow_key(): string {
663 $option_names = ['rank-math-options-instant-indexing', 'rank_math_instant_indexing'];
664 $key_candidates = ['indexnow_api_key', 'api_key', 'indexnow_key'];
665
666 foreach ($option_names as $option_name) {
667 $settings = get_option($option_name, []);
668 if (!is_array($settings)) {
669 continue;
670 }
671 foreach ($key_candidates as $key) {
672 if (!empty($settings[$key]) && is_string($settings[$key])) {
673 return trim($settings[$key]);
674 }
675 }
676 }
677
678 return '';
679 }
680
681 /**
682 * {@inheritDoc}
683 */
684 protected function export_redirections_page(int $page): array {
685 // Rank Math stores redirections in its own table
686 global $wpdb;
687
688 $table_name = $wpdb->prefix . 'rank_math_redirections';
689 $table_exists = $wpdb->get_var(
690 $wpdb->prepare("SHOW TABLES LIKE %s", $table_name)
691 );
692
693 if (!$table_exists) {
694 return [];
695 }
696
697 $offset = ($page - 1) * $this->chunk_size;
698
699 $rows = $wpdb->get_results(
700 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
701 $wpdb->prepare(
702 "SELECT * FROM {$table_name} ORDER BY id ASC LIMIT %d OFFSET %d",
703 $this->chunk_size,
704 $offset
705 ),
706 ARRAY_A
707 );
708 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
709
710 // One rule can fan out into several records, so pagination must key off
711 // the number of ROWS fetched, not the number of records emitted.
712 $this->last_page_row_count = is_array($rows) ? count($rows) : 0;
713
714 if (empty($rows)) {
715 return [];
716 }
717
718 $records = [];
719 foreach ($rows as $row) {
720 // Rank Math stores `sources` as a serialized list of
721 // ['pattern' => …, 'comparison' => exact|contains|start|end|regex]
722 // rows — ONE rule can match many source URLs. ThinkRank's redirect
723 // table is one row per source, so fan each source out into its own
724 // record rather than keeping only the first (which silently dropped
725 // every additional source).
726 $sources = Safe_Unserializer::unserialize($row['sources'] ?? '');
727 if (!is_array($sources) || empty($sources)) {
728 continue;
729 }
730
731 foreach ($sources as $source) {
732 if (!is_array($source)) {
733 continue;
734 }
735
736 $pattern = trim((string) ($source['pattern'] ?? ''));
737 if ($pattern === '') {
738 continue;
739 }
740
741 $comparison = strtolower((string) ($source['comparison'] ?? 'exact'));
742
743 $records[] = [
744 'object_type' => 'redirection',
745 'source_plugin' => $this->plugin_slug,
746 'data' => [],
747 'extended' => [
748 'source_url' => $pattern,
749 'target_url' => $row['url_to'] ?? '',
750 'http_code' => (int) ($row['header_code'] ?? 301),
751 'match_type' => $this->map_rm_match_type($comparison),
752 // Retained for readers that predate `match_type`.
753 'is_regex' => $comparison === 'regex',
754 'enabled' => ($row['status'] ?? 'active') === 'active',
755 'hits' => (int) ($row['hits'] ?? 0),
756 'created_at' => $this->normalize_rm_datetime($row['created'] ?? ''),
757 'last_accessed' => $this->normalize_rm_datetime($row['last_accessed'] ?? ''),
758 ],
759 ];
760 }
761 }
762
763 return $records;
764 }
765
766 /**
767 * {@inheritDoc}
768 *
769 * `content_blocks` records carry post ids only. The blocks themselves stay
770 * in `post_content` and are rewritten in place at migrate time — copying
771 * the content into the snapshot would mean holding a second copy of every
772 * FAQ page in `wp_options`, which is where snapshots live.
773 */
774 protected function export_custom_type_page(string $type, int $page): array {
775 if (Block_Converter::TYPE !== $type) {
776 return parent::export_custom_type_page($type, $page);
777 }
778
779 $post_ids = Block_Converter::get_post_ids($page, $this->chunk_size);
780
781 // export_chunk() decides whether another page follows from the number
782 // of rows the query returned, so report it even when nothing is emitted.
783 $this->last_page_row_count = count($post_ids);
784
785 $records = [];
786 foreach ($post_ids as $post_id) {
787 $records[] = [
788 'object_id' => $post_id,
789 'object_type' => 'post',
790 'source_plugin' => $this->plugin_slug,
791 'data' => ['post_id' => $post_id],
792 ];
793 }
794
795 return $records;
796 }
797
798 /**
799 * {@inheritDoc}
800 *
801 * Rank Math's 404 monitor keeps one row per URI in `rank_math_404_logs`,
802 * which lines up with ThinkRank Pro's `thinkrank_404_logs`.
803 */
804 protected function export_404_logs_page(int $page): array {
805 global $wpdb;
806
807 $table_name = $wpdb->prefix . 'rank_math_404_logs';
808 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
809 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table_name));
810 if (!$table_exists) {
811 return [];
812 }
813
814 $offset = ($page - 1) * $this->chunk_size;
815
816 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
817 $rows = $wpdb->get_results(
818 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
819 $wpdb->prepare(
820 "SELECT * FROM {$table_name} ORDER BY id ASC LIMIT %d OFFSET %d",
821 $this->chunk_size,
822 $offset
823 ),
824 ARRAY_A
825 );
826 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
827
828 $this->last_page_row_count = is_array($rows) ? count($rows) : 0;
829
830 if (empty($rows)) {
831 return [];
832 }
833
834 $records = [];
835 foreach ($rows as $row) {
836 $uri = trim((string) ($row['uri'] ?? ''));
837 if ($uri === '') {
838 continue;
839 }
840
841 $records[] = [
842 'object_type' => '404_log',
843 'source_plugin' => $this->plugin_slug,
844 'data' => [],
845 'extended' => [
846 'uri' => $uri,
847 'times_accessed' => max(1, (int) ($row['times_accessed'] ?? 1)),
848 'referer' => (string) ($row['referer'] ?? ''),
849 'user_agent' => (string) ($row['user_agent'] ?? ''),
850 'last_accessed' => $this->normalize_rm_datetime($row['accessed'] ?? ''),
851 ],
852 ];
853 }
854
855 return $records;
856 }
857
858 /**
859 * Map a Rank Math redirection `comparison` onto ThinkRank Pro's match_type.
860 *
861 * Rank Math's vocabulary is exact|contains|start|end|regex; ThinkRank Pro
862 * uses the same five, so this mostly normalizes and guards unknown values
863 * (Rank Math also had a legacy 'exact' alias set).
864 *
865 * @param string $comparison Rank Math comparison slug
866 * @return string ThinkRank match type
867 */
868 private function map_rm_match_type(string $comparison): string {
869 $supported = ['exact', 'contains', 'start', 'end', 'regex'];
870
871 return in_array($comparison, $supported, true) ? $comparison : 'exact';
872 }
873
874 /**
875 * Normalize a Rank Math datetime column into a MySQL datetime string.
876 *
877 * Rank Math's tables default these columns to the zero date
878 * ('0000-00-00 00:00:00'), which MySQL rejects on insert under strict mode.
879 *
880 * @param mixed $value Raw column value
881 * @return string Valid `Y-m-d H:i:s` string, or '' when unusable
882 */
883 private function normalize_rm_datetime($value): string {
884 $value = trim((string) $value);
885 if ($value === '' || strpos($value, '0000-00-00') === 0) {
886 return '';
887 }
888
889 $timestamp = strtotime($value);
890
891 return $timestamp ? gmdate('Y-m-d H:i:s', $timestamp) : '';
892 }
893
894 /**
895 * {@inheritDoc}
896 */
897 /**
898 * Map a Rank Math rich-snippet slug to ThinkRank's schema-type vocabulary.
899 * Unknown slugs and the 'off'/'none' sentinels return '' (no schema), which
900 * the migrator skips like any empty value.
901 *
902 * @param mixed $rm_value Raw rank_math_rich_snippet value
903 * @return string ThinkRank schema type, or '' when unmapped/disabled
904 */
905 private function map_schema_type($rm_value): string {
906 $key = strtolower(trim((string) $rm_value));
907 if ($key === '' || $key === 'off' || $key === 'none') {
908 return '';
909 }
910
911 return self::SCHEMA_TYPE_MAP[$key] ?? '';
912 }
913
914 /**
915 * Resolve a post's ThinkRank schema type from Rank Math meta.
916 *
917 * Prefers the legacy `rank_math_rich_snippet` slug when present, then falls
918 * back to Rank Math's modern per-post schema storage (`rank_math_schema_*`),
919 * which is what current Rank Math versions actually write. Returns '' when no
920 * supported schema is found (the migrator skips empty schema types).
921 *
922 * @param array $meta All Rank Math meta for a post
923 * @return string ThinkRank schema type, or ''
924 */
925 private function resolve_schema_type(array $meta): string {
926 $legacy = $this->map_schema_type($meta['rank_math_rich_snippet'] ?? '');
927 if ($legacy !== '') {
928 return $legacy;
929 }
930
931 return $this->detect_modern_schema_type($meta);
932 }
933
934 /**
935 * Derive a ThinkRank schema type from Rank Math's modern `rank_math_schema_*`
936 * meta blocks.
937 *
938 * A post may carry several schema blocks (e.g. BlogPosting + VideoObject);
939 * the one flagged `metadata.isPrimary` is preferred. To avoid discarding a
940 * usable type when the primary block is one ThinkRank does not model (e.g. a
941 * primary VideoObject alongside a secondary Article), the first block — in
942 * primary-then-rest order — that maps to a supported type wins.
943 *
944 * @param array $meta All Rank Math meta for a post
945 * @return string ThinkRank schema type, or ''
946 */
947 private function detect_modern_schema_type(array $meta): string {
948 $primary = [];
949 $others = [];
950
951 foreach ($meta as $key => $value) {
952 if (strpos($key, 'rank_math_schema_') !== 0) {
953 continue;
954 }
955
956 $schema = Safe_Unserializer::unserialize($value);
957 if (!is_array($schema)) {
958 continue;
959 }
960
961 // The @type is stored at the block root for most types; older Article
962 // blocks omit it and carry the type only in the meta key suffix.
963 $type_name = (string) ($schema['@type'] ?? substr($key, strlen('rank_math_schema_')));
964
965 // metadata.isPrimary is '1'/true for the primary block, '0'/false (or
966 // absent) otherwise. empty() treats '0', '', false and 0 as not-primary.
967 if (!empty($schema['metadata']['isPrimary'])) {
968 $primary[] = $type_name;
969 } else {
970 $others[] = $type_name;
971 }
972 }
973
974 foreach (array_merge($primary, $others) as $type_name) {
975 $mapped = $this->map_modern_schema_type($type_name);
976 if ($mapped !== '') {
977 return $mapped;
978 }
979 }
980
981 return '';
982 }
983
984 /**
985 * Map a Rank Math modern schema @type (PascalCase) to ThinkRank's vocabulary.
986 *
987 * @param string $type_name Rank Math schema @type
988 * @return string ThinkRank schema type, or '' when unmapped/unsupported
989 */
990 private function map_modern_schema_type(string $type_name): string {
991 $key = strtolower(trim($type_name));
992 if ($key === '') {
993 return '';
994 }
995
996 return self::MODERN_SCHEMA_TYPE_MAP[$key] ?? '';
997 }
998
999 /**
1000 * Resolve a Rank Math TERM title/description value, replacing the
1001 * term-context tokens (%term%, %term_description%) Rank Math uses for term
1002 * archives before delegating to the shared variable resolver. Without this
1003 * the term name is stripped and titles render as "Archives - Site".
1004 *
1005 * @param mixed $value Raw Rank Math term meta value
1006 * @param int $term_id Term ID for context
1007 * @return string Resolved value
1008 */
1009 private function convert_term_template_variables($value, int $term_id): string {
1010 // Same foreign-data rule as convert_template_variables (see abstract).
1011 $value = $this->stringify_template_value($value);
1012 if ($value === '' || strpos($value, '%') === false) {
1013 return $value;
1014 }
1015
1016 $term = get_term($term_id);
1017 if ($term instanceof \WP_Term) {
1018 $value = str_replace(
1019 ['%term%', '%term_description%'],
1020 [$term->name, wp_strip_all_tags((string) term_description($term_id))],
1021 $value
1022 );
1023 }
1024
1025 // Collapse whitespace left where a token (e.g. %page%) resolved to ''.
1026 return trim((string) preg_replace('/\s{2,}/', ' ', $this->convert_template_variables($value)));
1027 }
1028
1029 protected function convert_template_variables($value, ?int $post_id = null): string {
1030 // Foreign data first: booleans/arrays in the source plugin's options
1031 // must degrade to '' here, not fatal the migration (see abstract).
1032 $value = $this->stringify_template_value($value);
1033
1034 if (empty($value) || strpos($value, '%') === false) {
1035 return $value;
1036 }
1037
1038 $replacements = [
1039 '%sitename%' => get_bloginfo('name'),
1040 '%sitedesc%' => get_bloginfo('description'),
1041 '%sep%' => '-',
1042 '%page%' => '',
1043 '%currentyear%' => gmdate('Y'),
1044 '%currentdate%' => gmdate('Y-m-d'),
1045 '%currentmonth%' => gmdate('F'),
1046 '%currentday%' => gmdate('j'),
1047 ];
1048
1049 if ($post_id) {
1050 $post = get_post($post_id);
1051 if ($post) {
1052 $replacements['%title%'] = $post->post_title;
1053 $replacements['%excerpt%'] = \ThinkRank\Core\Seo_Text::trim_words(
1054 $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words(wp_strip_all_tags($post->post_content), 55),
1055 55
1056 );
1057 $replacements['%date%'] = get_the_date('', $post);
1058 $replacements['%modified%'] = get_the_modified_date('', $post);
1059 $replacements['%id%'] = (string) $post_id;
1060 $replacements['%name%'] = get_the_author_meta('display_name', (int) $post->post_author);
1061
1062 $post_type_obj = get_post_type_object($post->post_type);
1063 $replacements['%pt_single%'] = $post_type_obj ? $post_type_obj->labels->singular_name : '';
1064 $replacements['%pt_plural%'] = $post_type_obj ? $post_type_obj->labels->name : '';
1065
1066 $categories = get_the_category($post_id);
1067 $replacements['%category%'] = !empty($categories) ? $categories[0]->name : '';
1068 $replacements['%categories%'] = !empty($categories) ? implode(', ', wp_list_pluck($categories, 'name')) : '';
1069
1070 $tags = get_the_tags($post_id);
1071 $replacements['%tag%'] = !empty($tags) ? $tags[0]->name : '';
1072 $replacements['%tags%'] = !empty($tags) ? implode(', ', wp_list_pluck($tags, 'name')) : '';
1073 }
1074 }
1075
1076 $value = str_replace(array_keys($replacements), array_values($replacements), $value);
1077
1078 // Strip remaining unknown %variable% patterns (single percent)
1079 // Be careful not to strip legitimate percent signs
1080 $value = preg_replace('/%[a-z0-9_]+%/i', '', $value);
1081
1082 return trim($value);
1083 }
1084
1085 /**
1086 * Convert a Rank Math title/description TEMPLATE into ThinkRank's Global SEO
1087 * token vocabulary, preserving structural tokens (do NOT resolve to literal
1088 * values — these templates apply to every post of the type).
1089 *
1090 * ThinkRank's Global SEO engine understands: %title%, %sitename%, %sep%,
1091 * %excerpt%, %date%, %modified%, %author%, %category%. Rank Math tokens with
1092 * a direct equivalent are renamed; tokens ThinkRank cannot resolve (e.g.
1093 * %page%, %pt_single%, %currentyear%) are stripped so they never render
1094 * literally on the frontend.
1095 *
1096 * @param mixed $template Raw Rank Math template
1097 * @return string ThinkRank-compatible template
1098 */
1099 private function convert_template_tokens($template): string {
1100 // Foreign data first: booleans/arrays in the source plugin's options
1101 // must degrade to '' here, not fatal the migration (see abstract).
1102 $template = $this->stringify_template_value($template);
1103
1104 if (empty($template) || strpos($template, '%') === false) {
1105 return $template;
1106 }
1107
1108 // Rank Math token => ThinkRank Global SEO token (structure preserved).
1109 $token_map = [
1110 '%name%' => '%author%', // Rank Math author display name token
1111 // WooCommerce product variables. Rank Math lets a product title or
1112 // description carry the price, SKU and stock status; every one of
1113 // them used to be stripped by the pass below, so "Buy %title% for
1114 // %wc_price%" imported as "Buy %title% for" (#715).
1115 '%wc_price%' => '%price%',
1116 '%wc_sku%' => '%sku%',
1117 '%wc_shortdesc%' => '%short_description%',
1118 '%wc_brand%' => '%brand%',
1119 ];
1120 $template = str_replace(array_keys($token_map), array_values($token_map), $template);
1121
1122 // Tokens ThinkRank's Global SEO engine resolves natively — keep as-is.
1123 $supported = [
1124 '%title%', '%sitename%', '%sep%', '%excerpt%', '%date%', '%modified%', '%author%', '%category%',
1125 // Resolved by Pattern_Resolver on a product, empty elsewhere.
1126 '%price%', '%sale_price%', '%sku%', '%stock_status%', '%short_description%', '%brand%',
1127 ];
1128
1129 // Strip any token ThinkRank cannot resolve so it does not render literally.
1130 $template = preg_replace_callback(
1131 '/%[a-z0-9_]+%/i',
1132 static function (array $m) use ($supported): string {
1133 return in_array(strtolower($m[0]), $supported, true) ? $m[0] : '';
1134 },
1135 $template
1136 );
1137
1138 // Collapse whitespace left by stripped tokens (e.g. "%title% %page% %sep%").
1139 $template = preg_replace('/\s{2,}/', ' ', (string) $template);
1140
1141 return trim((string) $template);
1142 }
1143
1144 /**
1145 * Convert a Rank Math image alt/title FORMAT into ThinkRank's Image SEO token
1146 * vocabulary, preserving structure.
1147 *
1148 * ThinkRank's Image SEO engine resolves: %title%, %sitename%, %site_title%,
1149 * %sep%, %separator%, %count%, %filename%, %image_title%, %image_caption%.
1150 * Rank Math's counter tokens %count(alt)% / %count(title)% become %count%;
1151 * tokens with no equivalent are stripped so they never render literally.
1152 *
1153 * @param string $format Raw Rank Math image format
1154 * @return string ThinkRank-compatible image format
1155 */
1156 private function convert_image_tokens($format): string {
1157 // Foreign data first: booleans/arrays in the source plugin's options
1158 // must degrade to '' here, not fatal the migration (see abstract).
1159 $format = $this->stringify_template_value($format);
1160
1161 if ($format === '' || strpos($format, '%') === false) {
1162 return $format;
1163 }
1164
1165 // Rank Math counter tokens carry a parenthesised argument, e.g. %count(alt)%.
1166 $format = preg_replace('/%count\([a-z]+\)%/i', '%count%', $format);
1167 $format = str_replace('%name%', '', (string) $format);
1168
1169 $supported = ['%title%', '%sitename%', '%site_title%', '%sep%', '%separator%', '%count%', '%filename%', '%image_title%', '%image_caption%'];
1170 $format = preg_replace_callback(
1171 '/%[a-z0-9_]+%/i',
1172 static function (array $m) use ($supported): string {
1173 return in_array(strtolower($m[0]), $supported, true) ? $m[0] : '';
1174 },
1175 (string) $format
1176 );
1177
1178 $format = preg_replace('/\s{2,}/', ' ', (string) $format);
1179
1180 return trim((string) $format);
1181 }
1182
1183 /**
1184 * Extract schema details from Rank Math meta
1185 *
1186 * @param array $meta All Rank Math meta for a post
1187 * @return array Schema details
1188 */
1189 private function extract_schema_details(array $meta): array {
1190 $details = [];
1191
1192 foreach ($meta as $key => $value) {
1193 if (strpos($key, 'rank_math_schema_') === 0) {
1194 $schema_key = str_replace('rank_math_schema_', '', $key);
1195 // Schema blocks are always arrays; anything else is malformed
1196 // source data and must not travel further into the migrator.
1197 $details[$schema_key] = Safe_Unserializer::to_array($value);
1198 }
1199 }
1200
1201 return $details;
1202 }
1203
1204 /**
1205 * Extract Rank Math's review rich-snippet rating fields into ThinkRank's
1206 * Review schema-form vocabulary. Returned keys match the `review_*` fields
1207 * the schema builder reads; empty values are omitted so the migrator only
1208 * writes meaningful data. Returns [] when the post is not a review snippet.
1209 *
1210 * @param array $meta All Rank Math meta for a post
1211 * @return array Review form data (review_rating_value, review_best_rating, ...)
1212 */
1213 private function extract_review_schema(array $meta): array {
1214 if (($meta['rank_math_rich_snippet'] ?? '') !== 'review') {
1215 return [];
1216 }
1217
1218 $map = [
1219 'rank_math_snippet_name' => 'review_item_name',
1220 'rank_math_snippet_desc' => 'review_body',
1221 'rank_math_snippet_review_rating_value' => 'review_rating_value',
1222 'rank_math_snippet_review_best_rating' => 'review_best_rating',
1223 'rank_math_snippet_review_worst_rating' => 'review_worst_rating',
1224 ];
1225
1226 $review = [];
1227 foreach ($map as $rm_key => $tr_key) {
1228 $value = $meta[$rm_key] ?? '';
1229 if ($value !== '' && $value !== null) {
1230 $review[$tr_key] = $value;
1231 }
1232 }
1233
1234 return $review;
1235 }
1236
1237 /**
1238 * Extract Rank Math's modern VideoObject schema block into ThinkRank's
1239 * `video_*` schema-form vocabulary. Picks the primary VideoObject block (or
1240 * the first one), resolves text tokens, and drops any value still carrying an
1241 * unresolved Rank Math token (e.g. `%post_thumbnail%`) so the schema builder
1242 * falls back to the post's own data. Returns [] when no VideoObject block
1243 * exists or nothing meaningful survives.
1244 *
1245 * @param array $meta All Rank Math meta for a post
1246 * @param int $post_id Post ID for token resolution
1247 * @return array Video form data (video_name, video_embed_url, ...)
1248 */
1249 private function extract_video_schema(array $meta, int $post_id): array {
1250 $block = null;
1251 foreach ($meta as $key => $value) {
1252 if (strpos($key, 'rank_math_schema_') !== 0) {
1253 continue;
1254 }
1255 $schema = Safe_Unserializer::unserialize($value);
1256 if (!is_array($schema)) {
1257 continue;
1258 }
1259 $type = strtolower((string) ($schema['@type'] ?? substr($key, strlen('rank_math_schema_'))));
1260 if ($type !== 'videoobject') {
1261 continue;
1262 }
1263 // Prefer the primary block; otherwise keep the first one seen.
1264 if (!empty($schema['metadata']['isPrimary'])) {
1265 $block = $schema;
1266 break;
1267 }
1268 $block = $block ?? $schema;
1269 }
1270
1271 if (!is_array($block)) {
1272 return [];
1273 }
1274
1275 // Block field => ThinkRank form field. Text fields are run through the
1276 // template-variable resolver; URL/date fields are taken verbatim.
1277 $text_fields = [
1278 'name' => 'video_name',
1279 'description' => 'video_description',
1280 ];
1281 $raw_fields = [
1282 'contentUrl' => 'video_content_url',
1283 'embedUrl' => 'video_embed_url',
1284 'duration' => 'video_duration',
1285 'uploadDate' => 'video_upload_date',
1286 'thumbnailUrl' => 'video_thumbnail',
1287 ];
1288
1289 $video = [];
1290
1291 foreach ($text_fields as $block_key => $tr_key) {
1292 $value = $this->convert_template_variables((string) ($block[$block_key] ?? ''), $post_id);
1293 if ($value !== '' && strpos($value, '%') === false) {
1294 $video[$tr_key] = $value;
1295 }
1296 }
1297
1298 foreach ($raw_fields as $block_key => $tr_key) {
1299 $value = (string) ($block[$block_key] ?? '');
1300 // Drop unresolved tokens (e.g. %post_thumbnail%, %date(...)%) so the
1301 // builder's own fallback (featured image, publish date) applies.
1302 if ($value !== '' && strpos($value, '%') === false) {
1303 $video[$tr_key] = $value;
1304 }
1305 }
1306
1307 return $video;
1308 }
1309
1310 /**
1311 * Extract the boolean robots flags that Rank Math stores inside the
1312 * `rank_math_robots` indexed array (alongside index/noindex/nofollow).
1313 *
1314 * @param mixed $robots_raw Raw (serialized) rank_math_robots value
1315 * @return array Map of present flag => true (noarchive/noimageindex/nosnippet)
1316 */
1317 private function extract_robots_flags($robots_raw): array {
1318 $robots = Safe_Unserializer::unserialize($robots_raw);
1319 if (!is_array($robots)) {
1320 return [];
1321 }
1322
1323 $flags = [];
1324 foreach (['noarchive', 'noimageindex', 'nosnippet'] as $flag) {
1325 if (in_array($flag, $robots, true)) {
1326 $flags[$flag] = true;
1327 }
1328 }
1329
1330 return $flags;
1331 }
1332
1333 /**
1334 * Normalize Rank Math's pillar/cornerstone content flag to 0 or 1.
1335 *
1336 * Rank Math stores the enabled flag as the string 'on' (its checkbox value).
1337 * A plain (int) cast of 'on' yields 0, which silently drops the flag during
1338 * migration — so it must be matched against Rank Math's truthy representations.
1339 *
1340 * @param mixed $value Raw rank_math_pillar_content meta value
1341 * @return int 0 or 1
1342 */
1343 private function normalize_pillar_content($value): int {
1344 return in_array($value, ['on', '1', 1, true], true) ? 1 : 0;
1345 }
1346
1347 /**
1348 * Decode the `rank_math_advanced_robots` post meta.
1349 *
1350 * Rank Math stores it as an associative array keyed by directive, where the
1351 * value is the configured length/value or `false` when the directive is
1352 * disabled, e.g. ['max-snippet' => '120', 'max-video-preview' => false,
1353 * 'max-image-preview' => 'large'].
1354 *
1355 * @param mixed $raw Raw (serialized) meta value
1356 * @return array Associative directive => value map (empty when unset)
1357 */
1358 private function parse_advanced_robots_meta($raw): array {
1359 $advanced = Safe_Unserializer::unserialize($raw);
1360 return is_array($advanced) ? $advanced : [];
1361 }
1362
1363 /**
1364 * Read an integer advanced-robots directive (max-snippet, max-video-preview).
1365 *
1366 * Returns an empty-string sentinel when the directive is unset or disabled
1367 * so the migrator skips it rather than forcing a 0 value (which would read
1368 * as "no snippet").
1369 *
1370 * @param array $advanced Decoded rank_math_advanced_robots map
1371 * @param string $key Directive key
1372 * @return int|string Integer value, or '' when unset/disabled
1373 */
1374 private function advanced_robot_int(array $advanced, string $key) {
1375 if (!isset($advanced[$key]) || $advanced[$key] === false || $advanced[$key] === '') {
1376 return '';
1377 }
1378 return (int) $advanced[$key];
1379 }
1380
1381 /**
1382 * Read a string advanced-robots directive (max-image-preview).
1383 *
1384 * @param array $advanced Decoded rank_math_advanced_robots map
1385 * @param string $key Directive key
1386 * @return string Directive value, or '' when unset/disabled
1387 */
1388 private function advanced_robot_string(array $advanced, string $key): string {
1389 if (!isset($advanced[$key]) || $advanced[$key] === false) {
1390 return '';
1391 }
1392 return (string) $advanced[$key];
1393 }
1394
1395 /**
1396 * Extract the local business phone from Rank Math titles.
1397 *
1398 * Rank Math stores local phones under `phone_numbers` (an array of
1399 * ['type' => ..., 'number' => ...]); older/knowledge-graph setups use a
1400 * flat `phone`. Prefer the first structured number, fall back to `phone`.
1401 *
1402 * @param array $titles Rank Math titles option
1403 * @return string
1404 */
1405 private function extract_rm_local_phone(array $titles): string {
1406 $numbers = $titles['phone_numbers'] ?? [];
1407 if (is_array($numbers)) {
1408 foreach ($numbers as $entry) {
1409 if (is_array($entry) && !empty($entry['number'])) {
1410 return (string) $entry['number'];
1411 }
1412 }
1413 }
1414
1415 return (string) ($titles['phone'] ?? '');
1416 }
1417
1418 /**
1419 * Convert Rank Math opening hours into ThinkRank's Business Info format.
1420 *
1421 * Rank Math stores `opening_hours` as a group of ['day' => 'Monday',
1422 * 'time' => '09:00-17:00'] rows (24h H:i). ThinkRank stores hours keyed by
1423 * lowercase day: ['monday' => ['open' => 'HH:MM', 'close' => 'HH:MM',
1424 * 'closed' => bool]]. ThinkRank supports a single range per day, so the
1425 * first parseable row for each day wins (Rank Math's optional mid-day-break
1426 * second row is dropped). Days with no configured row are omitted.
1427 *
1428 * @param array $titles Rank Math titles option
1429 * @return array
1430 */
1431 private function extract_rm_opening_hours(array $titles): array {
1432 $hours = $titles['opening_hours'] ?? [];
1433 if (!is_array($hours) || empty($hours)) {
1434 return [];
1435 }
1436
1437 $valid_days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
1438 $result = [];
1439
1440 foreach ($hours as $entry) {
1441 if (!is_array($entry) || empty($entry['day']) || empty($entry['time'])) {
1442 continue;
1443 }
1444
1445 $day = strtolower((string) $entry['day']);
1446 if (!in_array($day, $valid_days, true) || isset($result[$day])) {
1447 continue;
1448 }
1449
1450 // Parse "HH:MM-HH:MM" (allow single-digit hours and en dash).
1451 if (!preg_match('/^\s*(\d{1,2}:\d{2})\s*[-\x{2013}]\s*(\d{1,2}:\d{2})\s*$/u', (string) $entry['time'], $m)) {
1452 continue;
1453 }
1454
1455 $result[$day] = [
1456 'open' => $this->pad_time_hh_mm($m[1]),
1457 'close' => $this->pad_time_hh_mm($m[2]),
1458 'closed' => false,
1459 ];
1460 }
1461
1462 return $result;
1463 }
1464
1465 /**
1466 * Zero-pad the hour component of an "H:MM" time to "HH:MM".
1467 *
1468 * @param string $time Time string like "9:00" or "09:00"
1469 * @return string
1470 */
1471 private function pad_time_hh_mm(string $time): string {
1472 $parts = explode(':', $time);
1473 if (count($parts) !== 2) {
1474 return $time;
1475 }
1476
1477 return str_pad($parts[0], 2, '0', STR_PAD_LEFT) . ':' . $parts[1];
1478 }
1479
1480 /**
1481 * Extract the local business postal address from Rank Math titles.
1482 *
1483 * Rank Math's `local_address` is a schema.org PostalAddress array
1484 * (streetAddress/addressLocality/addressRegion/postalCode/addressCountry).
1485 * Returns a normalized array keyed for ThinkRank's Business Info fields, or
1486 * an empty array when nothing is set.
1487 *
1488 * @param array $titles Rank Math titles option
1489 * @return array
1490 */
1491 private function extract_rm_local_address(array $titles): array {
1492 $address = $titles['local_address'] ?? [];
1493 if (!is_array($address) || empty($address)) {
1494 return [];
1495 }
1496
1497 $map = [
1498 'street' => 'streetAddress',
1499 'city' => 'addressLocality',
1500 'state' => 'addressRegion',
1501 'postal_code' => 'postalCode',
1502 'country' => 'addressCountry',
1503 ];
1504
1505 $result = [];
1506 foreach ($map as $target => $rm_key) {
1507 if (!empty($address[$rm_key])) {
1508 $result[$target] = (string) $address[$rm_key];
1509 }
1510 }
1511
1512 return $result;
1513 }
1514
1515 /**
1516 * Extract geo coordinates from Rank Math titles.
1517 *
1518 * Rank Math's `geo` is a single "latitude,longitude" string. Returns
1519 * ['latitude' => ..., 'longitude' => ...] when both parse, else [].
1520 *
1521 * @param array $titles Rank Math titles option
1522 * @return array
1523 */
1524 private function extract_rm_local_geo(array $titles): array {
1525 $geo = trim((string) ($titles['geo'] ?? ''));
1526 if ($geo === '') {
1527 return [];
1528 }
1529
1530 $parts = preg_split('/[\s,]+/', $geo);
1531 if (!is_array($parts) || !isset($parts[0], $parts[1]) || $parts[0] === '' || $parts[1] === '') {
1532 return [];
1533 }
1534
1535 if (!is_numeric($parts[0]) || !is_numeric($parts[1])) {
1536 return [];
1537 }
1538
1539 return [
1540 'latitude' => (string) $parts[0],
1541 'longitude' => (string) $parts[1],
1542 ];
1543 }
1544
1545 /**
1546 * Map Rank Math's per-context title formats onto ThinkRank's Site Identity
1547 * title-format keys (homepage_title, post_title, page_title, category_title,
1548 * tag_title, search_title, archive_title).
1549 *
1550 * These are a DIFFERENT token dialect from `post_type_settings` above: the
1551 * Site Identity renderer resolves %site_title%/%post_title%/%category_title%/
1552 * %tag_title%/%search_term%/%archive_title%/%sep%, whereas the Global SEO
1553 * renderer resolves %title%/%sitename%/%excerpt%. Converting with the wrong
1554 * dialect renders the token literally, so each context is converted with the
1555 * matching context token.
1556 *
1557 * @param array $titles Rank Math titles option
1558 * @return array Map of ThinkRank title-format key => converted template
1559 */
1560 private function extract_rm_title_formats(array $titles): array {
1561 // ThinkRank key => [Rank Math key, the %…% token Rank Math's %title%/%term%
1562 // stands for in that context].
1563 $map = [
1564 'homepage_title' => ['homepage_title', ''],
1565 'post_title' => ['pt_post_title', '%post_title%'],
1566 'page_title' => ['pt_page_title', '%page_title%'],
1567 'category_title' => ['tax_category_title', '%category_title%'],
1568 'tag_title' => ['tax_post_tag_title', '%tag_title%'],
1569 'search_title' => ['search_title', '%search_term%'],
1570 'archive_title' => ['date_archive_title', '%archive_title%'],
1571 ];
1572
1573 $formats = [];
1574 foreach ($map as $tr_key => [$rm_key, $context_token]) {
1575 $raw = (string) ($titles[$rm_key] ?? '');
1576 if ($raw === '') {
1577 continue;
1578 }
1579
1580 $converted = $this->convert_identity_tokens($raw, $context_token);
1581 if ($converted !== '') {
1582 $formats[$tr_key] = $converted;
1583 }
1584 }
1585
1586 return $formats;
1587 }
1588
1589 /**
1590 * Convert a Rank Math title template into ThinkRank's Site Identity token
1591 * vocabulary, preserving structure.
1592 *
1593 * The Site Identity renderer resolves: %site_title%, %site_description%,
1594 * %tagline%, %sep%/%separator%, %date%, plus the per-context tokens
1595 * %post_title%, %page_title%, %category_title%, %tag_title%, %search_term%,
1596 * %archive_title%, %author_name%. Rank Math's context-neutral %title% /
1597 * %term% become the caller-supplied $context_token; anything ThinkRank
1598 * cannot resolve is stripped so it never renders literally.
1599 *
1600 * @param string $template Raw Rank Math template
1601 * @param string $context_token Token %title%/%term% stands for here (may be '')
1602 * @return string ThinkRank Site Identity template
1603 */
1604 private function convert_identity_tokens(string $template, string $context_token): string {
1605 if ($template === '' || strpos($template, '%') === false) {
1606 return trim($template);
1607 }
1608
1609 $token_map = [
1610 '%sitename%' => '%site_title%',
1611 '%sitedesc%' => '%site_description%',
1612 '%name%' => '%author_name%',
1613 '%search_query%' => '%search_term%',
1614 ];
1615 if ($context_token !== '') {
1616 $token_map['%title%'] = $context_token;
1617 $token_map['%term%'] = $context_token;
1618 }
1619 $template = str_replace(array_keys($token_map), array_values($token_map), $template);
1620
1621 $supported = [
1622 '%site_title%', '%site_description%', '%tagline%', '%sep%', '%separator%',
1623 '%date%', '%post_title%', '%page_title%', '%category_title%', '%tag_title%',
1624 '%search_term%', '%archive_title%', '%author_name%',
1625 ];
1626
1627 $template = preg_replace_callback(
1628 '/%[a-z0-9_]+%/i',
1629 static function (array $m) use ($supported): string {
1630 return in_array(strtolower($m[0]), $supported, true) ? $m[0] : '';
1631 },
1632 $template
1633 );
1634
1635 // Collapse whitespace left by stripped tokens, then drop a separator that
1636 // ended up leading/trailing because the token beside it was removed.
1637 $template = preg_replace('/\s{2,}/', ' ', (string) $template);
1638 $template = trim((string) $template);
1639 $template = preg_replace('/^(?:%sep%|%separator%)\s*/', '', $template);
1640 $template = preg_replace('/\s*(?:%sep%|%separator%)$/', '', (string) $template);
1641
1642 return trim((string) $template);
1643 }
1644
1645 /**
1646 * Extract Rank Math's author-archive behaviour for ThinkRank's Author
1647 * Archives feature (author_archives_enabled / _title / _meta_desc).
1648 *
1649 * @param array $titles Rank Math titles option
1650 * @return array Author archive settings
1651 */
1652 private function extract_rm_author_archives(array $titles): array {
1653 return [
1654 // Rank Math DISABLES archives with this flag; ThinkRank stores the
1655 // positive `enabled`, so invert.
1656 'enabled' => ($titles['disable_author_archives'] ?? 'off') !== 'on',
1657 'title' => $this->convert_identity_tokens((string) ($titles['author_archive_title'] ?? ''), '%author_name%'),
1658 'description' => $this->convert_identity_tokens((string) ($titles['author_archive_description'] ?? ''), '%author_name%'),
1659 ];
1660 }
1661
1662 /**
1663 * Read the post types Rank Math's Instant Indexing module auto-submits.
1664 *
1665 * @return array List of post type slugs (empty when unconfigured)
1666 */
1667 private function extract_rm_indexnow_post_types(): array {
1668 foreach (['rank-math-options-instant-indexing', 'rank_math_instant_indexing'] as $option_name) {
1669 $settings = get_option($option_name, []);
1670 if (!is_array($settings)) {
1671 continue;
1672 }
1673 foreach (['bing_post_types', 'indexnow_post_types', 'post_types'] as $key) {
1674 if (!empty($settings[$key]) && is_array($settings[$key])) {
1675 return array_values(array_map('strval', $settings[$key]));
1676 }
1677 }
1678 }
1679
1680 return [];
1681 }
1682
1683 /**
1684 * Capture Rank Math's IndexNow submission history (`rank_math_indexnow_log`,
1685 * a list of ['url', 'status', 'message', 'time', 'manual_submission']).
1686 *
1687 * Rank Math trims this option itself, but cap it defensively so one site's
1688 * runaway log cannot bloat the settings chunk — and report the drop rather
1689 * than truncating silently.
1690 *
1691 * @return array{entries: array[], truncated: int}
1692 */
1693 private function extract_rm_indexnow_log(): array {
1694 $log = get_option('rank_math_indexnow_log', []);
1695 if (!is_array($log) || empty($log)) {
1696 return ['entries' => [], 'truncated' => 0];
1697 }
1698
1699 // Newest last in Rank Math's log; keep the most recent when capping.
1700 $truncated = max(0, count($log) - self::MAX_INDEXNOW_LOG_ENTRIES);
1701 if ($truncated > 0) {
1702 $log = array_slice($log, -self::MAX_INDEXNOW_LOG_ENTRIES);
1703 }
1704
1705 $entries = [];
1706 foreach ($log as $row) {
1707 if (!is_array($row) || empty($row['url'])) {
1708 continue;
1709 }
1710
1711 $code = (int) ($row['status'] ?? 0);
1712
1713 $entries[] = [
1714 'url' => (string) $row['url'],
1715 // ThinkRank stores a success/failed verdict alongside the raw code.
1716 'status' => ($code >= 200 && $code < 300) ? 'success' : 'failed',
1717 'response_code' => $code,
1718 'response_message' => (string) ($row['message'] ?? ''),
1719 'submitted_at' => !empty($row['time'])
1720 ? gmdate('Y-m-d H:i:s', (int) $row['time'])
1721 : '',
1722 ];
1723 }
1724
1725 return ['entries' => $entries, 'truncated' => $truncated];
1726 }
1727
1728 /**
1729 * Capture the post types Rank Math builds its News / Video sitemaps from,
1730 * for ThinkRank Pro's Publisher Sitemaps.
1731 *
1732 * @param array $sitemap Rank Math sitemap option
1733 * @return array News/Video post type lists (absent keys omitted)
1734 */
1735 private function extract_rm_publisher_sitemaps(array $sitemap): array {
1736 $out = [];
1737
1738 foreach (['video_sitemap_post_type' => 'video_post_types', 'news_sitemap_post_type' => 'news_post_types'] as $rm_key => $tr_key) {
1739 if (empty($sitemap[$rm_key]) || !is_array($sitemap[$rm_key])) {
1740 continue;
1741 }
1742 $out[$tr_key] = array_values(array_map('strval', $sitemap[$rm_key]));
1743 }
1744
1745 return $out;
1746 }
1747
1748 /**
1749 * Map Rank Math's email-report cadence onto ThinkRank's `frequency_days`.
1750 *
1751 * @param string $frequency Rank Math frequency slug
1752 * @return int Days between reports (ThinkRank default 30 when unknown)
1753 */
1754 private function map_rm_email_frequency(string $frequency): int {
1755 switch (strtolower(trim($frequency))) {
1756 case 'daily':
1757 return 1;
1758 case 'weekly':
1759 return 7;
1760 case 'monthly':
1761 return 30;
1762 default:
1763 return 30;
1764 }
1765 }
1766
1767 /**
1768 * Extract post type settings from Rank Math titles options
1769 *
1770 * @param array $titles Rank Math titles option
1771 * @return array Post type settings
1772 */
1773 private function extract_rm_post_type_settings(array $titles): array {
1774 $settings = [];
1775 $post_types = get_post_types(['public' => true], 'names');
1776
1777 foreach ($post_types as $pt) {
1778 $pt_settings = [];
1779 if (isset($titles["pt_{$pt}_title"])) {
1780 $pt_settings['title_template'] = $this->convert_template_tokens($titles["pt_{$pt}_title"]);
1781 }
1782 if (isset($titles["pt_{$pt}_description"])) {
1783 $pt_settings['description_template'] = $this->convert_template_tokens($titles["pt_{$pt}_description"]);
1784 }
1785 if (isset($titles["pt_{$pt}_robots"])) {
1786 $pt_settings['robots'] = Safe_Unserializer::to_array($titles["pt_{$pt}_robots"]);
1787 }
1788 // Rank Math's per-type Link Suggestions toggle maps onto ThinkRank's
1789 // global-SEO `link_suggestions` (which gates the Pillar Content column
1790 // and post-list filter). Only captured when Rank Math stored a value.
1791 if (isset($titles["pt_{$pt}_link_suggestions"])) {
1792 $pt_settings['link_suggestions'] = $titles["pt_{$pt}_link_suggestions"] === 'on';
1793 }
1794 // Rank Math only applies per-post-type robots when "custom robots" is
1795 // enabled for that type; capture the flag so migration does not force
1796 // robots that Rank Math was ignoring.
1797 $pt_settings['custom_robots'] = ($titles["pt_{$pt}_custom_robots"] ?? 'off') === 'on';
1798 // `link_suggestions` is a boolean, so array_key_exists — not !empty —
1799 // decides whether the type is worth emitting (a deliberate "off" is
1800 // exactly the value worth carrying over).
1801 if (!empty($pt_settings['title_template']) || !empty($pt_settings['description_template'])
1802 || !empty($pt_settings['robots']) || array_key_exists('link_suggestions', $pt_settings)) {
1803 $settings[$pt] = $pt_settings;
1804 }
1805 }
1806
1807 return $settings;
1808 }
1809 }
1810