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

class-aioseo-exporter.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.9.0, at includes/admin/importers/class-aioseo-exporter.php

1,311 lines 55.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * All in One SEO Exporter
5 *
6 * Reads AIOSEO data from the custom {prefix}aioseo_posts table (NOT postmeta).
7 * Must DESCRIBE table before querying for version safety.
8 *
9 * @package ThinkRank\Admin\Importers
10 * @since 2.0.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\Admin\Importers;
16
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 /**
22 * AIOSEO Exporter Class
23 *
24 * @since 2.0.0
25 */
26 class AIOSEO_Exporter extends Abstract_Plugin_Exporter {
27
28 /**
29 * Cached table columns from DESCRIBE
30 *
31 * @var array|null
32 */
33 private ?array $table_columns = null;
34
35 /**
36 * Constructor
37 */
38 public function __construct() {
39 $this->plugin_slug = 'aioseo';
40 $this->plugin_name = 'All in One SEO';
41 $this->plugin_file = 'all-in-one-seo-pack/all_in_one_seo_pack.php';
42 $this->meta_key_prefix = '';
43 $this->option_keys = ['aioseo_options'];
44 }
45
46 /**
47 * Get the AIOSEO posts table name
48 *
49 * @return string
50 */
51 private function get_table_name(): string {
52 global $wpdb;
53 return $wpdb->prefix . 'aioseo_posts';
54 }
55
56 /**
57 * Get the AIOSEO redirects table name
58 *
59 * @return string
60 */
61 private function get_redirects_table_name(): string {
62 global $wpdb;
63 return $wpdb->prefix . 'aioseo_redirects';
64 }
65
66 /**
67 * Check if the AIOSEO posts table exists
68 *
69 * @return bool
70 */
71 private function table_exists(): bool {
72 global $wpdb;
73 return (bool) $wpdb->get_var(
74 $wpdb->prepare("SHOW TABLES LIKE %s", $this->get_table_name())
75 );
76 }
77
78 /**
79 * Get table column names via DESCRIBE for version safety
80 *
81 * @return array Array of column names
82 */
83 private function get_table_columns(): array {
84 if ($this->table_columns !== null) {
85 return $this->table_columns;
86 }
87
88 global $wpdb;
89
90 if (!$this->table_exists()) {
91 $this->table_columns = [];
92 return $this->table_columns;
93 }
94
95 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
96 $columns = $wpdb->get_col("DESCRIBE {$this->get_table_name()}", 0);
97 $this->table_columns = is_array($columns) ? $columns : [];
98
99 return $this->table_columns;
100 }
101
102 /**
103 * Check if a column exists in the AIOSEO table
104 *
105 * @param string $column Column name
106 * @return bool
107 */
108 private function has_column(string $column): bool {
109 return in_array($column, $this->get_table_columns(), true);
110 }
111
112 /**
113 * {@inheritDoc}
114 */
115 public function detect(): bool {
116 if (!$this->table_exists()) {
117 return false;
118 }
119
120 global $wpdb;
121 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
122 $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->get_table_name()}");
123
124 return $count > 0;
125 }
126
127 /**
128 * {@inheritDoc}
129 */
130 public function get_available_types(): array {
131 global $wpdb;
132
133 $types = [];
134
135 if ($this->table_exists()) {
136 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
137 $post_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->get_table_name()}");
138 if ($post_count > 0) {
139 $types['postmeta'] = $post_count;
140 }
141 }
142
143 // AIOSEO Pro term SEO table.
144 $terms_table = $wpdb->prefix . 'aioseo_terms';
145 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $terms_table))) {
146 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
147 $term_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$terms_table}");
148 if ($term_count > 0) {
149 $types['termmeta'] = $term_count;
150 }
151 }
152
153 // Check for redirections table
154 $redirects_table = $this->get_redirects_table_name();
155 $redirects_exists = $wpdb->get_var(
156 $wpdb->prepare("SHOW TABLES LIKE %s", $redirects_table)
157 );
158 if ($redirects_exists) {
159 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
160 $redirect_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$redirects_table}");
161 if ($redirect_count > 0) {
162 $types['redirections'] = $redirect_count;
163 }
164 }
165
166 if (get_option('aioseo_options', null) !== null) {
167 $types['settings'] = 1;
168 }
169
170 return $types;
171 }
172
173 /**
174 * {@inheritDoc}
175 */
176 protected function export_postmeta_page(int $page): array {
177 if (!$this->table_exists()) {
178 return [];
179 }
180
181 global $wpdb;
182
183 $table = $this->get_table_name();
184 $offset = ($page - 1) * $this->chunk_size;
185 $columns = $this->get_table_columns();
186
187 if (empty($columns)) {
188 return [];
189 }
190
191 $rows = $wpdb->get_results(
192 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
193 $wpdb->prepare(
194 "SELECT * FROM {$table} ORDER BY post_id ASC LIMIT %d OFFSET %d",
195 $this->chunk_size,
196 $offset
197 ),
198 ARRAY_A
199 );
200 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
201
202 // Report the raw fetched-row count so export_chunk() paginates on it
203 // rather than the post-filter emitted count (rows with post_id=0 are
204 // skipped below).
205 $this->last_page_row_count = is_array($rows) ? count($rows) : 0;
206
207 if (empty($rows)) {
208 return [];
209 }
210
211 $records = [];
212 foreach ($rows as $row) {
213 $post_id = (int) ($row['post_id'] ?? 0);
214 if (!$post_id) {
215 continue;
216 }
217
218 // Parse keyphrases JSON
219 $focus_keyword = '';
220 $additional_keyphrases = [];
221 if (!empty($row['keyphrases'])) {
222 $keyphrases = json_decode($row['keyphrases'], true);
223 if (is_array($keyphrases)) {
224 if (isset($keyphrases['focus']['keyphrase'])) {
225 $focus_keyword = $keyphrases['focus']['keyphrase'];
226 }
227 if (isset($keyphrases['additional']) && is_array($keyphrases['additional'])) {
228 foreach ($keyphrases['additional'] as $additional) {
229 if (isset($additional['keyphrase']) && !empty($additional['keyphrase'])) {
230 $additional_keyphrases[] = $additional['keyphrase'];
231 }
232 }
233 }
234 }
235 }
236
237 // Robots. AIOSEO's robots_default flag means "inherit the global
238 // defaults" — the per-post robots columns are meaningless then
239 // (NULL or stale), so no directive may be emitted. Coercing the
240 // NULL robots_max_* columns to 0 would write an explicit
241 // "snippets/previews disabled" override onto every post. The
242 // migrator skips null values entirely.
243 $robots_default = $this->safe_column_int($row, 'robots_default') === 1;
244 $noindex = $robots_default ? null : $this->safe_column_int($row, 'robots_noindex');
245 $nofollow = $robots_default ? null : $this->safe_column_int($row, 'robots_nofollow');
246
247 // AIOSEO's schema_type placeholder 'default' means "no explicit
248 // choice"; passing it through would stamp a literal 'default'
249 // schema type on every post.
250 $schema_type = $this->safe_column($row, 'schema_type');
251 if (strtolower($schema_type) === 'default') {
252 $schema_type = '';
253 }
254
255 // max-image-preview is only a real restriction as 'none'/'standard';
256 // 'large' is the crawler default (and what AIOSEO stores alongside
257 // otherwise-inert robots rows), so emitting it would manufacture a
258 // robots override for posts that have no active directive.
259 $max_image_preview = $robots_default ? '' : $this->safe_column($row, 'robots_max_imagepreview');
260 if (!in_array($max_image_preview, ['none', 'standard'], true)) {
261 $max_image_preview = '';
262 }
263
264 // Full focus-keyword list (focus + additional) for the post's
265 // ThinkRank keyword array; the migrator falls back to the single
266 // focus_keyword when this is absent, dropping the additionals.
267 $focus_keywords = array_values(array_filter(
268 array_merge([$focus_keyword], $additional_keyphrases),
269 static fn($keyword) => trim((string) $keyword) !== ''
270 ));
271
272 $records[] = [
273 'object_id' => $post_id,
274 'object_type' => 'post',
275 'source_plugin' => $this->plugin_slug,
276 'data' => [
277 'seo_title' => $this->convert_template_variables($this->safe_column($row, 'title'), $post_id),
278 'meta_description' => $this->convert_template_variables($this->safe_column($row, 'description'), $post_id),
279 'focus_keyword' => $focus_keyword,
280 'focus_keywords' => $focus_keywords,
281 'canonical_url' => $this->safe_column($row, 'canonical_url'),
282 'noindex' => $noindex,
283 'nofollow' => $nofollow,
284 'noarchive' => $robots_default ? null : $this->safe_column_int($row, 'robots_noarchive'),
285 'noimageindex' => $robots_default ? null : $this->safe_column_int($row, 'robots_noimageindex'),
286 'nosnippet' => $robots_default ? null : $this->safe_column_int($row, 'robots_nosnippet'),
287 'max_snippet' => $robots_default ? null : $this->robots_limit($row, 'robots_max_snippet'),
288 'max_video_preview' => $robots_default ? null : $this->robots_limit($row, 'robots_max_videopreview'),
289 'max_image_preview' => $max_image_preview,
290 'og_title' => $this->convert_template_variables($this->safe_column($row, 'og_title'), $post_id),
291 'og_description' => $this->convert_template_variables($this->safe_column($row, 'og_description'), $post_id),
292 'og_image' => $this->safe_column($row, 'og_image_custom_url'),
293 'twitter_title' => $this->convert_template_variables($this->safe_column($row, 'twitter_title'), $post_id),
294 'twitter_description' => $this->convert_template_variables($this->safe_column($row, 'twitter_description'), $post_id),
295 'twitter_image' => $this->safe_column($row, 'twitter_image_custom_url'),
296 'primary_category' => $this->extract_primary_category($row),
297 'schema_type' => $schema_type,
298 // AIOSEO pillar content maps directly to ThinkRank pillar content.
299 'pillar_content' => $this->safe_column_int($row, 'pillar_content'),
300 ],
301 'extended' => [
302 'focus_keywords_additional' => $additional_keyphrases,
303 'pillar_content' => (bool) $this->safe_column_int($row, 'pillar_content'),
304 // "Use Facebook data for Twitter" toggle — ThinkRank has no
305 // per-post equivalent yet; preserved for a future mapping.
306 'twitter_use_og' => (bool) $this->safe_column_int($row, 'twitter_use_og'),
307 'og_object_type' => $this->safe_column($row, 'og_object_type'),
308 'og_image_type' => $this->safe_column($row, 'og_image_type'),
309 'twitter_card' => $this->safe_column($row, 'twitter_card'),
310 'twitter_image_type' => $this->safe_column($row, 'twitter_image_type'),
311 'schema_type_options' => $this->safe_column($row, 'schema_type_options'),
312 'seo_score' => $this->safe_column_int($row, 'seo_score'),
313 'keyphrases_score' => $this->safe_column($row, 'keyphrases_score'),
314 'page_analysis' => $this->safe_column($row, 'page_analysis'),
315 'priority' => $this->safe_column($row, 'priority'),
316 'frequency' => $this->safe_column($row, 'frequency'),
317 'videos' => $this->safe_column($row, 'videos'),
318 'video_thumbnail' => $this->safe_column($row, 'video_thumbnail'),
319 'local_seo' => $this->safe_column($row, 'local_seo'),
320 ],
321 ];
322 }
323
324 return $records;
325 }
326
327 /**
328 * Read an AIOSEO robots_max_* limit column, treating "no explicit limit"
329 * as absent. AIOSEO stores NULL when unset and -1 for "unlimited/default";
330 * only a real 0+ value is an actual directive.
331 *
332 * @param array $row Database row
333 * @param string $column Column name
334 * @return int|null Limit value, or null when not set
335 */
336 private function robots_limit(array $row, string $column): ?int {
337 if (!$this->has_column($column)) {
338 return null;
339 }
340
341 $value = $row[$column] ?? null;
342 if ($value === null || $value === '' || (int) $value === -1) {
343 return null;
344 }
345
346 return (int) $value;
347 }
348
349 /**
350 * Extract the primary category term ID from AIOSEO's primary_term column
351 * (JSON of {"<taxonomy>": <term_id>}, written per-taxonomy — see AIOSEO's
352 * own Yoast importer).
353 *
354 * @param array $row Database row
355 * @return int Term ID, or 0 when none is set
356 */
357 private function extract_primary_category(array $row): int {
358 if (!$this->has_column('primary_term') || empty($row['primary_term'])) {
359 return 0;
360 }
361
362 $terms = json_decode((string) $row['primary_term'], true);
363 if (!is_array($terms) || empty($terms['category'])) {
364 return 0;
365 }
366
367 return (int) $terms['category'];
368 }
369
370 /**
371 * {@inheritDoc}
372 *
373 * AIOSEO Pro stores term SEO in its own {prefix}aioseo_terms table (same
374 * column layout as aioseo_posts, keyed by term_id); some free/legacy
375 * versions used aioseo_*-prefixed termmeta instead. The table wins when it
376 * exists, with the termmeta scan as fallback.
377 */
378 protected function export_termmeta_page(int $page): array {
379 global $wpdb;
380
381 $terms_table = $wpdb->prefix . 'aioseo_terms';
382 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $terms_table))) {
383 return $this->export_terms_table_page($page);
384 }
385
386 $offset = ($page - 1) * $this->chunk_size;
387
388 $term_ids = $wpdb->get_col(
389 $wpdb->prepare(
390 "SELECT DISTINCT term_id FROM {$wpdb->termmeta} WHERE meta_key LIKE %s ORDER BY term_id ASC LIMIT %d OFFSET %d",
391 $wpdb->esc_like('aioseo_') . '%',
392 $this->chunk_size,
393 $offset
394 )
395 );
396
397 if (empty($term_ids)) {
398 return [];
399 }
400
401 $records = [];
402 foreach ($term_ids as $term_id) {
403 $term_id = (int) $term_id;
404
405 $records[] = [
406 'object_id' => $term_id,
407 'object_type' => 'term',
408 'source_plugin' => $this->plugin_slug,
409 'data' => [
410 'seo_title' => get_term_meta($term_id, 'aioseo_title', true) ?: '',
411 'meta_description' => get_term_meta($term_id, 'aioseo_description', true) ?: '',
412 'canonical_url' => get_term_meta($term_id, 'aioseo_canonical_url', true) ?: '',
413 'noindex' => (int) get_term_meta($term_id, 'aioseo_noindex', true),
414 'nofollow' => (int) get_term_meta($term_id, 'aioseo_nofollow', true),
415 'og_title' => get_term_meta($term_id, 'aioseo_og_title', true) ?: '',
416 'og_description' => get_term_meta($term_id, 'aioseo_og_description', true) ?: '',
417 ],
418 'extended' => [],
419 ];
420 }
421
422 return $records;
423 }
424
425 /**
426 * Export one page of AIOSEO Pro's aioseo_terms table.
427 *
428 * @param int $page Page number (1-indexed)
429 * @return array Snapshot records
430 */
431 private function export_terms_table_page(int $page): array {
432 global $wpdb;
433
434 $table = $wpdb->prefix . 'aioseo_terms';
435 $offset = ($page - 1) * $this->chunk_size;
436
437 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
438 $rows = $wpdb->get_results(
439 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
440 $wpdb->prepare(
441 "SELECT * FROM {$table} ORDER BY term_id ASC LIMIT %d OFFSET %d",
442 $this->chunk_size,
443 $offset
444 ),
445 ARRAY_A
446 );
447 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
448
449 $this->last_page_row_count = is_array($rows) ? count($rows) : 0;
450
451 if (empty($rows)) {
452 return [];
453 }
454
455 $records = [];
456 foreach ($rows as $row) {
457 $term_id = (int) ($row['term_id'] ?? 0);
458 if (!$term_id) {
459 continue;
460 }
461
462 $focus_keyword = '';
463 $additional = [];
464 if (!empty($row['keyphrases'])) {
465 $keyphrases = json_decode((string) $row['keyphrases'], true);
466 if (is_array($keyphrases)) {
467 $focus_keyword = (string) ($keyphrases['focus']['keyphrase'] ?? '');
468 foreach ($keyphrases['additional'] ?? [] as $extra) {
469 if (!empty($extra['keyphrase'])) {
470 $additional[] = (string) $extra['keyphrase'];
471 }
472 }
473 }
474 }
475
476 // Same robots semantics as posts: robots_default means "inherit".
477 $robots_default = (int) ($row['robots_default'] ?? 1) === 1;
478
479 $records[] = [
480 'object_id' => $term_id,
481 'object_type' => 'term',
482 'source_plugin' => $this->plugin_slug,
483 'data' => [
484 'seo_title' => $this->convert_template_variables((string) ($row['title'] ?? '')),
485 'meta_description' => $this->convert_template_variables((string) ($row['description'] ?? '')),
486 'focus_keyword' => $focus_keyword,
487 'focus_keywords' => array_values(array_filter(
488 array_merge([$focus_keyword], $additional),
489 static fn($keyword) => trim((string) $keyword) !== ''
490 )),
491 'canonical_url' => (string) ($row['canonical_url'] ?? ''),
492 'noindex' => $robots_default ? null : (int) ($row['robots_noindex'] ?? 0),
493 'nofollow' => $robots_default ? null : (int) ($row['robots_nofollow'] ?? 0),
494 'og_title' => $this->convert_template_variables((string) ($row['og_title'] ?? '')),
495 'og_description' => $this->convert_template_variables((string) ($row['og_description'] ?? '')),
496 'og_image' => (string) ($row['og_image_custom_url'] ?? ''),
497 'twitter_title' => $this->convert_template_variables((string) ($row['twitter_title'] ?? '')),
498 'twitter_description' => $this->convert_template_variables((string) ($row['twitter_description'] ?? '')),
499 'twitter_image' => (string) ($row['twitter_image_custom_url'] ?? ''),
500 ],
501 'extended' => [],
502 ];
503 }
504
505 return $records;
506 }
507
508 /**
509 * {@inheritDoc}
510 */
511 protected function export_usermeta_page(int $page): array {
512 return [];
513 }
514
515 /**
516 * {@inheritDoc}
517 */
518 protected function export_settings(): array {
519 $options_raw = get_option('aioseo_options', '');
520
521 // aioseo_options is stored as a JSON string
522 $options = is_string($options_raw) ? json_decode($options_raw, true) : [];
523 if (!is_array($options)) {
524 $options = [];
525 }
526
527 // Guard against scalar sub-values (a malformed/legacy JSON blob) before
528 // they reach the array-typed extractors below.
529 $search_appearance = is_array($options['searchAppearance'] ?? null) ? $options['searchAppearance'] : [];
530 $social = is_array($options['social'] ?? null) ? $options['social'] : [];
531 $sitemap = is_array($options['sitemap'] ?? null) ? $options['sitemap'] : [];
532 $archives = is_array($search_appearance['archives'] ?? null) ? $search_appearance['archives'] : [];
533
534 // AIOSEO Pro add-on settings (news/video sitemaps, image SEO, local
535 // business) live in a SEPARATE aioseo_options_pro option.
536 $pro_raw = get_option('aioseo_options_pro', '');
537 $pro = is_string($pro_raw) ? json_decode($pro_raw, true) : [];
538 $pro = is_array($pro) ? $pro : [];
539
540 // Bare Twitter handle: ThinkRank stores handles (matching Yoast/Rank
541 // Math), AIOSEO stores a full profile URL.
542 $twitter = (string) ($social['profiles']['urls']['twitterUrl'] ?? '');
543 $twitter = preg_replace('#^https?://(?:www\.)?(?:twitter|x)\.com/#i', '', $twitter);
544 $twitter = ltrim((string) $twitter, '@');
545
546 // Per-post-type/taxonomy templates live in the SEPARATE
547 // aioseo_options_dynamic option (also a JSON string).
548 $dynamic_raw = get_option('aioseo_options_dynamic', '');
549 $dynamic = is_string($dynamic_raw) ? json_decode($dynamic_raw, true) : [];
550 $dynamic_sa = is_array($dynamic['searchAppearance'] ?? null) ? $dynamic['searchAppearance'] : [];
551
552 return [
553 [
554 'type' => 'settings',
555 'source_plugin' => $this->plugin_slug,
556 'data' => [
557 // AIOSEO stores the separator HTML-encoded ('&#45;'); the
558 // migrator's Global-SEO path writes it verbatim, so decode
559 // here at the source.
560 'separator' => $this->decode_entities($search_appearance['global']['separator'] ?? '-'),
561 'homepage_title' => $this->convert_template_variables($search_appearance['global']['siteTitle'] ?? ''),
562 'homepage_description' => $this->convert_template_variables($search_appearance['global']['metaDescription'] ?? ''),
563 'organization_name' => $this->convert_template_variables($search_appearance['global']['schema']['organizationName'] ?? ''),
564 'organization_logo' => $search_appearance['global']['schema']['organizationLogo'] ?? '',
565 'knowledge_graph' => $this->extract_aioseo_knowledge_graph($search_appearance),
566 'noindex_archives' => [
567 'author' => $this->archive_noindex($archives['author'] ?? []),
568 'date' => $this->archive_noindex($archives['date'] ?? []),
569 ],
570 // Default Twitter card ('summary'/'summary_large_image') for
571 // ThinkRank's Social Meta settings.
572 'twitter_card_type' => (string) ($social['twitter']['general']['defaultCardType'] ?? ''),
573 // Site-wide social defaults with direct ThinkRank homes.
574 'social_defaults' => [
575 'facebook_app_id' => (string) ($social['facebook']['advanced']['appId'] ?? ''),
576 'og_default_image' => (string) ($social['facebook']['general']['defaultImagePosts'] ?? ''),
577 ],
578 'social_profiles' => [
579 'facebook' => $social['facebook']['general']['facebookPageUrl'] ?? '',
580 'twitter' => $twitter,
581 'instagram' => $social['profiles']['urls']['instagramUrl'] ?? '',
582 'linkedin' => $social['profiles']['urls']['linkedinUrl'] ?? '',
583 'youtube' => $social['profiles']['urls']['youtubeUrl'] ?? '',
584 'pinterest' => $social['profiles']['urls']['pinterestUrl'] ?? '',
585 ],
586 ],
587 'extended' => [
588 'search_appearance' => $search_appearance,
589 // Normalize AIOSEO's nested sitemap tree into the flat keys the
590 // migrator's migrate_sitemap() consumes. The full raw tree is kept
591 // under sitemap_settings_raw for features ThinkRank does not model.
592 'sitemap_settings' => $this->normalize_aioseo_sitemap($sitemap),
593 'sitemap_settings_raw' => $sitemap,
594 'social_settings' => $social,
595 'advanced' => $options['advanced'] ?? [],
596 'access_control' => $options['accessControl'] ?? [],
597 // Site-Identity per-context title formats (template dialect:
598 // %site_title%/%post_title%/%sep%/…).
599 'title_formats' => $this->extract_aioseo_title_formats($search_appearance, $dynamic_sa, $archives),
600 // Global-SEO per-post-type templates (template dialect:
601 // %title%/%sitename%/%sep%/…) — a DIFFERENT vocabulary from
602 // title_formats, resolved by a different renderer.
603 'post_type_settings' => $this->extract_aioseo_post_type_settings($dynamic_sa),
604 'author_archives' => $this->extract_aioseo_author_archives($archives),
605 'breadcrumb_settings' => $this->extract_aioseo_breadcrumbs(is_array($options['breadcrumbs'] ?? null) ? $options['breadcrumbs'] : []),
606 // Webmaster-tools verification codes. ThinkRank only renders
607 // a Pinterest verification tag today (the migrator applies
608 // it); the rest is preserved here — this bucket is NOT in
609 // HANDLED_EXTENDED_SETTINGS, so it gates /import/cleanup.
610 'webmaster_tools' => array_filter([
611 'google' => (string) ($options['webmasterTools']['google'] ?? ''),
612 'bing' => (string) ($options['webmasterTools']['bing'] ?? ''),
613 'yandex' => (string) ($options['webmasterTools']['yandex'] ?? ''),
614 'baidu' => (string) ($options['webmasterTools']['baidu'] ?? ''),
615 'pinterest' => (string) ($options['webmasterTools']['pinterest'] ?? ''),
616 ]),
617 // AIOSEO Pro add-ons → existing migrator paths.
618 'publisher_sitemaps' => $this->extract_aioseo_publisher_sitemaps($pro),
619 'image_seo' => $this->extract_aioseo_image_seo($pro),
620 'local_seo' => $this->extract_aioseo_local_seo($pro),
621 ],
622 ],
623 ];
624 }
625
626 /**
627 * Decode HTML entities AIOSEO stores in settings values (e.g. the title
628 * separator '&#45;' or breadcrumb '&raquo;').
629 *
630 * @param string $value Raw value
631 * @return string Decoded value
632 */
633 private function decode_entities(string $value): string {
634 return trim(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
635 }
636
637 /**
638 * Whether an AIOSEO archive node is explicitly noindexed: its robotsMeta
639 * must have default=false (custom robots on) AND noindex=true.
640 *
641 * @param array $archive AIOSEO archives.{author|date|search} node
642 * @return bool
643 */
644 private function archive_noindex(array $archive): bool {
645 // AIOSEO can remove an archive entirely (show=false, e.g. its RankMath
646 // importer maps "disable date archives" this way). ThinkRank cannot
647 // remove date archives, so noindex is the nearest equivalent.
648 if (array_key_exists('show', $archive) && !$archive['show']) {
649 return true;
650 }
651
652 $robots = $archive['advanced']['robotsMeta'] ?? [];
653 if (!is_array($robots) || !empty($robots['default'])) {
654 return false;
655 }
656
657 return !empty($robots['noindex']);
658 }
659
660 /**
661 * Extract AIOSEO's Knowledge Graph entity (searchAppearance.global.schema).
662 * AIOSEO stores siteRepresents ('organization'|'person') with separate
663 * organizationName/personName fields, both of which may hold smart tags
664 * (organizationName defaults to '#site_title').
665 *
666 * @param array $search_appearance searchAppearance option subtree
667 * @return array{type: string, name: string}
668 */
669 private function extract_aioseo_knowledge_graph(array $search_appearance): array {
670 $schema = $search_appearance['global']['schema'] ?? [];
671 $represents = strtolower((string) ($schema['siteRepresents'] ?? ''));
672
673 if ($represents === 'person') {
674 $name = (string) ($schema['personName'] ?? '');
675
676 return [
677 'type' => 'person',
678 'name' => $this->convert_template_variables($name),
679 ];
680 }
681
682 if ($represents === 'organization') {
683 $name = (string) ($schema['organizationName'] ?? '');
684
685 return [
686 'type' => 'organization',
687 'name' => $this->convert_template_variables($name),
688 ];
689 }
690
691 return ['type' => '', 'name' => ''];
692 }
693
694 /**
695 * Map AIOSEO's per-context title templates onto ThinkRank's Site Identity
696 * keys, converting #smart_tags to the identity renderer's %token%
697 * vocabulary.
698 *
699 * @param array $search_appearance aioseo_options searchAppearance subtree
700 * @param array $dynamic_sa aioseo_options_dynamic searchAppearance subtree
701 * @param array $archives searchAppearance.archives subtree
702 * @return array Map of ThinkRank title-format key => converted template
703 */
704 private function extract_aioseo_title_formats(array $search_appearance, array $dynamic_sa, array $archives): array {
705 $post_types = $dynamic_sa['postTypes'] ?? [];
706 $taxonomies = $dynamic_sa['taxonomies'] ?? [];
707
708 // ThinkRank key => [raw AIOSEO template, token #post_title/#taxonomy_title
709 // stands for in that context].
710 $sources = [
711 'homepage_title' => [$search_appearance['global']['siteTitle'] ?? '', ''],
712 'post_title' => [$post_types['post']['title'] ?? '', '%post_title%'],
713 'page_title' => [$post_types['page']['title'] ?? '', '%page_title%'],
714 'category_title' => [$taxonomies['category']['title'] ?? '', '%category_title%'],
715 'tag_title' => [$taxonomies['post_tag']['title'] ?? '', '%tag_title%'],
716 'search_title' => [$archives['search']['title'] ?? '', '%search_term%'],
717 'archive_title' => [$archives['date']['title'] ?? '', '%date%'],
718 'author_title' => [$archives['author']['title'] ?? '', '%author_name%'],
719 ];
720
721 $formats = [];
722 foreach ($sources as $tr_key => [$raw, $context_token]) {
723 $converted = $this->convert_aioseo_identity_template((string) $raw, $context_token);
724 if ($converted !== '') {
725 $formats[$tr_key] = $converted;
726 }
727 }
728
729 return $formats;
730 }
731
732 /**
733 * Convert an AIOSEO title template into ThinkRank's Site Identity token
734 * vocabulary, preserving structure. Tokens ThinkRank cannot resolve are
735 * stripped, and a separator left dangling by that strip is dropped.
736 *
737 * @param string $template Raw AIOSEO template (#smart_tag syntax)
738 * @param string $context_token Token #post_title/#taxonomy_title stands for (may be '')
739 * @return string ThinkRank Site Identity template
740 */
741 private function convert_aioseo_identity_template(string $template, string $context_token): string {
742 if ($template === '' || strpos($template, '#') === false) {
743 return trim($template);
744 }
745
746 $map = [
747 '#site_title' => '%site_title%',
748 '#tagline' => '%site_description%',
749 '#separator_sa' => '%sep%',
750 '#search_term' => '%search_term%',
751 '#archive_date' => '%date%',
752 '#post_date' => '%date%',
753 '#archive_title' => '%archive_title%',
754 '#author_name' => '%author_name%',
755 // AIOSEO splits the author name into first/last tags; ThinkRank has
756 // a single %author_name%, so first maps onto it and last collapses.
757 '#author_first_name' => '%author_name%',
758 '#author_last_name' => '',
759 ];
760 if ($context_token !== '') {
761 $map['#post_title'] = $context_token;
762 $map['#taxonomy_title'] = $context_token;
763 $map['#category_title'] = $context_token;
764 $map['#tag_title'] = $context_token;
765 }
766 $template = str_replace(array_keys($map), array_values($map), $template);
767
768 // Drop remaining AIOSEO tags ThinkRank cannot resolve.
769 $template = (string) preg_replace(self::AIOSEO_TAG_PATTERN, '', $template);
770
771 $template = (string) preg_replace('/\s{2,}/', ' ', $template);
772 $template = trim($template);
773 $template = (string) preg_replace('/^(?:%sep%)\s*/', '', $template);
774 $template = (string) preg_replace('/\s*(?:%sep%)$/', '', $template);
775
776 return trim($template);
777 }
778
779 /**
780 * Extract AIOSEO's per-post-type title/description templates (from
781 * aioseo_options_dynamic) in the shape the migrator's
782 * migrate_post_type_settings() consumes, using the Global SEO renderer's
783 * %title%/%sitename% dialect. A post type's noindex only counts when its
784 * robotsMeta default flag is off (custom robots active).
785 *
786 * @param array $dynamic_sa aioseo_options_dynamic searchAppearance subtree
787 * @return array Map of post_type => {title_template, description_template, noindex}
788 */
789 private function extract_aioseo_post_type_settings(array $dynamic_sa): array {
790 $settings = [];
791
792 foreach ($dynamic_sa['postTypes'] ?? [] as $post_type => $pt) {
793 if (!is_array($pt)) {
794 continue;
795 }
796
797 $pt_settings = [];
798
799 $title = $this->convert_aioseo_global_template((string) ($pt['title'] ?? ''));
800 if ($title !== '') {
801 $pt_settings['title_template'] = $title;
802 }
803
804 $description = $this->convert_aioseo_global_template((string) ($pt['metaDescription'] ?? ''));
805 if ($description !== '') {
806 $pt_settings['description_template'] = $description;
807 }
808
809 // Custom per-post-type robots, in the {custom_robots, robots: [...]}
810 // shape migrate_post_type_settings() consumes.
811 $robots = $pt['advanced']['robotsMeta'] ?? [];
812 if (is_array($robots) && empty($robots['default'])) {
813 $directives = [];
814 foreach (['noindex', 'nofollow', 'noarchive', 'noimageindex', 'nosnippet'] as $flag) {
815 if (!empty($robots[$flag])) {
816 $directives[] = $flag;
817 }
818 }
819 if (!empty($directives)) {
820 $pt_settings['custom_robots'] = true;
821 $pt_settings['robots'] = $directives;
822 }
823 }
824
825 if (!empty($pt_settings)) {
826 $settings[$post_type] = $pt_settings;
827 }
828 }
829
830 return $settings;
831 }
832
833 /**
834 * Convert an AIOSEO template into the Global SEO Pattern_Resolver
835 * vocabulary (%title%/%sitename%/%sep%/%excerpt% — a DIFFERENT dialect
836 * from the Site Identity one).
837 *
838 * @param string $template Raw AIOSEO template
839 * @return string Converted template
840 */
841 private function convert_aioseo_global_template(string $template): string {
842 if ($template === '' || strpos($template, '#') === false) {
843 return trim($template);
844 }
845
846 $map = [
847 '#post_title' => '%title%',
848 '#site_title' => '%sitename%',
849 '#separator_sa' => '%sep%',
850 '#post_excerpt' => '%excerpt%',
851 '#post_date' => '%date%',
852 '#author_name' => '%author%',
853 '#category_title' => '%category%',
854 ];
855 $template = str_replace(array_keys($map), array_values($map), $template);
856
857 // Drop remaining AIOSEO tags the resolver cannot handle.
858 $template = (string) preg_replace(self::AIOSEO_TAG_PATTERN, '', $template);
859 $template = (string) preg_replace('/\s+/', ' ', $template);
860
861 return trim($template);
862 }
863
864 /**
865 * Extract AIOSEO's author-archive behaviour for ThinkRank's Author
866 * Archives feature. The archive noindex flag travels separately in
867 * data.noindex_archives.
868 *
869 * @param array $archives searchAppearance.archives subtree
870 * @return array Author archive settings
871 */
872 private function extract_aioseo_author_archives(array $archives): array {
873 $author = $archives['author'] ?? [];
874 if (!is_array($author) || empty($author)) {
875 return [];
876 }
877
878 return [
879 'enabled' => !empty($author['show']),
880 'title' => $this->convert_aioseo_identity_template((string) ($author['title'] ?? ''), '%author_name%'),
881 'description' => $this->convert_aioseo_identity_template((string) ($author['metaDescription'] ?? ''), '%author_name%'),
882 ];
883 }
884
885 /**
886 * Extract AIOSEO's breadcrumb settings. AIOSEO breadcrumbs render only
887 * where placed (block/shortcode/PHP) and expose no global on/off toggle,
888 * so the presence of breadcrumb config maps to enabled=true — which also
889 * matches ThinkRank's default, keeping the migration non-destructive.
890 *
891 * @param array $breadcrumbs AIOSEO top-level breadcrumbs option
892 * @return array Canonical breadcrumb_settings payload
893 */
894 private function extract_aioseo_breadcrumbs(array $breadcrumbs): array {
895 if (empty($breadcrumbs)) {
896 return [];
897 }
898
899 return [
900 'enabled' => true,
901 'home_label' => (string) ($breadcrumbs['homepageLabel'] ?? ''),
902 'separator' => $this->decode_entities((string) ($breadcrumbs['separator'] ?? '')),
903 'prefix' => (string) ($breadcrumbs['breadcrumbPrefix'] ?? ''),
904 ];
905 }
906
907 /**
908 * Extract AIOSEO Pro's News/Video sitemap post types in the shape
909 * migrate_publisher_sitemaps() consumes.
910 *
911 * @param array $pro aioseo_options_pro subtree
912 * @return array {news_post_types, video_post_types} (absent keys omitted)
913 */
914 private function extract_aioseo_publisher_sitemaps(array $pro): array {
915 $out = [];
916
917 foreach (['news' => 'news_post_types', 'video' => 'video_post_types'] as $kind => $target) {
918 $node = $pro['sitemap'][$kind] ?? [];
919 if (!is_array($node) || empty($node['enable'])) {
920 continue;
921 }
922
923 $types = $this->aioseo_inclusion_node($node['postTypes'] ?? []);
924 $list = $types['all']
925 ? array_values(get_post_types(['public' => true], 'names'))
926 : $types['included'];
927
928 if (!empty($list)) {
929 $out[$target] = $list;
930 }
931 }
932
933 return $out;
934 }
935
936 /**
937 * Extract AIOSEO Pro's Image SEO title/alt formats in the shape
938 * migrate_image_seo() consumes. Presence of a format implies the feature
939 * was in use, so the matching auto-generation flag is enabled (mirroring
940 * how Rank Math's AIOSEO importer treats it).
941 *
942 * @param array $pro aioseo_options_pro subtree
943 * @return array Image SEO payload (empty when unused)
944 */
945 private function extract_aioseo_image_seo(array $pro): array {
946 $format = $pro['image']['format'] ?? [];
947 if (!is_array($format) || empty($format)) {
948 return [];
949 }
950
951 $out = [];
952
953 $title = $this->convert_aioseo_image_template((string) ($format['title'] ?? ''));
954 if ($title !== '') {
955 $out['add_missing_title'] = true;
956 $out['title_format'] = $title;
957 }
958
959 $alt = $this->convert_aioseo_image_template((string) ($format['altTag'] ?? ''));
960 if ($alt !== '') {
961 $out['add_missing_alt'] = true;
962 $out['alt_format'] = $alt;
963 }
964
965 return $out;
966 }
967
968 /**
969 * Convert an AIOSEO image-format template to ThinkRank's Image SEO token
970 * vocabulary (%title%/%filename%/%separator%/%sitename%).
971 *
972 * @param string $template Raw AIOSEO template
973 * @return string Converted template
974 */
975 private function convert_aioseo_image_template(string $template): string {
976 if ($template === '' || strpos($template, '#') === false) {
977 return trim($template);
978 }
979
980 $map = [
981 '#post_title' => '%title%',
982 '#image_title' => '%title%',
983 '#site_title' => '%sitename%',
984 '#separator_sa' => '%separator%',
985 '#image_filename' => '%filename%',
986 '#attachment_caption' => '%caption%',
987 '#alt_tag' => '%alt%',
988 ];
989 $template = str_replace(array_keys($map), array_values($map), $template);
990 $template = (string) preg_replace(self::AIOSEO_TAG_PATTERN, '', $template);
991 $template = (string) preg_replace('/\s+/', ' ', $template);
992
993 return trim($template);
994 }
995
996 /**
997 * Extract AIOSEO Pro's Local Business settings in the canonical local_seo
998 * shape migrate_site_identity() consumes (business NAP + hours).
999 *
1000 * @param array $pro aioseo_options_pro subtree
1001 * @return array Local SEO payload (empty when unused)
1002 */
1003 private function extract_aioseo_local_seo(array $pro): array {
1004 $business = $pro['localBusiness']['locations']['business'] ?? [];
1005 if (!is_array($business) || empty($business)) {
1006 return [];
1007 }
1008
1009 $address = is_array($business['address'] ?? null) ? $business['address'] : [];
1010 $street = trim((string) ($address['streetLine1'] ?? ''));
1011 if (!empty($address['streetLine2'])) {
1012 $street = trim($street . ', ' . $address['streetLine2'], ', ');
1013 }
1014
1015 $out = [
1016 'business_type' => (string) ($business['businessType'] ?? ''),
1017 'business_name' => (string) ($business['name'] ?? ''),
1018 'phone' => (string) ($business['contact']['phone'] ?? ''),
1019 'price_range' => (string) ($business['payment']['priceRange'] ?? ''),
1020 'address' => array_filter([
1021 'street' => $street,
1022 'city' => (string) ($address['city'] ?? ''),
1023 'state' => (string) ($address['state'] ?? ''),
1024 'postal_code' => (string) ($address['zipCode'] ?? ''),
1025 'country' => (string) ($address['country'] ?? ''),
1026 ]),
1027 'opening_hours' => $this->extract_aioseo_opening_hours($pro['localBusiness']['openingHours'] ?? []),
1028 ];
1029
1030 return array_filter($out);
1031 }
1032
1033 /**
1034 * Convert AIOSEO's per-day opening hours into ThinkRank's business_hours
1035 * shape ({day: {open, close, closed}}).
1036 *
1037 * @param array $opening_hours AIOSEO localBusiness.openingHours subtree
1038 * @return array Per-day hours (closed days omitted)
1039 */
1040 private function extract_aioseo_opening_hours(array $opening_hours): array {
1041 $days = is_array($opening_hours['days'] ?? null) ? $opening_hours['days'] : [];
1042 $out = [];
1043
1044 foreach ($days as $day => $hours) {
1045 $day = strtolower((string) $day);
1046 if (!is_array($hours) || !empty($hours['closed'])) {
1047 continue;
1048 }
1049
1050 $open = (string) ($hours['openTime'] ?? '');
1051 $close = (string) ($hours['closeTime'] ?? '');
1052 if ($open === '' || $close === '') {
1053 continue;
1054 }
1055
1056 $out[$day] = [
1057 'open' => $open,
1058 'close' => $close,
1059 'closed' => false,
1060 ];
1061 }
1062
1063 return $out;
1064 }
1065
1066 /**
1067 * Normalize AIOSEO's nested `sitemap` option tree into the flat, canonical
1068 * `sitemap_settings` shape the migrator's migrate_sitemap() reads.
1069 *
1070 * AIOSEO stores the general sitemap config under sitemap.general with an
1071 * `enable` flag, `linksPerIndex`, and post-type/taxonomy inclusion expressed
1072 * as { all: bool, included: [slugs] }. Image inclusion is on unless
1073 * advancedSettings.excludeImages is set. AIOSEO exposes no featured-image or
1074 * ping toggle, so those keys are intentionally omitted (the migrator skips
1075 * absent keys). Returns ['has_data' => false] when there is nothing to migrate.
1076 *
1077 * NOTE: AIOSEO is not installed in this environment; the key paths below come
1078 * from AIOSEO's documented options schema, not a live capture.
1079 *
1080 * @param array $sitemap AIOSEO options['sitemap'] subtree
1081 * @return array Canonical sitemap_settings payload
1082 */
1083 private function normalize_aioseo_sitemap(array $sitemap): array {
1084 $general = $sitemap['general'] ?? [];
1085 if (!is_array($general) || empty($general)) {
1086 return ['has_data' => false];
1087 }
1088
1089 $post_types = $this->aioseo_inclusion_node($general['postTypes'] ?? []);
1090 $taxonomies = $this->aioseo_inclusion_node($general['taxonomies'] ?? []);
1091 $advanced = is_array($general['advancedSettings'] ?? null) ? $general['advancedSettings'] : [];
1092
1093 $normalized = [
1094 'enabled' => !empty($general['enable']),
1095 'include_posts' => $post_types['all'] || in_array('post', $post_types['included'], true),
1096 'include_pages' => $post_types['all'] || in_array('page', $post_types['included'], true),
1097 'include_categories' => $taxonomies['all'] || in_array('category', $taxonomies['included'], true),
1098 'include_tags' => $taxonomies['all'] || in_array('post_tag', $taxonomies['included'], true),
1099 // AIOSEO includes images unless advancedSettings.excludeImages is on.
1100 'include_images' => empty($advanced['excludeImages']),
1101 // AIOSEO's `indexes` flag is the equivalent of ThinkRank's sitemap
1102 // index toggle (an index that points at per-type child sitemaps).
1103 'use_sitemap_index' => !empty($general['indexes']),
1104 'has_data' => true,
1105 ];
1106
1107 if (isset($general['linksPerIndex'])) {
1108 $normalized['links_per_sitemap'] = (int) $general['linksPerIndex'];
1109 }
1110
1111 // Excluded posts/terms: AIOSEO stores each entry as a JSON-encoded
1112 // {value: <id>, label: ...} object. ThinkRank stores comma-separated
1113 // ID lists.
1114 foreach (['excludePosts' => 'exclude_posts', 'excludeTerms' => 'exclude_terms'] as $source => $target) {
1115 $ids = [];
1116 foreach ((array) ($advanced[$source] ?? []) as $entry) {
1117 $entry = is_string($entry) ? json_decode($entry, true) : $entry;
1118 if (is_array($entry) && !empty($entry['value'])) {
1119 $ids[] = (int) $entry['value'];
1120 }
1121 }
1122 if (!empty($ids)) {
1123 $normalized[$target] = implode(', ', $ids);
1124 }
1125 }
1126
1127 return $normalized;
1128 }
1129
1130 /**
1131 * Coerce an AIOSEO inclusion node ({ all, included }) to a predictable shape.
1132 * `included` may be a real array of slugs or a JSON-encoded string depending
1133 * on the AIOSEO version, so both are normalized to a flat slug array.
1134 *
1135 * @param mixed $node AIOSEO postTypes/taxonomies node
1136 * @return array{all: bool, included: array}
1137 */
1138 private function aioseo_inclusion_node($node): array {
1139 if (!is_array($node)) {
1140 return ['all' => false, 'included' => []];
1141 }
1142
1143 $included = $node['included'] ?? [];
1144 if (is_string($included)) {
1145 $decoded = json_decode($included, true);
1146 $included = is_array($decoded) ? $decoded : [];
1147 }
1148 if (!is_array($included)) {
1149 $included = [];
1150 }
1151
1152 return ['all' => !empty($node['all']), 'included' => array_values($included)];
1153 }
1154
1155 /**
1156 * {@inheritDoc}
1157 */
1158 protected function export_redirections_page(int $page): array {
1159 global $wpdb;
1160
1161 $table = $this->get_redirects_table_name();
1162 $table_exists = $wpdb->get_var(
1163 $wpdb->prepare("SHOW TABLES LIKE %s", $table)
1164 );
1165
1166 if (!$table_exists) {
1167 return [];
1168 }
1169
1170 $offset = ($page - 1) * $this->chunk_size;
1171
1172 $rows = $wpdb->get_results(
1173 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
1174 $wpdb->prepare(
1175 "SELECT * FROM {$table} ORDER BY id ASC LIMIT %d OFFSET %d",
1176 $this->chunk_size,
1177 $offset
1178 ),
1179 ARRAY_A
1180 );
1181 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1182
1183 if (empty($rows)) {
1184 return [];
1185 }
1186
1187 $records = [];
1188 foreach ($rows as $row) {
1189 // AIOSEO stores source URLs as JSON array
1190 $source_urls = json_decode($row['source_url'] ?? '[]', true);
1191 $source_url = '';
1192 $is_regex = false;
1193
1194 if (is_array($source_urls) && !empty($source_urls)) {
1195 $first = $source_urls[0] ?? [];
1196 $source_url = $first['url'] ?? '';
1197 $is_regex = ($first['match'] ?? '') === 'regex';
1198 } elseif (is_string($source_urls)) {
1199 $source_url = $source_urls;
1200 }
1201
1202 $records[] = [
1203 'object_type' => 'redirection',
1204 'source_plugin' => $this->plugin_slug,
1205 'data' => [],
1206 'extended' => [
1207 'source_url' => $source_url,
1208 'target_url' => $row['target_url'] ?? '',
1209 'http_code' => (int) ($row['type'] ?? 301),
1210 'is_regex' => $is_regex,
1211 'enabled' => (bool) ($row['enabled'] ?? true),
1212 ],
1213 ];
1214 }
1215
1216 return $records;
1217 }
1218
1219 /**
1220 * {@inheritDoc}
1221 */
1222 protected function convert_template_variables($value, ?int $post_id = null): string {
1223 // Foreign data first: booleans/arrays in the source plugin's options
1224 // must degrade to '' here, not fatal the migration (see abstract).
1225 $value = $this->stringify_template_value($value);
1226
1227 // No template tag → return untouched. Trimming/stripping plain values
1228 // mutates real content (trailing spaces, hashtags, URL anchors).
1229 if (empty($value) || strpos($value, '#') === false) {
1230 return $value;
1231 }
1232
1233 // AIOSEO uses #variable syntax
1234 $replacements = [
1235 '#site_title' => get_bloginfo('name'),
1236 '#tagline' => get_bloginfo('description'),
1237 '#separator_sa' => '-',
1238 '#current_year' => gmdate('Y'),
1239 '#current_date' => gmdate('Y-m-d'),
1240 '#current_month' => gmdate('F'),
1241 '#current_day' => gmdate('j'),
1242 ];
1243
1244 if ($post_id) {
1245 $post = get_post($post_id);
1246 if ($post) {
1247 $replacements['#post_title'] = $post->post_title;
1248 $replacements['#post_excerpt'] = \ThinkRank\Core\Seo_Text::trim_words(
1249 $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words(wp_strip_all_tags($post->post_content), 55),
1250 55
1251 );
1252 $replacements['#post_date'] = get_the_date('', $post);
1253 $replacements['#author_name'] = get_the_author_meta('display_name', (int) $post->post_author);
1254
1255 $post_type_obj = get_post_type_object($post->post_type);
1256 $replacements['#post_type'] = $post_type_obj ? $post_type_obj->labels->singular_name : '';
1257
1258 $categories = get_the_category($post_id);
1259 $replacements['#category_title'] = !empty($categories) ? $categories[0]->name : '';
1260
1261 $tags = get_the_tags($post_id);
1262 $replacements['#tag_title'] = !empty($tags) ? $tags[0]->name : '';
1263 }
1264 }
1265
1266 $value = str_replace(array_keys($replacements), array_values($replacements), $value);
1267
1268 // Strip only KNOWN AIOSEO tags that resolved to nothing above — a
1269 // blanket /#\w+/ would destroy real hashtags and URL anchors in
1270 // descriptions.
1271 $value = preg_replace(self::AIOSEO_TAG_PATTERN, '', $value);
1272
1273 return trim($value);
1274 }
1275
1276 /**
1277 * Every smart tag AIOSEO can emit in a title/description template (see
1278 * AIOSEO's app/Common/Utils/Tags.php). Used to strip tags this exporter
1279 * cannot resolve without touching real `#hashtag` content.
1280 */
1281 private const AIOSEO_TAG_PATTERN = '/#(?:post_title|post_excerpt|post_content|post_date|post_day|post_month|post_year|site_title|tagline|separator_sa|current_year|current_date|current_month|current_day|author_name|author_first_name|author_last_name|author_bio|post_type|category_title|taxonomy_title|tag_title|taxonomy_description|category|categories|archive_title|archive_date|search_term|page_number|attachment_caption|attachment_description|alt_tag|permalink|custom_field-[a-zA-Z0-9_-]+|tax_name|tax_parent_name|breadcrumb_[a-z0-9_]+)\b/';
1282
1283 /**
1284 * Safely get a string column value
1285 *
1286 * @param array $row Database row
1287 * @param string $column Column name
1288 * @return string Column value or empty string
1289 */
1290 private function safe_column(array $row, string $column): string {
1291 if (!$this->has_column($column)) {
1292 return '';
1293 }
1294 return (string) ($row[$column] ?? '');
1295 }
1296
1297 /**
1298 * Safely get an integer column value
1299 *
1300 * @param array $row Database row
1301 * @param string $column Column name
1302 * @return int Column value or 0
1303 */
1304 private function safe_column_int(array $row, string $column): int {
1305 if (!$this->has_column($column)) {
1306 return 0;
1307 }
1308 return (int) ($row[$column] ?? 0);
1309 }
1310 }
1311