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

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