PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.26.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.26.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 1.1.0 All 49 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 1.26.0, at includes/admin/importers/class-rankmath-exporter.php

1,728 lines 72.5 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 maybe_unserialize()
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 $wpdb->prepare(
686 "SELECT * FROM {$table_name} ORDER BY id ASC LIMIT %d OFFSET %d",
687 $this->chunk_size,
688 $offset
689 ),
690 ARRAY_A
691 );
692
693 // One rule can fan out into several records, so pagination must key off
694 // the number of ROWS fetched, not the number of records emitted.
695 $this->last_page_row_count = is_array($rows) ? count($rows) : 0;
696
697 if (empty($rows)) {
698 return [];
699 }
700
701 $records = [];
702 foreach ($rows as $row) {
703 // Rank Math stores `sources` as a serialized list of
704 // ['pattern' => …, 'comparison' => exact|contains|start|end|regex]
705 // rows — ONE rule can match many source URLs. ThinkRank's redirect
706 // table is one row per source, so fan each source out into its own
707 // record rather than keeping only the first (which silently dropped
708 // every additional source).
709 $sources = maybe_unserialize($row['sources'] ?? '');
710 if (!is_array($sources) || empty($sources)) {
711 continue;
712 }
713
714 foreach ($sources as $source) {
715 if (!is_array($source)) {
716 continue;
717 }
718
719 $pattern = trim((string) ($source['pattern'] ?? ''));
720 if ($pattern === '') {
721 continue;
722 }
723
724 $comparison = strtolower((string) ($source['comparison'] ?? 'exact'));
725
726 $records[] = [
727 'object_type' => 'redirection',
728 'source_plugin' => $this->plugin_slug,
729 'data' => [],
730 'extended' => [
731 'source_url' => $pattern,
732 'target_url' => $row['url_to'] ?? '',
733 'http_code' => (int) ($row['header_code'] ?? 301),
734 'match_type' => $this->map_rm_match_type($comparison),
735 // Retained for readers that predate `match_type`.
736 'is_regex' => $comparison === 'regex',
737 'enabled' => ($row['status'] ?? 'active') === 'active',
738 'hits' => (int) ($row['hits'] ?? 0),
739 'created_at' => $this->normalize_rm_datetime($row['created'] ?? ''),
740 'last_accessed' => $this->normalize_rm_datetime($row['last_accessed'] ?? ''),
741 ],
742 ];
743 }
744 }
745
746 return $records;
747 }
748
749 /**
750 * {@inheritDoc}
751 *
752 * Rank Math's 404 monitor keeps one row per URI in `rank_math_404_logs`,
753 * which lines up with ThinkRank Pro's `thinkrank_404_logs`.
754 */
755 protected function export_404_logs_page(int $page): array {
756 global $wpdb;
757
758 $table_name = $wpdb->prefix . 'rank_math_404_logs';
759 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
760 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table_name));
761 if (!$table_exists) {
762 return [];
763 }
764
765 $offset = ($page - 1) * $this->chunk_size;
766
767 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
768 $rows = $wpdb->get_results(
769 $wpdb->prepare(
770 "SELECT * FROM {$table_name} ORDER BY id ASC LIMIT %d OFFSET %d",
771 $this->chunk_size,
772 $offset
773 ),
774 ARRAY_A
775 );
776
777 $this->last_page_row_count = is_array($rows) ? count($rows) : 0;
778
779 if (empty($rows)) {
780 return [];
781 }
782
783 $records = [];
784 foreach ($rows as $row) {
785 $uri = trim((string) ($row['uri'] ?? ''));
786 if ($uri === '') {
787 continue;
788 }
789
790 $records[] = [
791 'object_type' => '404_log',
792 'source_plugin' => $this->plugin_slug,
793 'data' => [],
794 'extended' => [
795 'uri' => $uri,
796 'times_accessed' => max(1, (int) ($row['times_accessed'] ?? 1)),
797 'referer' => (string) ($row['referer'] ?? ''),
798 'user_agent' => (string) ($row['user_agent'] ?? ''),
799 'last_accessed' => $this->normalize_rm_datetime($row['accessed'] ?? ''),
800 ],
801 ];
802 }
803
804 return $records;
805 }
806
807 /**
808 * Map a Rank Math redirection `comparison` onto ThinkRank Pro's match_type.
809 *
810 * Rank Math's vocabulary is exact|contains|start|end|regex; ThinkRank Pro
811 * uses the same five, so this mostly normalizes and guards unknown values
812 * (Rank Math also had a legacy 'exact' alias set).
813 *
814 * @param string $comparison Rank Math comparison slug
815 * @return string ThinkRank match type
816 */
817 private function map_rm_match_type(string $comparison): string {
818 $supported = ['exact', 'contains', 'start', 'end', 'regex'];
819
820 return in_array($comparison, $supported, true) ? $comparison : 'exact';
821 }
822
823 /**
824 * Normalize a Rank Math datetime column into a MySQL datetime string.
825 *
826 * Rank Math's tables default these columns to the zero date
827 * ('0000-00-00 00:00:00'), which MySQL rejects on insert under strict mode.
828 *
829 * @param mixed $value Raw column value
830 * @return string Valid `Y-m-d H:i:s` string, or '' when unusable
831 */
832 private function normalize_rm_datetime($value): string {
833 $value = trim((string) $value);
834 if ($value === '' || strpos($value, '0000-00-00') === 0) {
835 return '';
836 }
837
838 $timestamp = strtotime($value);
839
840 return $timestamp ? gmdate('Y-m-d H:i:s', $timestamp) : '';
841 }
842
843 /**
844 * {@inheritDoc}
845 */
846 /**
847 * Map a Rank Math rich-snippet slug to ThinkRank's schema-type vocabulary.
848 * Unknown slugs and the 'off'/'none' sentinels return '' (no schema), which
849 * the migrator skips like any empty value.
850 *
851 * @param mixed $rm_value Raw rank_math_rich_snippet value
852 * @return string ThinkRank schema type, or '' when unmapped/disabled
853 */
854 private function map_schema_type($rm_value): string {
855 $key = strtolower(trim((string) $rm_value));
856 if ($key === '' || $key === 'off' || $key === 'none') {
857 return '';
858 }
859
860 return self::SCHEMA_TYPE_MAP[$key] ?? '';
861 }
862
863 /**
864 * Resolve a post's ThinkRank schema type from Rank Math meta.
865 *
866 * Prefers the legacy `rank_math_rich_snippet` slug when present, then falls
867 * back to Rank Math's modern per-post schema storage (`rank_math_schema_*`),
868 * which is what current Rank Math versions actually write. Returns '' when no
869 * supported schema is found (the migrator skips empty schema types).
870 *
871 * @param array $meta All Rank Math meta for a post
872 * @return string ThinkRank schema type, or ''
873 */
874 private function resolve_schema_type(array $meta): string {
875 $legacy = $this->map_schema_type($meta['rank_math_rich_snippet'] ?? '');
876 if ($legacy !== '') {
877 return $legacy;
878 }
879
880 return $this->detect_modern_schema_type($meta);
881 }
882
883 /**
884 * Derive a ThinkRank schema type from Rank Math's modern `rank_math_schema_*`
885 * meta blocks.
886 *
887 * A post may carry several schema blocks (e.g. BlogPosting + VideoObject);
888 * the one flagged `metadata.isPrimary` is preferred. To avoid discarding a
889 * usable type when the primary block is one ThinkRank does not model (e.g. a
890 * primary VideoObject alongside a secondary Article), the first block — in
891 * primary-then-rest order — that maps to a supported type wins.
892 *
893 * @param array $meta All Rank Math meta for a post
894 * @return string ThinkRank schema type, or ''
895 */
896 private function detect_modern_schema_type(array $meta): string {
897 $primary = [];
898 $others = [];
899
900 foreach ($meta as $key => $value) {
901 if (strpos($key, 'rank_math_schema_') !== 0) {
902 continue;
903 }
904
905 $schema = maybe_unserialize($value);
906 if (!is_array($schema)) {
907 continue;
908 }
909
910 // The @type is stored at the block root for most types; older Article
911 // blocks omit it and carry the type only in the meta key suffix.
912 $type_name = (string) ($schema['@type'] ?? substr($key, strlen('rank_math_schema_')));
913
914 // metadata.isPrimary is '1'/true for the primary block, '0'/false (or
915 // absent) otherwise. empty() treats '0', '', false and 0 as not-primary.
916 if (!empty($schema['metadata']['isPrimary'])) {
917 $primary[] = $type_name;
918 } else {
919 $others[] = $type_name;
920 }
921 }
922
923 foreach (array_merge($primary, $others) as $type_name) {
924 $mapped = $this->map_modern_schema_type($type_name);
925 if ($mapped !== '') {
926 return $mapped;
927 }
928 }
929
930 return '';
931 }
932
933 /**
934 * Map a Rank Math modern schema @type (PascalCase) to ThinkRank's vocabulary.
935 *
936 * @param string $type_name Rank Math schema @type
937 * @return string ThinkRank schema type, or '' when unmapped/unsupported
938 */
939 private function map_modern_schema_type(string $type_name): string {
940 $key = strtolower(trim($type_name));
941 if ($key === '') {
942 return '';
943 }
944
945 return self::MODERN_SCHEMA_TYPE_MAP[$key] ?? '';
946 }
947
948 /**
949 * Resolve a Rank Math TERM title/description value, replacing the
950 * term-context tokens (%term%, %term_description%) Rank Math uses for term
951 * archives before delegating to the shared variable resolver. Without this
952 * the term name is stripped and titles render as "Archives - Site".
953 *
954 * @param string $value Raw Rank Math term meta value
955 * @param int $term_id Term ID for context
956 * @return string Resolved value
957 */
958 private function convert_term_template_variables(string $value, int $term_id): string {
959 if ($value === '' || strpos($value, '%') === false) {
960 return $value;
961 }
962
963 $term = get_term($term_id);
964 if ($term instanceof \WP_Term) {
965 $value = str_replace(
966 ['%term%', '%term_description%'],
967 [$term->name, wp_strip_all_tags((string) term_description($term_id))],
968 $value
969 );
970 }
971
972 // Collapse whitespace left where a token (e.g. %page%) resolved to ''.
973 return trim((string) preg_replace('/\s{2,}/', ' ', $this->convert_template_variables($value)));
974 }
975
976 protected function convert_template_variables(string $value, ?int $post_id = null): string {
977 if (empty($value) || strpos($value, '%') === false) {
978 return $value;
979 }
980
981 $replacements = [
982 '%sitename%' => get_bloginfo('name'),
983 '%sitedesc%' => get_bloginfo('description'),
984 '%sep%' => '-',
985 '%page%' => '',
986 '%currentyear%' => gmdate('Y'),
987 '%currentdate%' => gmdate('Y-m-d'),
988 '%currentmonth%' => gmdate('F'),
989 '%currentday%' => gmdate('j'),
990 ];
991
992 if ($post_id) {
993 $post = get_post($post_id);
994 if ($post) {
995 $replacements['%title%'] = $post->post_title;
996 $replacements['%excerpt%'] = wp_trim_words($post->post_excerpt ?: wp_trim_words(wp_strip_all_tags($post->post_content), 55), 55);
997 $replacements['%date%'] = get_the_date('', $post);
998 $replacements['%modified%'] = get_the_modified_date('', $post);
999 $replacements['%id%'] = (string) $post_id;
1000 $replacements['%name%'] = get_the_author_meta('display_name', (int) $post->post_author);
1001
1002 $post_type_obj = get_post_type_object($post->post_type);
1003 $replacements['%pt_single%'] = $post_type_obj ? $post_type_obj->labels->singular_name : '';
1004 $replacements['%pt_plural%'] = $post_type_obj ? $post_type_obj->labels->name : '';
1005
1006 $categories = get_the_category($post_id);
1007 $replacements['%category%'] = !empty($categories) ? $categories[0]->name : '';
1008 $replacements['%categories%'] = !empty($categories) ? implode(', ', wp_list_pluck($categories, 'name')) : '';
1009
1010 $tags = get_the_tags($post_id);
1011 $replacements['%tag%'] = !empty($tags) ? $tags[0]->name : '';
1012 $replacements['%tags%'] = !empty($tags) ? implode(', ', wp_list_pluck($tags, 'name')) : '';
1013 }
1014 }
1015
1016 $value = str_replace(array_keys($replacements), array_values($replacements), $value);
1017
1018 // Strip remaining unknown %variable% patterns (single percent)
1019 // Be careful not to strip legitimate percent signs
1020 $value = preg_replace('/%[a-z0-9_]+%/i', '', $value);
1021
1022 return trim($value);
1023 }
1024
1025 /**
1026 * Convert a Rank Math title/description TEMPLATE into ThinkRank's Global SEO
1027 * token vocabulary, preserving structural tokens (do NOT resolve to literal
1028 * values — these templates apply to every post of the type).
1029 *
1030 * ThinkRank's Global SEO engine understands: %title%, %sitename%, %sep%,
1031 * %excerpt%, %date%, %modified%, %author%, %category%. Rank Math tokens with
1032 * a direct equivalent are renamed; tokens ThinkRank cannot resolve (e.g.
1033 * %page%, %pt_single%, %currentyear%) are stripped so they never render
1034 * literally on the frontend.
1035 *
1036 * @param string $template Raw Rank Math template
1037 * @return string ThinkRank-compatible template
1038 */
1039 private function convert_template_tokens(string $template): string {
1040 if (empty($template) || strpos($template, '%') === false) {
1041 return $template;
1042 }
1043
1044 // Rank Math token => ThinkRank Global SEO token (structure preserved).
1045 $token_map = [
1046 '%name%' => '%author%', // Rank Math author display name token
1047 ];
1048 $template = str_replace(array_keys($token_map), array_values($token_map), $template);
1049
1050 // Tokens ThinkRank's Global SEO engine resolves natively — keep as-is.
1051 $supported = ['%title%', '%sitename%', '%sep%', '%excerpt%', '%date%', '%modified%', '%author%', '%category%'];
1052
1053 // Strip any token ThinkRank cannot resolve so it does not render literally.
1054 $template = preg_replace_callback(
1055 '/%[a-z0-9_]+%/i',
1056 static function (array $m) use ($supported): string {
1057 return in_array(strtolower($m[0]), $supported, true) ? $m[0] : '';
1058 },
1059 $template
1060 );
1061
1062 // Collapse whitespace left by stripped tokens (e.g. "%title% %page% %sep%").
1063 $template = preg_replace('/\s{2,}/', ' ', (string) $template);
1064
1065 return trim((string) $template);
1066 }
1067
1068 /**
1069 * Convert a Rank Math image alt/title FORMAT into ThinkRank's Image SEO token
1070 * vocabulary, preserving structure.
1071 *
1072 * ThinkRank's Image SEO engine resolves: %title%, %sitename%, %site_title%,
1073 * %sep%, %separator%, %count%, %filename%, %image_title%, %image_caption%.
1074 * Rank Math's counter tokens %count(alt)% / %count(title)% become %count%;
1075 * tokens with no equivalent are stripped so they never render literally.
1076 *
1077 * @param string $format Raw Rank Math image format
1078 * @return string ThinkRank-compatible image format
1079 */
1080 private function convert_image_tokens(string $format): string {
1081 if ($format === '' || strpos($format, '%') === false) {
1082 return $format;
1083 }
1084
1085 // Rank Math counter tokens carry a parenthesised argument, e.g. %count(alt)%.
1086 $format = preg_replace('/%count\([a-z]+\)%/i', '%count%', $format);
1087 $format = str_replace('%name%', '', (string) $format);
1088
1089 $supported = ['%title%', '%sitename%', '%site_title%', '%sep%', '%separator%', '%count%', '%filename%', '%image_title%', '%image_caption%'];
1090 $format = preg_replace_callback(
1091 '/%[a-z0-9_]+%/i',
1092 static function (array $m) use ($supported): string {
1093 return in_array(strtolower($m[0]), $supported, true) ? $m[0] : '';
1094 },
1095 (string) $format
1096 );
1097
1098 $format = preg_replace('/\s{2,}/', ' ', (string) $format);
1099
1100 return trim((string) $format);
1101 }
1102
1103 /**
1104 * Extract schema details from Rank Math meta
1105 *
1106 * @param array $meta All Rank Math meta for a post
1107 * @return array Schema details
1108 */
1109 private function extract_schema_details(array $meta): array {
1110 $details = [];
1111
1112 foreach ($meta as $key => $value) {
1113 if (strpos($key, 'rank_math_schema_') === 0) {
1114 $schema_key = str_replace('rank_math_schema_', '', $key);
1115 $details[$schema_key] = maybe_unserialize($value);
1116 }
1117 }
1118
1119 return $details;
1120 }
1121
1122 /**
1123 * Extract Rank Math's review rich-snippet rating fields into ThinkRank's
1124 * Review schema-form vocabulary. Returned keys match the `review_*` fields
1125 * the schema builder reads; empty values are omitted so the migrator only
1126 * writes meaningful data. Returns [] when the post is not a review snippet.
1127 *
1128 * @param array $meta All Rank Math meta for a post
1129 * @return array Review form data (review_rating_value, review_best_rating, ...)
1130 */
1131 private function extract_review_schema(array $meta): array {
1132 if (($meta['rank_math_rich_snippet'] ?? '') !== 'review') {
1133 return [];
1134 }
1135
1136 $map = [
1137 'rank_math_snippet_name' => 'review_item_name',
1138 'rank_math_snippet_desc' => 'review_body',
1139 'rank_math_snippet_review_rating_value' => 'review_rating_value',
1140 'rank_math_snippet_review_best_rating' => 'review_best_rating',
1141 'rank_math_snippet_review_worst_rating' => 'review_worst_rating',
1142 ];
1143
1144 $review = [];
1145 foreach ($map as $rm_key => $tr_key) {
1146 $value = $meta[$rm_key] ?? '';
1147 if ($value !== '' && $value !== null) {
1148 $review[$tr_key] = $value;
1149 }
1150 }
1151
1152 return $review;
1153 }
1154
1155 /**
1156 * Extract Rank Math's modern VideoObject schema block into ThinkRank's
1157 * `video_*` schema-form vocabulary. Picks the primary VideoObject block (or
1158 * the first one), resolves text tokens, and drops any value still carrying an
1159 * unresolved Rank Math token (e.g. `%post_thumbnail%`) so the schema builder
1160 * falls back to the post's own data. Returns [] when no VideoObject block
1161 * exists or nothing meaningful survives.
1162 *
1163 * @param array $meta All Rank Math meta for a post
1164 * @param int $post_id Post ID for token resolution
1165 * @return array Video form data (video_name, video_embed_url, ...)
1166 */
1167 private function extract_video_schema(array $meta, int $post_id): array {
1168 $block = null;
1169 foreach ($meta as $key => $value) {
1170 if (strpos($key, 'rank_math_schema_') !== 0) {
1171 continue;
1172 }
1173 $schema = maybe_unserialize($value);
1174 if (!is_array($schema)) {
1175 continue;
1176 }
1177 $type = strtolower((string) ($schema['@type'] ?? substr($key, strlen('rank_math_schema_'))));
1178 if ($type !== 'videoobject') {
1179 continue;
1180 }
1181 // Prefer the primary block; otherwise keep the first one seen.
1182 if (!empty($schema['metadata']['isPrimary'])) {
1183 $block = $schema;
1184 break;
1185 }
1186 $block = $block ?? $schema;
1187 }
1188
1189 if (!is_array($block)) {
1190 return [];
1191 }
1192
1193 // Block field => ThinkRank form field. Text fields are run through the
1194 // template-variable resolver; URL/date fields are taken verbatim.
1195 $text_fields = [
1196 'name' => 'video_name',
1197 'description' => 'video_description',
1198 ];
1199 $raw_fields = [
1200 'contentUrl' => 'video_content_url',
1201 'embedUrl' => 'video_embed_url',
1202 'duration' => 'video_duration',
1203 'uploadDate' => 'video_upload_date',
1204 'thumbnailUrl' => 'video_thumbnail',
1205 ];
1206
1207 $video = [];
1208
1209 foreach ($text_fields as $block_key => $tr_key) {
1210 $value = $this->convert_template_variables((string) ($block[$block_key] ?? ''), $post_id);
1211 if ($value !== '' && strpos($value, '%') === false) {
1212 $video[$tr_key] = $value;
1213 }
1214 }
1215
1216 foreach ($raw_fields as $block_key => $tr_key) {
1217 $value = (string) ($block[$block_key] ?? '');
1218 // Drop unresolved tokens (e.g. %post_thumbnail%, %date(...)%) so the
1219 // builder's own fallback (featured image, publish date) applies.
1220 if ($value !== '' && strpos($value, '%') === false) {
1221 $video[$tr_key] = $value;
1222 }
1223 }
1224
1225 return $video;
1226 }
1227
1228 /**
1229 * Extract the boolean robots flags that Rank Math stores inside the
1230 * `rank_math_robots` indexed array (alongside index/noindex/nofollow).
1231 *
1232 * @param mixed $robots_raw Raw (serialized) rank_math_robots value
1233 * @return array Map of present flag => true (noarchive/noimageindex/nosnippet)
1234 */
1235 private function extract_robots_flags($robots_raw): array {
1236 $robots = maybe_unserialize($robots_raw);
1237 if (!is_array($robots)) {
1238 return [];
1239 }
1240
1241 $flags = [];
1242 foreach (['noarchive', 'noimageindex', 'nosnippet'] as $flag) {
1243 if (in_array($flag, $robots, true)) {
1244 $flags[$flag] = true;
1245 }
1246 }
1247
1248 return $flags;
1249 }
1250
1251 /**
1252 * Normalize Rank Math's pillar/cornerstone content flag to 0 or 1.
1253 *
1254 * Rank Math stores the enabled flag as the string 'on' (its checkbox value).
1255 * A plain (int) cast of 'on' yields 0, which silently drops the flag during
1256 * migration — so it must be matched against Rank Math's truthy representations.
1257 *
1258 * @param mixed $value Raw rank_math_pillar_content meta value
1259 * @return int 0 or 1
1260 */
1261 private function normalize_pillar_content($value): int {
1262 return in_array($value, ['on', '1', 1, true], true) ? 1 : 0;
1263 }
1264
1265 /**
1266 * Decode the `rank_math_advanced_robots` post meta.
1267 *
1268 * Rank Math stores it as an associative array keyed by directive, where the
1269 * value is the configured length/value or `false` when the directive is
1270 * disabled, e.g. ['max-snippet' => '120', 'max-video-preview' => false,
1271 * 'max-image-preview' => 'large'].
1272 *
1273 * @param mixed $raw Raw (serialized) meta value
1274 * @return array Associative directive => value map (empty when unset)
1275 */
1276 private function parse_advanced_robots_meta($raw): array {
1277 $advanced = maybe_unserialize($raw);
1278 return is_array($advanced) ? $advanced : [];
1279 }
1280
1281 /**
1282 * Read an integer advanced-robots directive (max-snippet, max-video-preview).
1283 *
1284 * Returns an empty-string sentinel when the directive is unset or disabled
1285 * so the migrator skips it rather than forcing a 0 value (which would read
1286 * as "no snippet").
1287 *
1288 * @param array $advanced Decoded rank_math_advanced_robots map
1289 * @param string $key Directive key
1290 * @return int|string Integer value, or '' when unset/disabled
1291 */
1292 private function advanced_robot_int(array $advanced, string $key) {
1293 if (!isset($advanced[$key]) || $advanced[$key] === false || $advanced[$key] === '') {
1294 return '';
1295 }
1296 return (int) $advanced[$key];
1297 }
1298
1299 /**
1300 * Read a string advanced-robots directive (max-image-preview).
1301 *
1302 * @param array $advanced Decoded rank_math_advanced_robots map
1303 * @param string $key Directive key
1304 * @return string Directive value, or '' when unset/disabled
1305 */
1306 private function advanced_robot_string(array $advanced, string $key): string {
1307 if (!isset($advanced[$key]) || $advanced[$key] === false) {
1308 return '';
1309 }
1310 return (string) $advanced[$key];
1311 }
1312
1313 /**
1314 * Extract the local business phone from Rank Math titles.
1315 *
1316 * Rank Math stores local phones under `phone_numbers` (an array of
1317 * ['type' => ..., 'number' => ...]); older/knowledge-graph setups use a
1318 * flat `phone`. Prefer the first structured number, fall back to `phone`.
1319 *
1320 * @param array $titles Rank Math titles option
1321 * @return string
1322 */
1323 private function extract_rm_local_phone(array $titles): string {
1324 $numbers = $titles['phone_numbers'] ?? [];
1325 if (is_array($numbers)) {
1326 foreach ($numbers as $entry) {
1327 if (is_array($entry) && !empty($entry['number'])) {
1328 return (string) $entry['number'];
1329 }
1330 }
1331 }
1332
1333 return (string) ($titles['phone'] ?? '');
1334 }
1335
1336 /**
1337 * Convert Rank Math opening hours into ThinkRank's Business Info format.
1338 *
1339 * Rank Math stores `opening_hours` as a group of ['day' => 'Monday',
1340 * 'time' => '09:00-17:00'] rows (24h H:i). ThinkRank stores hours keyed by
1341 * lowercase day: ['monday' => ['open' => 'HH:MM', 'close' => 'HH:MM',
1342 * 'closed' => bool]]. ThinkRank supports a single range per day, so the
1343 * first parseable row for each day wins (Rank Math's optional mid-day-break
1344 * second row is dropped). Days with no configured row are omitted.
1345 *
1346 * @param array $titles Rank Math titles option
1347 * @return array
1348 */
1349 private function extract_rm_opening_hours(array $titles): array {
1350 $hours = $titles['opening_hours'] ?? [];
1351 if (!is_array($hours) || empty($hours)) {
1352 return [];
1353 }
1354
1355 $valid_days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
1356 $result = [];
1357
1358 foreach ($hours as $entry) {
1359 if (!is_array($entry) || empty($entry['day']) || empty($entry['time'])) {
1360 continue;
1361 }
1362
1363 $day = strtolower((string) $entry['day']);
1364 if (!in_array($day, $valid_days, true) || isset($result[$day])) {
1365 continue;
1366 }
1367
1368 // Parse "HH:MM-HH:MM" (allow single-digit hours and en dash).
1369 if (!preg_match('/^\s*(\d{1,2}:\d{2})\s*[-\x{2013}]\s*(\d{1,2}:\d{2})\s*$/u', (string) $entry['time'], $m)) {
1370 continue;
1371 }
1372
1373 $result[$day] = [
1374 'open' => $this->pad_time_hh_mm($m[1]),
1375 'close' => $this->pad_time_hh_mm($m[2]),
1376 'closed' => false,
1377 ];
1378 }
1379
1380 return $result;
1381 }
1382
1383 /**
1384 * Zero-pad the hour component of an "H:MM" time to "HH:MM".
1385 *
1386 * @param string $time Time string like "9:00" or "09:00"
1387 * @return string
1388 */
1389 private function pad_time_hh_mm(string $time): string {
1390 $parts = explode(':', $time);
1391 if (count($parts) !== 2) {
1392 return $time;
1393 }
1394
1395 return str_pad($parts[0], 2, '0', STR_PAD_LEFT) . ':' . $parts[1];
1396 }
1397
1398 /**
1399 * Extract the local business postal address from Rank Math titles.
1400 *
1401 * Rank Math's `local_address` is a schema.org PostalAddress array
1402 * (streetAddress/addressLocality/addressRegion/postalCode/addressCountry).
1403 * Returns a normalized array keyed for ThinkRank's Business Info fields, or
1404 * an empty array when nothing is set.
1405 *
1406 * @param array $titles Rank Math titles option
1407 * @return array
1408 */
1409 private function extract_rm_local_address(array $titles): array {
1410 $address = $titles['local_address'] ?? [];
1411 if (!is_array($address) || empty($address)) {
1412 return [];
1413 }
1414
1415 $map = [
1416 'street' => 'streetAddress',
1417 'city' => 'addressLocality',
1418 'state' => 'addressRegion',
1419 'postal_code' => 'postalCode',
1420 'country' => 'addressCountry',
1421 ];
1422
1423 $result = [];
1424 foreach ($map as $target => $rm_key) {
1425 if (!empty($address[$rm_key])) {
1426 $result[$target] = (string) $address[$rm_key];
1427 }
1428 }
1429
1430 return $result;
1431 }
1432
1433 /**
1434 * Extract geo coordinates from Rank Math titles.
1435 *
1436 * Rank Math's `geo` is a single "latitude,longitude" string. Returns
1437 * ['latitude' => ..., 'longitude' => ...] when both parse, else [].
1438 *
1439 * @param array $titles Rank Math titles option
1440 * @return array
1441 */
1442 private function extract_rm_local_geo(array $titles): array {
1443 $geo = trim((string) ($titles['geo'] ?? ''));
1444 if ($geo === '') {
1445 return [];
1446 }
1447
1448 $parts = preg_split('/[\s,]+/', $geo);
1449 if (!is_array($parts) || !isset($parts[0], $parts[1]) || $parts[0] === '' || $parts[1] === '') {
1450 return [];
1451 }
1452
1453 if (!is_numeric($parts[0]) || !is_numeric($parts[1])) {
1454 return [];
1455 }
1456
1457 return [
1458 'latitude' => (string) $parts[0],
1459 'longitude' => (string) $parts[1],
1460 ];
1461 }
1462
1463 /**
1464 * Map Rank Math's per-context title formats onto ThinkRank's Site Identity
1465 * title-format keys (homepage_title, post_title, page_title, category_title,
1466 * tag_title, search_title, archive_title).
1467 *
1468 * These are a DIFFERENT token dialect from `post_type_settings` above: the
1469 * Site Identity renderer resolves %site_title%/%post_title%/%category_title%/
1470 * %tag_title%/%search_term%/%archive_title%/%sep%, whereas the Global SEO
1471 * renderer resolves %title%/%sitename%/%excerpt%. Converting with the wrong
1472 * dialect renders the token literally, so each context is converted with the
1473 * matching context token.
1474 *
1475 * @param array $titles Rank Math titles option
1476 * @return array Map of ThinkRank title-format key => converted template
1477 */
1478 private function extract_rm_title_formats(array $titles): array {
1479 // ThinkRank key => [Rank Math key, the %…% token Rank Math's %title%/%term%
1480 // stands for in that context].
1481 $map = [
1482 'homepage_title' => ['homepage_title', ''],
1483 'post_title' => ['pt_post_title', '%post_title%'],
1484 'page_title' => ['pt_page_title', '%page_title%'],
1485 'category_title' => ['tax_category_title', '%category_title%'],
1486 'tag_title' => ['tax_post_tag_title', '%tag_title%'],
1487 'search_title' => ['search_title', '%search_term%'],
1488 'archive_title' => ['date_archive_title', '%archive_title%'],
1489 ];
1490
1491 $formats = [];
1492 foreach ($map as $tr_key => [$rm_key, $context_token]) {
1493 $raw = (string) ($titles[$rm_key] ?? '');
1494 if ($raw === '') {
1495 continue;
1496 }
1497
1498 $converted = $this->convert_identity_tokens($raw, $context_token);
1499 if ($converted !== '') {
1500 $formats[$tr_key] = $converted;
1501 }
1502 }
1503
1504 return $formats;
1505 }
1506
1507 /**
1508 * Convert a Rank Math title template into ThinkRank's Site Identity token
1509 * vocabulary, preserving structure.
1510 *
1511 * The Site Identity renderer resolves: %site_title%, %site_description%,
1512 * %tagline%, %sep%/%separator%, %date%, plus the per-context tokens
1513 * %post_title%, %page_title%, %category_title%, %tag_title%, %search_term%,
1514 * %archive_title%, %author_name%. Rank Math's context-neutral %title% /
1515 * %term% become the caller-supplied $context_token; anything ThinkRank
1516 * cannot resolve is stripped so it never renders literally.
1517 *
1518 * @param string $template Raw Rank Math template
1519 * @param string $context_token Token %title%/%term% stands for here (may be '')
1520 * @return string ThinkRank Site Identity template
1521 */
1522 private function convert_identity_tokens(string $template, string $context_token): string {
1523 if ($template === '' || strpos($template, '%') === false) {
1524 return trim($template);
1525 }
1526
1527 $token_map = [
1528 '%sitename%' => '%site_title%',
1529 '%sitedesc%' => '%site_description%',
1530 '%name%' => '%author_name%',
1531 '%search_query%' => '%search_term%',
1532 ];
1533 if ($context_token !== '') {
1534 $token_map['%title%'] = $context_token;
1535 $token_map['%term%'] = $context_token;
1536 }
1537 $template = str_replace(array_keys($token_map), array_values($token_map), $template);
1538
1539 $supported = [
1540 '%site_title%', '%site_description%', '%tagline%', '%sep%', '%separator%',
1541 '%date%', '%post_title%', '%page_title%', '%category_title%', '%tag_title%',
1542 '%search_term%', '%archive_title%', '%author_name%',
1543 ];
1544
1545 $template = preg_replace_callback(
1546 '/%[a-z0-9_]+%/i',
1547 static function (array $m) use ($supported): string {
1548 return in_array(strtolower($m[0]), $supported, true) ? $m[0] : '';
1549 },
1550 $template
1551 );
1552
1553 // Collapse whitespace left by stripped tokens, then drop a separator that
1554 // ended up leading/trailing because the token beside it was removed.
1555 $template = preg_replace('/\s{2,}/', ' ', (string) $template);
1556 $template = trim((string) $template);
1557 $template = preg_replace('/^(?:%sep%|%separator%)\s*/', '', $template);
1558 $template = preg_replace('/\s*(?:%sep%|%separator%)$/', '', (string) $template);
1559
1560 return trim((string) $template);
1561 }
1562
1563 /**
1564 * Extract Rank Math's author-archive behaviour for ThinkRank's Author
1565 * Archives feature (author_archives_enabled / _title / _meta_desc).
1566 *
1567 * @param array $titles Rank Math titles option
1568 * @return array Author archive settings
1569 */
1570 private function extract_rm_author_archives(array $titles): array {
1571 return [
1572 // Rank Math DISABLES archives with this flag; ThinkRank stores the
1573 // positive `enabled`, so invert.
1574 'enabled' => ($titles['disable_author_archives'] ?? 'off') !== 'on',
1575 'title' => $this->convert_identity_tokens((string) ($titles['author_archive_title'] ?? ''), '%author_name%'),
1576 'description' => $this->convert_identity_tokens((string) ($titles['author_archive_description'] ?? ''), '%author_name%'),
1577 ];
1578 }
1579
1580 /**
1581 * Read the post types Rank Math's Instant Indexing module auto-submits.
1582 *
1583 * @return array List of post type slugs (empty when unconfigured)
1584 */
1585 private function extract_rm_indexnow_post_types(): array {
1586 foreach (['rank-math-options-instant-indexing', 'rank_math_instant_indexing'] as $option_name) {
1587 $settings = get_option($option_name, []);
1588 if (!is_array($settings)) {
1589 continue;
1590 }
1591 foreach (['bing_post_types', 'indexnow_post_types', 'post_types'] as $key) {
1592 if (!empty($settings[$key]) && is_array($settings[$key])) {
1593 return array_values(array_map('strval', $settings[$key]));
1594 }
1595 }
1596 }
1597
1598 return [];
1599 }
1600
1601 /**
1602 * Capture Rank Math's IndexNow submission history (`rank_math_indexnow_log`,
1603 * a list of ['url', 'status', 'message', 'time', 'manual_submission']).
1604 *
1605 * Rank Math trims this option itself, but cap it defensively so one site's
1606 * runaway log cannot bloat the settings chunk — and report the drop rather
1607 * than truncating silently.
1608 *
1609 * @return array{entries: array[], truncated: int}
1610 */
1611 private function extract_rm_indexnow_log(): array {
1612 $log = get_option('rank_math_indexnow_log', []);
1613 if (!is_array($log) || empty($log)) {
1614 return ['entries' => [], 'truncated' => 0];
1615 }
1616
1617 // Newest last in Rank Math's log; keep the most recent when capping.
1618 $truncated = max(0, count($log) - self::MAX_INDEXNOW_LOG_ENTRIES);
1619 if ($truncated > 0) {
1620 $log = array_slice($log, -self::MAX_INDEXNOW_LOG_ENTRIES);
1621 }
1622
1623 $entries = [];
1624 foreach ($log as $row) {
1625 if (!is_array($row) || empty($row['url'])) {
1626 continue;
1627 }
1628
1629 $code = (int) ($row['status'] ?? 0);
1630
1631 $entries[] = [
1632 'url' => (string) $row['url'],
1633 // ThinkRank stores a success/failed verdict alongside the raw code.
1634 'status' => ($code >= 200 && $code < 300) ? 'success' : 'failed',
1635 'response_code' => $code,
1636 'response_message' => (string) ($row['message'] ?? ''),
1637 'submitted_at' => !empty($row['time'])
1638 ? gmdate('Y-m-d H:i:s', (int) $row['time'])
1639 : '',
1640 ];
1641 }
1642
1643 return ['entries' => $entries, 'truncated' => $truncated];
1644 }
1645
1646 /**
1647 * Capture the post types Rank Math builds its News / Video sitemaps from,
1648 * for ThinkRank Pro's Publisher Sitemaps.
1649 *
1650 * @param array $sitemap Rank Math sitemap option
1651 * @return array News/Video post type lists (absent keys omitted)
1652 */
1653 private function extract_rm_publisher_sitemaps(array $sitemap): array {
1654 $out = [];
1655
1656 foreach (['video_sitemap_post_type' => 'video_post_types', 'news_sitemap_post_type' => 'news_post_types'] as $rm_key => $tr_key) {
1657 if (empty($sitemap[$rm_key]) || !is_array($sitemap[$rm_key])) {
1658 continue;
1659 }
1660 $out[$tr_key] = array_values(array_map('strval', $sitemap[$rm_key]));
1661 }
1662
1663 return $out;
1664 }
1665
1666 /**
1667 * Map Rank Math's email-report cadence onto ThinkRank's `frequency_days`.
1668 *
1669 * @param string $frequency Rank Math frequency slug
1670 * @return int Days between reports (ThinkRank default 30 when unknown)
1671 */
1672 private function map_rm_email_frequency(string $frequency): int {
1673 switch (strtolower(trim($frequency))) {
1674 case 'daily':
1675 return 1;
1676 case 'weekly':
1677 return 7;
1678 case 'monthly':
1679 return 30;
1680 default:
1681 return 30;
1682 }
1683 }
1684
1685 /**
1686 * Extract post type settings from Rank Math titles options
1687 *
1688 * @param array $titles Rank Math titles option
1689 * @return array Post type settings
1690 */
1691 private function extract_rm_post_type_settings(array $titles): array {
1692 $settings = [];
1693 $post_types = get_post_types(['public' => true], 'names');
1694
1695 foreach ($post_types as $pt) {
1696 $pt_settings = [];
1697 if (isset($titles["pt_{$pt}_title"])) {
1698 $pt_settings['title_template'] = $this->convert_template_tokens($titles["pt_{$pt}_title"]);
1699 }
1700 if (isset($titles["pt_{$pt}_description"])) {
1701 $pt_settings['description_template'] = $this->convert_template_tokens($titles["pt_{$pt}_description"]);
1702 }
1703 if (isset($titles["pt_{$pt}_robots"])) {
1704 $pt_settings['robots'] = maybe_unserialize($titles["pt_{$pt}_robots"]);
1705 }
1706 // Rank Math's per-type Link Suggestions toggle maps onto ThinkRank's
1707 // global-SEO `link_suggestions` (which gates the Pillar Content column
1708 // and post-list filter). Only captured when Rank Math stored a value.
1709 if (isset($titles["pt_{$pt}_link_suggestions"])) {
1710 $pt_settings['link_suggestions'] = $titles["pt_{$pt}_link_suggestions"] === 'on';
1711 }
1712 // Rank Math only applies per-post-type robots when "custom robots" is
1713 // enabled for that type; capture the flag so migration does not force
1714 // robots that Rank Math was ignoring.
1715 $pt_settings['custom_robots'] = ($titles["pt_{$pt}_custom_robots"] ?? 'off') === 'on';
1716 // `link_suggestions` is a boolean, so array_key_exists — not !empty —
1717 // decides whether the type is worth emitting (a deliberate "off" is
1718 // exactly the value worth carrying over).
1719 if (!empty($pt_settings['title_template']) || !empty($pt_settings['description_template'])
1720 || !empty($pt_settings['robots']) || array_key_exists('link_suggestions', $pt_settings)) {
1721 $settings[$pt] = $pt_settings;
1722 }
1723 }
1724
1725 return $settings;
1726 }
1727 }
1728