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

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