PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.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 1.28.0 1.27.0 1.26.0 1.25.0 All 54 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.13.0, at includes/admin/importers/class-rankmath-exporter.php

1,870 lines 78.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_post_value($meta['rank_math_title'] ?? '', $post_id),
267 'meta_description' => $this->convert_post_value($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_post_value($meta['rank_math_facebook_title'] ?? '', $post_id),
282 'og_description' => $this->convert_post_value($meta['rank_math_facebook_description'] ?? '', $post_id),
283 'og_image' => $meta['rank_math_facebook_image'] ?? '',
284 'twitter_title' => $this->convert_post_value($meta['rank_math_twitter_title'] ?? '', $post_id),
285 'twitter_description' => $this->convert_post_value($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_value($meta['rank_math_title'] ?? '', $term_id),
354 'meta_description' => $this->convert_term_value($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_value($meta['rank_math_facebook_title'] ?? '', $term_id),
360 'og_description' => $this->convert_term_value($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 * Rank Math per-post tokens ThinkRank resolves per request (#886): kept
1001 * as tags rather than frozen into text. Same vocabulary as
1002 * convert_template_tokens().
1003 */
1004 private const POST_TOKENS = [
1005 '%title%' => '%title%',
1006 '%sitename%' => '%sitename%',
1007 '%sep%' => '%sep%',
1008 '%excerpt%' => '%excerpt%',
1009 '%date%' => '%date%',
1010 '%modified%' => '%modified%',
1011 '%name%' => '%author%',
1012 '%category%' => '%category%',
1013 '%wc_price%' => '%price%',
1014 '%wc_sku%' => '%sku%',
1015 '%wc_shortdesc%' => '%short_description%',
1016 '%wc_brand%' => '%brand%',
1017 ];
1018
1019 /**
1020 * Rank Math per-term tokens ThinkRank resolves on a term archive.
1021 */
1022 private const TERM_TOKENS = [
1023 '%term%' => '%term%',
1024 '%term_description%' => '%excerpt%',
1025 '%sitename%' => '%sitename%',
1026 '%sep%' => '%sep%',
1027 ];
1028
1029 /**
1030 * A per-post Rank Math value: mapped tokens as ThinkRank tags, the rest literal.
1031 *
1032 * @param mixed $value Raw Rank Math value
1033 * @param int $post_id Post ID
1034 * @return string
1035 */
1036 private function convert_post_value($value, int $post_id): string {
1037 return $this->tokenize_object_template(
1038 $this->stringify_template_value($value),
1039 self::literal_token_patterns(self::POST_TOKENS),
1040 fn(string $rest): string => $this->convert_template_variables($rest, $post_id)
1041 );
1042 }
1043
1044 /**
1045 * A per-term Rank Math value: mapped tokens as ThinkRank tags, the rest literal.
1046 *
1047 * @param mixed $value Raw Rank Math value
1048 * @param int $term_id Term ID
1049 * @return string
1050 */
1051 private function convert_term_value($value, int $term_id): string {
1052 return $this->tokenize_object_template(
1053 $this->stringify_template_value($value),
1054 self::literal_token_patterns(self::TERM_TOKENS),
1055 fn(string $rest): string => $this->convert_term_template_variables($rest, $term_id)
1056 );
1057 }
1058
1059 /**
1060 * Resolve a Rank Math TERM title/description value, replacing the
1061 * term-context tokens (%term%, %term_description%) Rank Math uses for term
1062 * archives before delegating to the shared variable resolver. Without this
1063 * the term name is stripped and titles render as "Archives - Site".
1064 *
1065 * @param mixed $value Raw Rank Math term meta value
1066 * @param int $term_id Term ID for context
1067 * @return string Resolved value
1068 */
1069 private function convert_term_template_variables($value, int $term_id): string {
1070 // Same foreign-data rule as convert_template_variables (see abstract).
1071 $value = $this->stringify_template_value($value);
1072 if ($value === '' || strpos($value, '%') === false) {
1073 return $value;
1074 }
1075
1076 $term = get_term($term_id);
1077 if ($term instanceof \WP_Term) {
1078 $value = str_replace(
1079 ['%term%', '%term_description%'],
1080 [$term->name, wp_strip_all_tags((string) term_description($term_id))],
1081 $value
1082 );
1083 }
1084
1085 // Collapse whitespace left where a token (e.g. %page%) resolved to ''.
1086 return trim((string) preg_replace('/\s{2,}/', ' ', $this->convert_template_variables($value)));
1087 }
1088
1089 protected function convert_template_variables($value, ?int $post_id = null): string {
1090 // Foreign data first: booleans/arrays in the source plugin's options
1091 // must degrade to '' here, not fatal the migration (see abstract).
1092 $value = $this->stringify_template_value($value);
1093
1094 if (empty($value) || strpos($value, '%') === false) {
1095 return $value;
1096 }
1097
1098 $replacements = [
1099 '%sitename%' => get_bloginfo('name'),
1100 '%sitedesc%' => get_bloginfo('description'),
1101 '%sep%' => '-',
1102 '%page%' => '',
1103 '%currentyear%' => gmdate('Y'),
1104 '%currentdate%' => gmdate('Y-m-d'),
1105 '%currentmonth%' => gmdate('F'),
1106 '%currentday%' => gmdate('j'),
1107 ];
1108
1109 if ($post_id) {
1110 $post = get_post($post_id);
1111 if ($post) {
1112 $replacements['%title%'] = $post->post_title;
1113 $replacements['%excerpt%'] = \ThinkRank\Core\Seo_Text::trim_words(
1114 $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words(wp_strip_all_tags($post->post_content), 55),
1115 55
1116 );
1117 $replacements['%date%'] = get_the_date('', $post);
1118 $replacements['%modified%'] = get_the_modified_date('', $post);
1119 $replacements['%id%'] = (string) $post_id;
1120 $replacements['%name%'] = get_the_author_meta('display_name', (int) $post->post_author);
1121
1122 $post_type_obj = get_post_type_object($post->post_type);
1123 $replacements['%pt_single%'] = $post_type_obj ? $post_type_obj->labels->singular_name : '';
1124 $replacements['%pt_plural%'] = $post_type_obj ? $post_type_obj->labels->name : '';
1125
1126 $categories = get_the_category($post_id);
1127 $replacements['%category%'] = !empty($categories) ? $categories[0]->name : '';
1128 $replacements['%categories%'] = !empty($categories) ? implode(', ', wp_list_pluck($categories, 'name')) : '';
1129
1130 $tags = get_the_tags($post_id);
1131 $replacements['%tag%'] = !empty($tags) ? $tags[0]->name : '';
1132 $replacements['%tags%'] = !empty($tags) ? implode(', ', wp_list_pluck($tags, 'name')) : '';
1133 }
1134 }
1135
1136 $value = str_replace(array_keys($replacements), array_values($replacements), $value);
1137
1138 // Strip remaining unknown %variable% patterns (single percent)
1139 // Be careful not to strip legitimate percent signs
1140 $value = preg_replace('/%[a-z0-9_]+%/i', '', $value);
1141
1142 return trim($value);
1143 }
1144
1145 /**
1146 * Convert a Rank Math title/description TEMPLATE into ThinkRank's Global SEO
1147 * token vocabulary, preserving structural tokens (do NOT resolve to literal
1148 * values — these templates apply to every post of the type).
1149 *
1150 * ThinkRank's Global SEO engine understands: %title%, %sitename%, %sep%,
1151 * %excerpt%, %date%, %modified%, %author%, %category%. Rank Math tokens with
1152 * a direct equivalent are renamed; tokens ThinkRank cannot resolve (e.g.
1153 * %page%, %pt_single%, %currentyear%) are stripped so they never render
1154 * literally on the frontend.
1155 *
1156 * @param mixed $template Raw Rank Math template
1157 * @return string ThinkRank-compatible template
1158 */
1159 private function convert_template_tokens($template): string {
1160 // Foreign data first: booleans/arrays in the source plugin's options
1161 // must degrade to '' here, not fatal the migration (see abstract).
1162 $template = $this->stringify_template_value($template);
1163
1164 if (empty($template) || strpos($template, '%') === false) {
1165 return $template;
1166 }
1167
1168 // Rank Math token => ThinkRank Global SEO token (structure preserved).
1169 $token_map = [
1170 '%name%' => '%author%', // Rank Math author display name token
1171 // WooCommerce product variables. Rank Math lets a product title or
1172 // description carry the price, SKU and stock status; every one of
1173 // them used to be stripped by the pass below, so "Buy %title% for
1174 // %wc_price%" imported as "Buy %title% for" (#715).
1175 '%wc_price%' => '%price%',
1176 '%wc_sku%' => '%sku%',
1177 '%wc_shortdesc%' => '%short_description%',
1178 '%wc_brand%' => '%brand%',
1179 ];
1180 $template = str_replace(array_keys($token_map), array_values($token_map), $template);
1181
1182 // Tokens ThinkRank's Global SEO engine resolves natively — keep as-is.
1183 $supported = [
1184 '%title%', '%sitename%', '%sep%', '%excerpt%', '%date%', '%modified%', '%author%', '%category%',
1185 // Resolved by Pattern_Resolver on a product, empty elsewhere.
1186 '%price%', '%sale_price%', '%sku%', '%stock_status%', '%short_description%', '%brand%',
1187 ];
1188
1189 // Strip any token ThinkRank cannot resolve so it does not render literally.
1190 $template = preg_replace_callback(
1191 '/%[a-z0-9_]+%/i',
1192 static function (array $m) use ($supported): string {
1193 return in_array(strtolower($m[0]), $supported, true) ? $m[0] : '';
1194 },
1195 $template
1196 );
1197
1198 // Collapse whitespace left by stripped tokens (e.g. "%title% %page% %sep%").
1199 $template = preg_replace('/\s{2,}/', ' ', (string) $template);
1200
1201 return trim((string) $template);
1202 }
1203
1204 /**
1205 * Convert a Rank Math image alt/title FORMAT into ThinkRank's Image SEO token
1206 * vocabulary, preserving structure.
1207 *
1208 * ThinkRank's Image SEO engine resolves: %title%, %sitename%, %site_title%,
1209 * %sep%, %separator%, %count%, %filename%, %image_title%, %image_caption%.
1210 * Rank Math's counter tokens %count(alt)% / %count(title)% become %count%;
1211 * tokens with no equivalent are stripped so they never render literally.
1212 *
1213 * @param string $format Raw Rank Math image format
1214 * @return string ThinkRank-compatible image format
1215 */
1216 private function convert_image_tokens($format): string {
1217 // Foreign data first: booleans/arrays in the source plugin's options
1218 // must degrade to '' here, not fatal the migration (see abstract).
1219 $format = $this->stringify_template_value($format);
1220
1221 if ($format === '' || strpos($format, '%') === false) {
1222 return $format;
1223 }
1224
1225 // Rank Math counter tokens carry a parenthesised argument, e.g. %count(alt)%.
1226 $format = preg_replace('/%count\([a-z]+\)%/i', '%count%', $format);
1227 $format = str_replace('%name%', '', (string) $format);
1228
1229 $supported = ['%title%', '%sitename%', '%site_title%', '%sep%', '%separator%', '%count%', '%filename%', '%image_title%', '%image_caption%'];
1230 $format = preg_replace_callback(
1231 '/%[a-z0-9_]+%/i',
1232 static function (array $m) use ($supported): string {
1233 return in_array(strtolower($m[0]), $supported, true) ? $m[0] : '';
1234 },
1235 (string) $format
1236 );
1237
1238 $format = preg_replace('/\s{2,}/', ' ', (string) $format);
1239
1240 return trim((string) $format);
1241 }
1242
1243 /**
1244 * Extract schema details from Rank Math meta
1245 *
1246 * @param array $meta All Rank Math meta for a post
1247 * @return array Schema details
1248 */
1249 private function extract_schema_details(array $meta): array {
1250 $details = [];
1251
1252 foreach ($meta as $key => $value) {
1253 if (strpos($key, 'rank_math_schema_') === 0) {
1254 $schema_key = str_replace('rank_math_schema_', '', $key);
1255 // Schema blocks are always arrays; anything else is malformed
1256 // source data and must not travel further into the migrator.
1257 $details[$schema_key] = Safe_Unserializer::to_array($value);
1258 }
1259 }
1260
1261 return $details;
1262 }
1263
1264 /**
1265 * Extract Rank Math's review rich-snippet rating fields into ThinkRank's
1266 * Review schema-form vocabulary. Returned keys match the `review_*` fields
1267 * the schema builder reads; empty values are omitted so the migrator only
1268 * writes meaningful data. Returns [] when the post is not a review snippet.
1269 *
1270 * @param array $meta All Rank Math meta for a post
1271 * @return array Review form data (review_rating_value, review_best_rating, ...)
1272 */
1273 private function extract_review_schema(array $meta): array {
1274 if (($meta['rank_math_rich_snippet'] ?? '') !== 'review') {
1275 return [];
1276 }
1277
1278 $map = [
1279 'rank_math_snippet_name' => 'review_item_name',
1280 'rank_math_snippet_desc' => 'review_body',
1281 'rank_math_snippet_review_rating_value' => 'review_rating_value',
1282 'rank_math_snippet_review_best_rating' => 'review_best_rating',
1283 'rank_math_snippet_review_worst_rating' => 'review_worst_rating',
1284 ];
1285
1286 $review = [];
1287 foreach ($map as $rm_key => $tr_key) {
1288 $value = $meta[$rm_key] ?? '';
1289 if ($value !== '' && $value !== null) {
1290 $review[$tr_key] = $value;
1291 }
1292 }
1293
1294 return $review;
1295 }
1296
1297 /**
1298 * Extract Rank Math's modern VideoObject schema block into ThinkRank's
1299 * `video_*` schema-form vocabulary. Picks the primary VideoObject block (or
1300 * the first one), resolves text tokens, and drops any value still carrying an
1301 * unresolved Rank Math token (e.g. `%post_thumbnail%`) so the schema builder
1302 * falls back to the post's own data. Returns [] when no VideoObject block
1303 * exists or nothing meaningful survives.
1304 *
1305 * @param array $meta All Rank Math meta for a post
1306 * @param int $post_id Post ID for token resolution
1307 * @return array Video form data (video_name, video_embed_url, ...)
1308 */
1309 private function extract_video_schema(array $meta, int $post_id): array {
1310 $block = null;
1311 foreach ($meta as $key => $value) {
1312 if (strpos($key, 'rank_math_schema_') !== 0) {
1313 continue;
1314 }
1315 $schema = Safe_Unserializer::unserialize($value);
1316 if (!is_array($schema)) {
1317 continue;
1318 }
1319 $type = strtolower((string) ($schema['@type'] ?? substr($key, strlen('rank_math_schema_'))));
1320 if ($type !== 'videoobject') {
1321 continue;
1322 }
1323 // Prefer the primary block; otherwise keep the first one seen.
1324 if (!empty($schema['metadata']['isPrimary'])) {
1325 $block = $schema;
1326 break;
1327 }
1328 $block = $block ?? $schema;
1329 }
1330
1331 if (!is_array($block)) {
1332 return [];
1333 }
1334
1335 // Block field => ThinkRank form field. Text fields are run through the
1336 // template-variable resolver; URL/date fields are taken verbatim.
1337 $text_fields = [
1338 'name' => 'video_name',
1339 'description' => 'video_description',
1340 ];
1341 $raw_fields = [
1342 'contentUrl' => 'video_content_url',
1343 'embedUrl' => 'video_embed_url',
1344 'duration' => 'video_duration',
1345 'uploadDate' => 'video_upload_date',
1346 'thumbnailUrl' => 'video_thumbnail',
1347 ];
1348
1349 $video = [];
1350
1351 foreach ($text_fields as $block_key => $tr_key) {
1352 $value = $this->convert_template_variables((string) ($block[$block_key] ?? ''), $post_id);
1353 if ($value !== '' && strpos($value, '%') === false) {
1354 $video[$tr_key] = $value;
1355 }
1356 }
1357
1358 foreach ($raw_fields as $block_key => $tr_key) {
1359 $value = (string) ($block[$block_key] ?? '');
1360 // Drop unresolved tokens (e.g. %post_thumbnail%, %date(...)%) so the
1361 // builder's own fallback (featured image, publish date) applies.
1362 if ($value !== '' && strpos($value, '%') === false) {
1363 $video[$tr_key] = $value;
1364 }
1365 }
1366
1367 return $video;
1368 }
1369
1370 /**
1371 * Extract the boolean robots flags that Rank Math stores inside the
1372 * `rank_math_robots` indexed array (alongside index/noindex/nofollow).
1373 *
1374 * @param mixed $robots_raw Raw (serialized) rank_math_robots value
1375 * @return array Map of present flag => true (noarchive/noimageindex/nosnippet)
1376 */
1377 private function extract_robots_flags($robots_raw): array {
1378 $robots = Safe_Unserializer::unserialize($robots_raw);
1379 if (!is_array($robots)) {
1380 return [];
1381 }
1382
1383 $flags = [];
1384 foreach (['noarchive', 'noimageindex', 'nosnippet'] as $flag) {
1385 if (in_array($flag, $robots, true)) {
1386 $flags[$flag] = true;
1387 }
1388 }
1389
1390 return $flags;
1391 }
1392
1393 /**
1394 * Normalize Rank Math's pillar/cornerstone content flag to 0 or 1.
1395 *
1396 * Rank Math stores the enabled flag as the string 'on' (its checkbox value).
1397 * A plain (int) cast of 'on' yields 0, which silently drops the flag during
1398 * migration — so it must be matched against Rank Math's truthy representations.
1399 *
1400 * @param mixed $value Raw rank_math_pillar_content meta value
1401 * @return int 0 or 1
1402 */
1403 private function normalize_pillar_content($value): int {
1404 return in_array($value, ['on', '1', 1, true], true) ? 1 : 0;
1405 }
1406
1407 /**
1408 * Decode the `rank_math_advanced_robots` post meta.
1409 *
1410 * Rank Math stores it as an associative array keyed by directive, where the
1411 * value is the configured length/value or `false` when the directive is
1412 * disabled, e.g. ['max-snippet' => '120', 'max-video-preview' => false,
1413 * 'max-image-preview' => 'large'].
1414 *
1415 * @param mixed $raw Raw (serialized) meta value
1416 * @return array Associative directive => value map (empty when unset)
1417 */
1418 private function parse_advanced_robots_meta($raw): array {
1419 $advanced = Safe_Unserializer::unserialize($raw);
1420 return is_array($advanced) ? $advanced : [];
1421 }
1422
1423 /**
1424 * Read an integer advanced-robots directive (max-snippet, max-video-preview).
1425 *
1426 * Returns an empty-string sentinel when the directive is unset or disabled
1427 * so the migrator skips it rather than forcing a 0 value (which would read
1428 * as "no snippet").
1429 *
1430 * @param array $advanced Decoded rank_math_advanced_robots map
1431 * @param string $key Directive key
1432 * @return int|string Integer value, or '' when unset/disabled
1433 */
1434 private function advanced_robot_int(array $advanced, string $key) {
1435 if (!isset($advanced[$key]) || $advanced[$key] === false || $advanced[$key] === '') {
1436 return '';
1437 }
1438 return (int) $advanced[$key];
1439 }
1440
1441 /**
1442 * Read a string advanced-robots directive (max-image-preview).
1443 *
1444 * @param array $advanced Decoded rank_math_advanced_robots map
1445 * @param string $key Directive key
1446 * @return string Directive value, or '' when unset/disabled
1447 */
1448 private function advanced_robot_string(array $advanced, string $key): string {
1449 if (!isset($advanced[$key]) || $advanced[$key] === false) {
1450 return '';
1451 }
1452 return (string) $advanced[$key];
1453 }
1454
1455 /**
1456 * Extract the local business phone from Rank Math titles.
1457 *
1458 * Rank Math stores local phones under `phone_numbers` (an array of
1459 * ['type' => ..., 'number' => ...]); older/knowledge-graph setups use a
1460 * flat `phone`. Prefer the first structured number, fall back to `phone`.
1461 *
1462 * @param array $titles Rank Math titles option
1463 * @return string
1464 */
1465 private function extract_rm_local_phone(array $titles): string {
1466 $numbers = $titles['phone_numbers'] ?? [];
1467 if (is_array($numbers)) {
1468 foreach ($numbers as $entry) {
1469 if (is_array($entry) && !empty($entry['number'])) {
1470 return (string) $entry['number'];
1471 }
1472 }
1473 }
1474
1475 return (string) ($titles['phone'] ?? '');
1476 }
1477
1478 /**
1479 * Convert Rank Math opening hours into ThinkRank's Business Info format.
1480 *
1481 * Rank Math stores `opening_hours` as a group of ['day' => 'Monday',
1482 * 'time' => '09:00-17:00'] rows (24h H:i). ThinkRank stores hours keyed by
1483 * lowercase day: ['monday' => ['open' => 'HH:MM', 'close' => 'HH:MM',
1484 * 'closed' => bool]]. ThinkRank supports a single range per day, so the
1485 * first parseable row for each day wins (Rank Math's optional mid-day-break
1486 * second row is dropped). Days with no configured row are omitted.
1487 *
1488 * @param array $titles Rank Math titles option
1489 * @return array
1490 */
1491 private function extract_rm_opening_hours(array $titles): array {
1492 $hours = $titles['opening_hours'] ?? [];
1493 if (!is_array($hours) || empty($hours)) {
1494 return [];
1495 }
1496
1497 $valid_days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
1498 $result = [];
1499
1500 foreach ($hours as $entry) {
1501 if (!is_array($entry) || empty($entry['day']) || empty($entry['time'])) {
1502 continue;
1503 }
1504
1505 $day = strtolower((string) $entry['day']);
1506 if (!in_array($day, $valid_days, true) || isset($result[$day])) {
1507 continue;
1508 }
1509
1510 // Parse "HH:MM-HH:MM" (allow single-digit hours and en dash).
1511 if (!preg_match('/^\s*(\d{1,2}:\d{2})\s*[-\x{2013}]\s*(\d{1,2}:\d{2})\s*$/u', (string) $entry['time'], $m)) {
1512 continue;
1513 }
1514
1515 $result[$day] = [
1516 'open' => $this->pad_time_hh_mm($m[1]),
1517 'close' => $this->pad_time_hh_mm($m[2]),
1518 'closed' => false,
1519 ];
1520 }
1521
1522 return $result;
1523 }
1524
1525 /**
1526 * Zero-pad the hour component of an "H:MM" time to "HH:MM".
1527 *
1528 * @param string $time Time string like "9:00" or "09:00"
1529 * @return string
1530 */
1531 private function pad_time_hh_mm(string $time): string {
1532 $parts = explode(':', $time);
1533 if (count($parts) !== 2) {
1534 return $time;
1535 }
1536
1537 return str_pad($parts[0], 2, '0', STR_PAD_LEFT) . ':' . $parts[1];
1538 }
1539
1540 /**
1541 * Extract the local business postal address from Rank Math titles.
1542 *
1543 * Rank Math's `local_address` is a schema.org PostalAddress array
1544 * (streetAddress/addressLocality/addressRegion/postalCode/addressCountry).
1545 * Returns a normalized array keyed for ThinkRank's Business Info fields, or
1546 * an empty array when nothing is set.
1547 *
1548 * @param array $titles Rank Math titles option
1549 * @return array
1550 */
1551 private function extract_rm_local_address(array $titles): array {
1552 $address = $titles['local_address'] ?? [];
1553 if (!is_array($address) || empty($address)) {
1554 return [];
1555 }
1556
1557 $map = [
1558 'street' => 'streetAddress',
1559 'city' => 'addressLocality',
1560 'state' => 'addressRegion',
1561 'postal_code' => 'postalCode',
1562 'country' => 'addressCountry',
1563 ];
1564
1565 $result = [];
1566 foreach ($map as $target => $rm_key) {
1567 if (!empty($address[$rm_key])) {
1568 $result[$target] = (string) $address[$rm_key];
1569 }
1570 }
1571
1572 return $result;
1573 }
1574
1575 /**
1576 * Extract geo coordinates from Rank Math titles.
1577 *
1578 * Rank Math's `geo` is a single "latitude,longitude" string. Returns
1579 * ['latitude' => ..., 'longitude' => ...] when both parse, else [].
1580 *
1581 * @param array $titles Rank Math titles option
1582 * @return array
1583 */
1584 private function extract_rm_local_geo(array $titles): array {
1585 $geo = trim((string) ($titles['geo'] ?? ''));
1586 if ($geo === '') {
1587 return [];
1588 }
1589
1590 $parts = preg_split('/[\s,]+/', $geo);
1591 if (!is_array($parts) || !isset($parts[0], $parts[1]) || $parts[0] === '' || $parts[1] === '') {
1592 return [];
1593 }
1594
1595 if (!is_numeric($parts[0]) || !is_numeric($parts[1])) {
1596 return [];
1597 }
1598
1599 return [
1600 'latitude' => (string) $parts[0],
1601 'longitude' => (string) $parts[1],
1602 ];
1603 }
1604
1605 /**
1606 * Map Rank Math's per-context title formats onto ThinkRank's Site Identity
1607 * title-format keys (homepage_title, post_title, page_title, category_title,
1608 * tag_title, search_title, archive_title).
1609 *
1610 * These are a DIFFERENT token dialect from `post_type_settings` above: the
1611 * Site Identity renderer resolves %site_title%/%post_title%/%category_title%/
1612 * %tag_title%/%search_term%/%archive_title%/%sep%, whereas the Global SEO
1613 * renderer resolves %title%/%sitename%/%excerpt%. Converting with the wrong
1614 * dialect renders the token literally, so each context is converted with the
1615 * matching context token.
1616 *
1617 * @param array $titles Rank Math titles option
1618 * @return array Map of ThinkRank title-format key => converted template
1619 */
1620 private function extract_rm_title_formats(array $titles): array {
1621 // ThinkRank key => [Rank Math key, the %…% token Rank Math's %title%/%term%
1622 // stands for in that context].
1623 $map = [
1624 'homepage_title' => ['homepage_title', ''],
1625 'post_title' => ['pt_post_title', '%post_title%'],
1626 'page_title' => ['pt_page_title', '%page_title%'],
1627 'category_title' => ['tax_category_title', '%category_title%'],
1628 'tag_title' => ['tax_post_tag_title', '%tag_title%'],
1629 'search_title' => ['search_title', '%search_term%'],
1630 'archive_title' => ['date_archive_title', '%archive_title%'],
1631 ];
1632
1633 $formats = [];
1634 foreach ($map as $tr_key => [$rm_key, $context_token]) {
1635 $raw = (string) ($titles[$rm_key] ?? '');
1636 if ($raw === '') {
1637 continue;
1638 }
1639
1640 $converted = $this->convert_identity_tokens($raw, $context_token);
1641 if ($converted !== '') {
1642 $formats[$tr_key] = $converted;
1643 }
1644 }
1645
1646 return $formats;
1647 }
1648
1649 /**
1650 * Convert a Rank Math title template into ThinkRank's Site Identity token
1651 * vocabulary, preserving structure.
1652 *
1653 * The Site Identity renderer resolves: %site_title%, %site_description%,
1654 * %tagline%, %sep%/%separator%, %date%, plus the per-context tokens
1655 * %post_title%, %page_title%, %category_title%, %tag_title%, %search_term%,
1656 * %archive_title%, %author_name%. Rank Math's context-neutral %title% /
1657 * %term% become the caller-supplied $context_token; anything ThinkRank
1658 * cannot resolve is stripped so it never renders literally.
1659 *
1660 * @param string $template Raw Rank Math template
1661 * @param string $context_token Token %title%/%term% stands for here (may be '')
1662 * @return string ThinkRank Site Identity template
1663 */
1664 private function convert_identity_tokens(string $template, string $context_token): string {
1665 if ($template === '' || strpos($template, '%') === false) {
1666 return trim($template);
1667 }
1668
1669 $token_map = [
1670 '%sitename%' => '%site_title%',
1671 '%sitedesc%' => '%site_description%',
1672 '%name%' => '%author_name%',
1673 '%search_query%' => '%search_term%',
1674 ];
1675 if ($context_token !== '') {
1676 $token_map['%title%'] = $context_token;
1677 $token_map['%term%'] = $context_token;
1678 }
1679 $template = str_replace(array_keys($token_map), array_values($token_map), $template);
1680
1681 $supported = [
1682 '%site_title%', '%site_description%', '%tagline%', '%sep%', '%separator%',
1683 '%date%', '%post_title%', '%page_title%', '%category_title%', '%tag_title%',
1684 '%search_term%', '%archive_title%', '%author_name%',
1685 ];
1686
1687 $template = preg_replace_callback(
1688 '/%[a-z0-9_]+%/i',
1689 static function (array $m) use ($supported): string {
1690 return in_array(strtolower($m[0]), $supported, true) ? $m[0] : '';
1691 },
1692 $template
1693 );
1694
1695 // Collapse whitespace left by stripped tokens, then drop a separator that
1696 // ended up leading/trailing because the token beside it was removed.
1697 $template = preg_replace('/\s{2,}/', ' ', (string) $template);
1698 $template = trim((string) $template);
1699 $template = preg_replace('/^(?:%sep%|%separator%)\s*/', '', $template);
1700 $template = preg_replace('/\s*(?:%sep%|%separator%)$/', '', (string) $template);
1701
1702 return trim((string) $template);
1703 }
1704
1705 /**
1706 * Extract Rank Math's author-archive behaviour for ThinkRank's Author
1707 * Archives feature (author_archives_enabled / _title / _meta_desc).
1708 *
1709 * @param array $titles Rank Math titles option
1710 * @return array Author archive settings
1711 */
1712 private function extract_rm_author_archives(array $titles): array {
1713 return [
1714 // Rank Math DISABLES archives with this flag; ThinkRank stores the
1715 // positive `enabled`, so invert.
1716 'enabled' => ($titles['disable_author_archives'] ?? 'off') !== 'on',
1717 'title' => $this->convert_identity_tokens((string) ($titles['author_archive_title'] ?? ''), '%author_name%'),
1718 'description' => $this->convert_identity_tokens((string) ($titles['author_archive_description'] ?? ''), '%author_name%'),
1719 ];
1720 }
1721
1722 /**
1723 * Read the post types Rank Math's Instant Indexing module auto-submits.
1724 *
1725 * @return array List of post type slugs (empty when unconfigured)
1726 */
1727 private function extract_rm_indexnow_post_types(): array {
1728 foreach (['rank-math-options-instant-indexing', 'rank_math_instant_indexing'] as $option_name) {
1729 $settings = get_option($option_name, []);
1730 if (!is_array($settings)) {
1731 continue;
1732 }
1733 foreach (['bing_post_types', 'indexnow_post_types', 'post_types'] as $key) {
1734 if (!empty($settings[$key]) && is_array($settings[$key])) {
1735 return array_values(array_map('strval', $settings[$key]));
1736 }
1737 }
1738 }
1739
1740 return [];
1741 }
1742
1743 /**
1744 * Capture Rank Math's IndexNow submission history (`rank_math_indexnow_log`,
1745 * a list of ['url', 'status', 'message', 'time', 'manual_submission']).
1746 *
1747 * Rank Math trims this option itself, but cap it defensively so one site's
1748 * runaway log cannot bloat the settings chunk — and report the drop rather
1749 * than truncating silently.
1750 *
1751 * @return array{entries: array[], truncated: int}
1752 */
1753 private function extract_rm_indexnow_log(): array {
1754 $log = get_option('rank_math_indexnow_log', []);
1755 if (!is_array($log) || empty($log)) {
1756 return ['entries' => [], 'truncated' => 0];
1757 }
1758
1759 // Newest last in Rank Math's log; keep the most recent when capping.
1760 $truncated = max(0, count($log) - self::MAX_INDEXNOW_LOG_ENTRIES);
1761 if ($truncated > 0) {
1762 $log = array_slice($log, -self::MAX_INDEXNOW_LOG_ENTRIES);
1763 }
1764
1765 $entries = [];
1766 foreach ($log as $row) {
1767 if (!is_array($row) || empty($row['url'])) {
1768 continue;
1769 }
1770
1771 $code = (int) ($row['status'] ?? 0);
1772
1773 $entries[] = [
1774 'url' => (string) $row['url'],
1775 // ThinkRank stores a success/failed verdict alongside the raw code.
1776 'status' => ($code >= 200 && $code < 300) ? 'success' : 'failed',
1777 'response_code' => $code,
1778 'response_message' => (string) ($row['message'] ?? ''),
1779 'submitted_at' => !empty($row['time'])
1780 ? gmdate('Y-m-d H:i:s', (int) $row['time'])
1781 : '',
1782 ];
1783 }
1784
1785 return ['entries' => $entries, 'truncated' => $truncated];
1786 }
1787
1788 /**
1789 * Capture the post types Rank Math builds its News / Video sitemaps from,
1790 * for ThinkRank Pro's Publisher Sitemaps.
1791 *
1792 * @param array $sitemap Rank Math sitemap option
1793 * @return array News/Video post type lists (absent keys omitted)
1794 */
1795 private function extract_rm_publisher_sitemaps(array $sitemap): array {
1796 $out = [];
1797
1798 foreach (['video_sitemap_post_type' => 'video_post_types', 'news_sitemap_post_type' => 'news_post_types'] as $rm_key => $tr_key) {
1799 if (empty($sitemap[$rm_key]) || !is_array($sitemap[$rm_key])) {
1800 continue;
1801 }
1802 $out[$tr_key] = array_values(array_map('strval', $sitemap[$rm_key]));
1803 }
1804
1805 return $out;
1806 }
1807
1808 /**
1809 * Map Rank Math's email-report cadence onto ThinkRank's `frequency_days`.
1810 *
1811 * @param string $frequency Rank Math frequency slug
1812 * @return int Days between reports (ThinkRank default 30 when unknown)
1813 */
1814 private function map_rm_email_frequency(string $frequency): int {
1815 switch (strtolower(trim($frequency))) {
1816 case 'daily':
1817 return 1;
1818 case 'weekly':
1819 return 7;
1820 case 'monthly':
1821 return 30;
1822 default:
1823 return 30;
1824 }
1825 }
1826
1827 /**
1828 * Extract post type settings from Rank Math titles options
1829 *
1830 * @param array $titles Rank Math titles option
1831 * @return array Post type settings
1832 */
1833 private function extract_rm_post_type_settings(array $titles): array {
1834 $settings = [];
1835 $post_types = get_post_types(['public' => true], 'names');
1836
1837 foreach ($post_types as $pt) {
1838 $pt_settings = [];
1839 if (isset($titles["pt_{$pt}_title"])) {
1840 $pt_settings['title_template'] = $this->convert_template_tokens($titles["pt_{$pt}_title"]);
1841 }
1842 if (isset($titles["pt_{$pt}_description"])) {
1843 $pt_settings['description_template'] = $this->convert_template_tokens($titles["pt_{$pt}_description"]);
1844 }
1845 if (isset($titles["pt_{$pt}_robots"])) {
1846 $pt_settings['robots'] = Safe_Unserializer::to_array($titles["pt_{$pt}_robots"]);
1847 }
1848 // Rank Math's per-type Link Suggestions toggle maps onto ThinkRank's
1849 // global-SEO `link_suggestions` (which gates the Pillar Content column
1850 // and post-list filter). Only captured when Rank Math stored a value.
1851 if (isset($titles["pt_{$pt}_link_suggestions"])) {
1852 $pt_settings['link_suggestions'] = $titles["pt_{$pt}_link_suggestions"] === 'on';
1853 }
1854 // Rank Math only applies per-post-type robots when "custom robots" is
1855 // enabled for that type; capture the flag so migration does not force
1856 // robots that Rank Math was ignoring.
1857 $pt_settings['custom_robots'] = ($titles["pt_{$pt}_custom_robots"] ?? 'off') === 'on';
1858 // `link_suggestions` is a boolean, so array_key_exists — not !empty —
1859 // decides whether the type is worth emitting (a deliberate "off" is
1860 // exactly the value worth carrying over).
1861 if (!empty($pt_settings['title_template']) || !empty($pt_settings['description_template'])
1862 || !empty($pt_settings['robots']) || array_key_exists('link_suggestions', $pt_settings)) {
1863 $settings[$pt] = $pt_settings;
1864 }
1865 }
1866
1867 return $settings;
1868 }
1869 }
1870