PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.2.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.2.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 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.2.0, at includes/admin/importers/class-rankmath-exporter.php

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