PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 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 All 58 releases
← All changes | includes/admin/importers/class-snapshot-migrator.php +539 -66 2.3.0 → 2.12.0 View file →
@@ -23,8 +23,9 @@
23 23 namespace ThinkRank\Admin\Importers;
24 24
25 25 use ThinkRank\SEO\Focus_Keywords;
26 26 use ThinkRank\SEO\Metadata_Pending;
27 +use ThinkRank\SEO\Object_Redirect;
27 28 use ThinkRank\SEO\Pattern_Resolver;
28 29
29 30 if (!defined('ABSPATH')) {
30 31 exit;
@@ -53,8 +54,15 @@
53 54 'twitter_description' => '_thinkrank_twitter_description',
54 55 'twitter_image' => '_thinkrank_twitter_image',
55 56 'primary_category' => '_thinkrank_primary_category',
56 57 'schema_type' => '_thinkrank_selected_schema_type',
58 + // WooCommerce product identifier (GTIN/MPN/ISBN). Free does not read
59 + // it; ThinkRank Pro's Product_Fields does, under this exact key, so
60 + // importing it here means the identifier is already in place when Pro
61 + // is activated. Without it Google reports "missing identifier" on every
62 + // product after a switch, which is a rich-result warning the user did
63 + // not have before they migrated (#715).
64 + 'product_identifier' => '_thinkrank_product_gtin',
57 65 ];
58 66
59 67 /**
60 68 * Canonical robots meta fields. Composed into JSON-encoded
@@ -71,9 +79,17 @@
71 79
72 80 /**
73 81 * Data types that are migratable (have post/term/user meta mappings)
74 82 */
75 - private const MIGRATABLE_TYPES = ['postmeta', 'termmeta', 'usermeta', 'redirections', '404_logs', 'settings'];
83 + private const MIGRATABLE_TYPES = [
84 + 'postmeta',
85 + 'termmeta',
86 + 'usermeta',
87 + 'redirections',
88 + '404_logs',
89 + 'settings',
90 + Block_Converter::TYPE,
91 + ];
76 92
77 93 /**
78 94 * Settings-record `extended` keys that either migrate today or are safe to
79 95 * discard on cleanup (raw_options is pure capture-all insurance; a fresh
@@ -94,8 +110,9 @@
94 110 'role_capabilities',
95 111 'image_seo',
96 112 'sitemap_settings',
97 113 'analytics_connected',
114 + 'focus_pages',
98 115 // Capture-all raw buckets (whole source option sets stored verbatim).
99 116 // They live in the SNAPSHOT — cleanup never touches the snapshot — and
100 117 // a re-export recreates them, so they never block cleanup.
101 118 'raw_options',
@@ -157,16 +174,42 @@
157 174 if ($type === '404_logs') {
158 175 return $this->migrate_404_logs($plugin, $page);
159 176 }
160 177
178 + if ($type === Block_Converter::TYPE) {
179 + return $this->migrate_content_blocks($plugin, $page);
180 + }
181 +
161 182 $chunk = Snapshot_Store::read_chunk($plugin, $type, $page);
162 183 if ($chunk === null || empty($chunk)) {
184 + // An empty chunk is not the end of the type. An exporter that pages
185 + // one shared table and then splits the rows by kind writes nothing
186 + // for a page whose rows all belonged to another kind — Squirrly
187 + // reads the whole `qss` table that way, so a site whose terms and
188 + // authors sit past the first page of posts has an empty chunk 1 and
189 + // its real records in chunk 2. Ending the loop here dropped them
190 + // silently, under a `complete` status, and cleanup then removed the
191 + // source copy. Keep asking while the manifest says there are more
192 + // chunks, exactly as the non-empty path below does.
193 + $total_chunks = (int) ($manifest['types'][$type]['total_chunks'] ?? 0);
194 + $has_more = $page < $total_chunks;
195 +
196 + // The last chunk of a sparse export can legitimately be the empty
197 + // one — Squirrly's tail page holds only term rows — and it still
198 + // ends the migration, so release the editors that mark_bulk() set
199 + // polling. Without this they poll until the marker's own expiry.
200 + if ($type === 'postmeta' && !$has_more) {
201 + Metadata_Pending::clear_bulk();
202 + }
203 +
163 204 return [
164 - 'status' => 'complete',
165 - 'message' => 'No data in chunk',
166 - 'has_more' => false,
167 - 'processed' => 0,
168 - 'skipped' => 0,
205 + 'status' => $has_more ? 'processing' : 'complete',
206 + 'message' => sprintf('No data in chunk %d', $page),
207 + 'has_more' => $has_more,
208 + 'page' => $page,
209 + 'total_chunks' => $total_chunks,
210 + 'processed' => 0,
211 + 'skipped' => 0,
169 212 ];
170 213 }
171 214
172 215 // Tell an open editor that SEO meta is being written right now, so its
@@ -196,8 +239,18 @@
196 239 $skipped++;
197 240 continue;
198 241 }
199 242
243 + // The object has to still exist. A source that keys its SEO by URL
244 + // rather than by a foreign key keeps rows for content that was
245 + // deleted years ago — Squirrly's `qss` table is keyed on a URL hash
246 + // — and writing their meta creates orphan rows no screen can reach
247 + // and no uninstall sweeps, while reporting them as migrated.
248 + if (!$this->object_exists($object_type, $object_id)) {
249 + $skipped++;
250 + continue;
251 + }
252 +
200 253 // Track migrated posts so their SEO score can be computed once the
201 254 // chunk's meta has landed (terms are not scored).
202 255 if ($object_type === 'post') {
203 256 $post_ids[$object_id] = true;
@@ -208,8 +261,25 @@
208 261 // Collect focus keywords (primary + secondary) to seed the Pro
209 262 // Rank Tracker watch-list once the chunk is processed.
210 263 $this->collect_keywords($record, $data, $keywords);
211 264
265 + // Term social fields travel in `extended`, not `data`: only the
266 + // AIOSEO exporter puts them in the canonical bucket, the other four
267 + // put the identical values one level out. The loop below walks
268 + // `data`, so every term OG image and Twitter title/description was
269 + // exported and then dropped. Fold them in for terms, without
270 + // letting them win over a value the record already carries.
271 + if ($object_type === 'term') {
272 + $extended = is_array($record['extended'] ?? null) ? $record['extended'] : [];
273 + foreach (['og_title', 'og_description', 'og_image', 'twitter_title', 'twitter_description', 'twitter_image'] as $social_key) {
274 + if (!isset($data[$social_key]) || $data[$social_key] === '') {
275 + if (isset($extended[$social_key]) && $extended[$social_key] !== '') {
276 + $data[$social_key] = $extended[$social_key];
277 + }
278 + }
279 + }
280 + }
281 +
212 282 foreach ($data as $canonical_key => $value) {
213 283 if (!isset(self::META_MAP[$canonical_key])) {
214 284 continue;
215 285 }
@@ -215,10 +285,13 @@
215 285 }
216 286
217 287 // Focus keywords are migrated as an array via the dedicated
218 288 // migrate_focus_keywords() below (which also keeps the legacy
219 - // single-value meta in sync), so skip the scalar write here.
220 - if ($canonical_key === 'focus_keyword') {
289 + // single-value meta in sync), so skip the scalar write here —
290 + // but that writer only runs for posts, so skipping it for a
291 + // term meant nobody wrote the term's focus keyword at all,
292 + // even though get-term-seo reads `_thinkrank_focus_keyword`.
293 + if ($canonical_key === 'focus_keyword' && $object_type === 'post') {
221 294 continue;
222 295 }
223 296
224 297 $thinkrank_key = self::META_MAP[$canonical_key];
@@ -232,8 +305,12 @@
232 305 if ($value === 0 && in_array($canonical_key, ['primary_category'], true)) {
233 306 continue;
234 307 }
235 308
309 + // The meta writers unslash their value, so every write below
310 + // slashes first. Unslashed, a title or description with a
311 + // backslash in it lost it on the way in, and a JSON value lost
312 + // the backslash of every `\"` and `\uXXXX` escape.
236 313 if ($object_type === 'post') {
237 314 // Never overwrite existing ThinkRank data
238 315 $existing = get_post_meta($object_id, $thinkrank_key, true);
239 316 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -239,9 +316,9 @@
239 316 if ($existing !== '' && $existing !== false && $existing !== null) {
240 317 continue;
241 318 }
242 319
243 - update_post_meta($object_id, $thinkrank_key, $value);
320 + update_post_meta($object_id, $thinkrank_key, wp_slash($value));
244 321 $record_had_writes = true;
245 322 } elseif ($object_type === 'term') {
246 323 $existing = get_term_meta($object_id, $thinkrank_key, true);
247 324 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -247,9 +324,9 @@
247 324 if ($existing !== '' && $existing !== false && $existing !== null) {
248 325 continue;
249 326 }
250 327
251 - update_term_meta($object_id, $thinkrank_key, $value);
328 + update_term_meta($object_id, $thinkrank_key, wp_slash($value));
252 329 $record_had_writes = true;
253 330 } elseif ($object_type === 'user') {
254 331 $existing = get_user_meta($object_id, $thinkrank_key, true);
255 332 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -255,9 +332,9 @@
255 332 if ($existing !== '' && $existing !== false && $existing !== null) {
256 333 continue;
257 334 }
258 335
259 - update_user_meta($object_id, $thinkrank_key, $value);
336 + update_user_meta($object_id, $thinkrank_key, wp_slash($value));
260 337 $record_had_writes = true;
261 338 }
262 339 }
263 340
@@ -271,8 +348,17 @@
271 348 if ($object_type === 'post' && $this->migrate_robots_payload($object_id, $data)) {
272 349 $record_had_writes = true;
273 350 }
274 351
352 + // The same directives for a term. Every exporter emits term
353 + // noindex/nofollow and ThinkRank stores them, but nothing wrote
354 + // them — so a category the owner had deliberately kept out of the
355 + // index came back indexable after the switch, which is the worst
356 + // way for an import to be wrong.
357 + if ($object_type === 'term' && $this->migrate_term_robots_payload($object_id, $data)) {
358 + $record_had_writes = true;
359 + }
360 +
275 361 // Pillar / cornerstone content flag (post meta only).
276 362 if ($object_type === 'post' && $this->migrate_pillar_content($object_id, $data)) {
277 363 $record_had_writes = true;
278 364 }
@@ -288,13 +374,34 @@
288 374 if ($object_type === 'post' && $this->migrate_video_schema($object_id, $data, $record)) {
289 375 $record_had_writes = true;
290 376 }
291 377
378 + // Per-object redirect. SEOPress is the one source that stores a
379 + // redirect as object meta rather than in a rules table, so its
380 + // per-post redirects were exported into extended.redirect_* and
381 + // then dropped for want of anywhere to put them. They have a home
382 + // now: Object_Redirect writes through to Pro's rules table, and
383 + // returns a WP_Error (which we skip) when Pro is inactive, leaving
384 + // the value in the snapshot for a later run.
385 + if (in_array($object_type, ['post', 'term'], true)
386 + && $this->migrate_object_redirect($object_type, $object_id, $record)) {
387 + $record_had_writes = true;
388 + }
389 +
292 390 // Per-post "exclude from sitemap" flags. ThinkRank models sitemap
293 391 // exclusion as one comma-separated ID list on the sitemap settings
294 392 // rather than per-post meta, so collect the IDs and apply them once
295 393 // after the chunk (a settings write per post would be wasteful).
296 - if ($object_type === 'post' && !empty($record['extended']['exclude_sitemap'])) {
394 + // Two spellings reach here: Rank Math's exporter emits
395 + // `exclude_sitemap`, Squirrly's `exclude_from_sitemap`. Only the
396 + // first was read, so every Squirrly `nositemap` flag was dropped
397 + // and posts the owner had hidden reappeared in the sitemap. Accept
398 + // both rather than renaming one, because snapshots already exported
399 + // carry whichever spelling their exporter used at the time.
400 + $excluded_from_sitemap = !empty($record['extended']['exclude_sitemap'])
401 + || !empty($record['extended']['exclude_from_sitemap']);
402 +
403 + if ($object_type === 'post' && $excluded_from_sitemap) {
297 404 $sitemap_excluded[] = $object_id;
298 405 }
299 406
300 407 if ($record_had_writes) {
@@ -545,18 +652,22 @@
545 652 // strings; but the restore's whole policy is to write the file's value
546 653 // verbatim, and a file holding one as an array would otherwise raise a
547 654 // TypeError that takes down the rest of the chunk with it. One bad key
548 655 // is worth skipping, not the records behind it.
656 + //
657 + // wp_slash() because the meta writers unslash: a restored JSON value
658 + // (schema form data, robots) would otherwise lose the backslash of
659 + // every escaped quote and come back as invalid JSON.
549 660 try {
550 661 switch ($object_type) {
551 662 case 'post':
552 - update_post_meta($object_id, $key, $value);
663 + update_post_meta($object_id, $key, wp_slash($value));
553 664 return true;
554 665 case 'term':
555 - update_term_meta($object_id, $key, $value);
666 + update_term_meta($object_id, $key, wp_slash($value));
556 667 return true;
557 668 case 'user':
558 - update_user_meta($object_id, $key, $value);
669 + update_user_meta($object_id, $key, wp_slash($value));
559 670 return true;
560 671 }
561 672 } catch (\Throwable $e) {
562 673 if (defined('WP_DEBUG') && WP_DEBUG) {
@@ -779,12 +890,32 @@
779 890 if (!in_array($option_name, Thinkrank_Exporter::AGGREGATE_OPTIONS, true)) {
780 891 continue;
781 892 }
782 893
783 - if (!$overwrite && get_option($option_name, '__tr_not_set__') !== '__tr_not_set__') {
894 + $existing = get_option($option_name, '__tr_not_set__');
895 +
896 + if (!$overwrite && $existing !== '__tr_not_set__') {
784 897 continue;
785 898 }
786 899
900 + // An aggregate option is written whole, so a key the exporter
901 + // stripped would be DELETED here rather than just left alone — an
902 + // overwrite-restore would wipe this site's Google OAuth tokens and
903 + // platform verification codes on the way to restoring everything
904 + // around them. Carry the local values forward for exactly the keys
905 + // export redacts, matching that redaction key for key and depth for
906 + // depth.
907 + if (is_array($value) && is_array($existing)) {
908 + $value = $this->carry_forward_redacted(
909 + $value,
910 + $existing,
911 + array_merge(
912 + Thinkrank_Exporter::secret_setting_keys(),
913 + Thinkrank_Exporter::SECRET_OPTION_KEYS[$option_name] ?? []
914 + )
915 + );
916 + }
917 +
787 918 update_option($option_name, $value);
788 919 $written++;
789 920 }
790 921
@@ -791,8 +922,41 @@
791 922 return $written;
792 923 }
793 924
794 925 /**
926 + * Put back the secrets the export stripped, from what this site already has.
927 + *
928 + * The mirror image of Thinkrank_Exporter::strip_secret_keys(): that walks
929 + * the payload to any depth removing keys named as secrets, so this walks it
930 + * to the same depth restoring them. A key the export DID carry is left
931 + * alone — the carry-forward only fills a hole, so a deliberate change still
932 + * lands.
933 + *
934 + * @since 2.3.1
935 + *
936 + * @param array $incoming The option value from the snapshot.
937 + * @param array $existing The option value this site already holds.
938 + * @param string[] $secret_keys Key names redaction removes.
939 + * @return array
940 + */
941 + private function carry_forward_redacted(array $incoming, array $existing, array $secret_keys): array {
942 + foreach ($existing as $key => $existing_value) {
943 + if (is_string($key) && in_array($key, $secret_keys, true)) {
944 + if (!array_key_exists($key, $incoming)) {
945 + $incoming[$key] = $existing_value;
946 + }
947 + continue;
948 + }
949 +
950 + if (is_array($existing_value) && isset($incoming[$key]) && is_array($incoming[$key])) {
951 + $incoming[$key] = $this->carry_forward_redacted($incoming[$key], $existing_value, $secret_keys);
952 + }
953 + }
954 +
955 + return $incoming;
956 + }
957 +
958 + /**
795 959 * Dry-run a snapshot chunk: classify what a migrate WOULD do without
796 960 * writing anything. Mirrors migrate_chunk()'s per-field decision (skip
797 961 * empty values, never overwrite existing ThinkRank data) so the counts
798 962 * match what a real migrate would produce.
@@ -1185,8 +1349,160 @@
1185 1349 return $seeded;
1186 1350 }
1187 1351
1188 1352 /**
1353 + * Migrate a source plugin's per-object redirect into ThinkRank.
1354 + *
1355 + * The destination is Pro's redirections table, not object meta, so this
1356 + * goes through Object_Redirect rather than writing a key: that keeps the
1357 + * imported rule subject to the same guards as one typed into the edit
1358 + * screen (no self-referential rule, no query-string source) and puts it in
1359 + * the Redirections manager where the user can see and edit it.
1360 + *
1361 + * An existing redirect on the object is left alone — the import rule is
1362 + * SKIP on conflict, and a redirect the user already set here outranks one
1363 + * carried over from the plugin being replaced.
1364 + *
1365 + * @param string $object_type 'post' or 'term'.
1366 + * @param int $object_id Object ID.
1367 + * @param array $record Full snapshot record.
1368 + * @return bool Whether a redirect was written.
1369 + */
1370 + private function migrate_object_redirect(string $object_type, int $object_id, array $record): bool {
1371 + $extended = $record['extended'] ?? [];
1372 +
1373 + if (!is_array($extended) || empty($extended['redirect_url'])) {
1374 + return false;
1375 + }
1376 +
1377 + // A source that models the redirect as a toggle plus a URL can carry a
1378 + // URL the site is not actually serving. Honour the toggle when present.
1379 + if (array_key_exists('redirect_enabled', $extended) && empty($extended['redirect_enabled'])) {
1380 + return false;
1381 + }
1382 +
1383 + if ('' !== Object_Redirect::get($object_type, $object_id)['url']) {
1384 + return false;
1385 + }
1386 +
1387 + $result = Object_Redirect::save(
1388 + $object_type,
1389 + $object_id,
1390 + (string) $extended['redirect_url'],
1391 + $extended['redirect_type'] ?? Object_Redirect::DEFAULT_TYPE
1392 + );
1393 +
1394 + return !is_wp_error($result);
1395 + }
1396 +
1397 + /**
1398 + * The robots directives for a term.
1399 + *
1400 + * Deliberately narrower than migrate_robots_payload(): update-term-seo
1401 + * writes `_thinkrank_robots_meta` and `_thinkrank_robots_meta_enabled` and
1402 + * nothing else for a term, so the advanced directives a post supports have
1403 + * nowhere to go here and are left in the snapshot rather than written to a
1404 + * key no reader looks at.
1405 + *
1406 + * Same two rules as the post path — never overwrite an existing payload,
1407 + * and never turn the override on for an all-false set, which is just
1408 + * ThinkRank's default index/follow spelled out.
1409 + *
1410 + * @param int $term_id Target term ID.
1411 + * @param array $data Canonical record data.
1412 + * @return bool True when a payload was written.
1413 + */
1414 + private function migrate_term_robots_payload(int $term_id, array $data): bool {
1415 + $existing = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1416 + if (is_string($existing) && $existing !== '') {
1417 + return false;
1418 + }
1419 +
1420 + $robots = [];
1421 + $has_active_directive = false;
1422 +
1423 + foreach (self::ROBOTS_FIELDS as $field) {
1424 + if (!array_key_exists($field, $data)) {
1425 + continue;
1426 + }
1427 +
1428 + $value = $data[$field];
1429 + if ($value === '' || $value === null) {
1430 + continue;
1431 + }
1432 +
1433 + $robots[$field] = (bool) (int) $value;
1434 + if ($robots[$field]) {
1435 + $has_active_directive = true;
1436 + }
1437 + }
1438 +
1439 + if (!$has_active_directive) {
1440 + return false;
1441 + }
1442 +
1443 + $robots['index'] = empty($robots['noindex']);
1444 +
1445 + update_term_meta($term_id, '_thinkrank_robots_meta', wp_slash(wp_json_encode($robots)));
1446 + update_term_meta($term_id, '_thinkrank_robots_meta_enabled', 1);
1447 +
1448 + return true;
1449 + }
1450 +
1451 + /**
1452 + * Carry the source's watched pages into ThinkRank Pro's Focus Pages.
1453 + *
1454 + * Squirrly keeps this list on its own servers, so the exporter reads it
1455 + * live while the source plugin is still installed and connected — after
1456 + * the switch there is nowhere left to read it from. See
1457 + * Squirrly_Exporter::fetch_focus_pages().
1458 + *
1459 + * Focus Pages is a Pro feature and a deliberately small, hand-picked list
1460 + * (Settings::MAX_PAGES). Two rules follow from that: never touch a
1461 + * selection the user has already made here, and never import more than
1462 + * the cap. Without Pro the ids stay in the snapshot for a later run, the
1463 + * same way per-object redirects wait for Pro's rules table.
1464 + *
1465 + * @param array $extended Extended settings payload.
1466 + * @return bool True when at least one page was added.
1467 + */
1468 + private function migrate_focus_pages(array $extended): bool {
1469 + $ids = $extended['focus_pages'] ?? [];
1470 + if (!is_array($ids) || $ids === []) {
1471 + return false;
1472 + }
1473 +
1474 + if (!class_exists('ThinkRank\\Pro\\Focus_Pages\\Settings')) {
1475 + return false;
1476 + }
1477 +
1478 + $settings = new \ThinkRank\Pro\Focus_Pages\Settings();
1479 +
1480 + // A choice already made here outranks one carried over, exactly as
1481 + // every other field in this class treats an existing value.
1482 + if ($settings->get() !== []) {
1483 + return false;
1484 + }
1485 +
1486 + $added = false;
1487 + foreach ($ids as $id) {
1488 + $post_id = (int) $id;
1489 + if ($post_id <= 0 || get_post($post_id) === null) {
1490 + continue;
1491 + }
1492 +
1493 + if (method_exists($settings, 'is_full') && $settings->is_full()) {
1494 + break;
1495 + }
1496 +
1497 + $settings->add($post_id);
1498 + $added = true;
1499 + }
1500 +
1501 + return $added;
1502 + }
1503 +
1504 + /**
1189 1505 * Migrate the pillar / cornerstone content flag to ThinkRank post meta.
1190 1506 *
1191 1507 * ThinkRank stores an enabled flag as the string '1'; the reader
1192 1508 * (Pillar_Content endpoint) matches meta_value = '1'. Never overwrites an
@@ -1214,19 +1530,17 @@
1214 1530 /**
1215 1531 * Migrate the post's focus keywords.
1216 1532 *
1217 1533 * Reads the full list from the snapshot's `focus_keywords` (falling back to
1218 - * the single `focus_keyword`) and persists via Focus_Keywords::save_with_
1219 - * overflow(): the first MAX keywords are the base, the rest are stored as
1220 - * gated overflow (free) that Pro unlocks automatically. Never overwrites
1221 - * existing ThinkRank focus keywords.
1534 + * the single `focus_keyword`) and persists via Focus_Keywords::save(). Never
1535 + * overwrites existing ThinkRank focus keywords.
1222 1536 *
1223 - * Posts whose source exceeded the free limit are recorded in `$truncations`
1224 - * so the import summary can surface them as a Pro upsell.
1537 + * Posts whose source had more keywords than were stored are recorded in
1538 + * `$truncations` so the import summary can report them.
1225 1539 *
1226 1540 * @param int $post_id Target post ID.
1227 1541 * @param array $data Canonical record data.
1228 - * @param array|null $truncations Accumulator: appended with overflow info.
1542 + * @param array|null $truncations Accumulator: appended with what was dropped.
1229 1543 * @return bool True when keywords were written.
1230 1544 */
1231 1545 private function migrate_focus_keywords(int $post_id, array $data, ?array &$truncations = null): bool {
1232 1546 $keywords = [];
@@ -1235,9 +1549,10 @@
1235 1549 } elseif (!empty($data['focus_keyword'])) {
1236 1550 $keywords = [$data['focus_keyword']];
1237 1551 }
1238 1552
1239 - if (empty(Focus_Keywords::normalize($keywords, 0))) {
1553 + $all = Focus_Keywords::normalize($keywords, 0);
1554 + if (empty($all)) {
1240 1555 return false;
1241 1556 }
1242 1557
1243 1558 // Never overwrite existing ThinkRank focus keywords.
@@ -1244,19 +1559,19 @@
1244 1559 if (!empty(Focus_Keywords::get($post_id))) {
1245 1560 return false;
1246 1561 }
1247 1562
1248 - $result = Focus_Keywords::save_with_overflow($post_id, $keywords);
1563 + $saved = Focus_Keywords::save($post_id, $all);
1249 1564
1250 - if (!empty($result['overflow']) && is_array($truncations)) {
1565 + if (count($saved) < count($all) && is_array($truncations)) {
1251 1566 $truncations[] = [
1252 1567 'post_id' => $post_id,
1253 - 'kept' => count($result['kept']),
1254 - 'gated' => $result['overflow'],
1568 + 'kept' => count($saved),
1569 + 'dropped' => array_slice($all, count($saved)),
1255 1570 ];
1256 1571 }
1257 1572
1258 - return !empty($result['kept']);
1573 + return !empty($saved);
1259 1574 }
1260 1575
1261 1576 /**
1262 1577 * Seed the metabox Review schema form data for an imported review post.
@@ -1287,9 +1602,9 @@
1287 1602 if (is_string($existing) && $existing !== '') {
1288 1603 return false;
1289 1604 }
1290 1605
1291 - update_post_meta($post_id, '_thinkrank_schema_form_data', wp_json_encode($review));
1606 + update_post_meta($post_id, '_thinkrank_schema_form_data', wp_slash(wp_json_encode($review)));
1292 1607
1293 1608 return true;
1294 1609 }
1295 1610
@@ -1321,9 +1636,9 @@
1321 1636 if (is_string($existing) && $existing !== '') {
1322 1637 return false;
1323 1638 }
1324 1639
1325 - update_post_meta($post_id, '_thinkrank_schema_form_data', wp_json_encode($video));
1640 + update_post_meta($post_id, '_thinkrank_schema_form_data', wp_slash(wp_json_encode($video)));
1326 1641
1327 1642 return true;
1328 1643 }
1329 1644
@@ -1403,13 +1718,13 @@
1403 1718 }
1404 1719
1405 1720 $wrote = false;
1406 1721 if (!empty($robots)) {
1407 - update_post_meta($post_id, '_thinkrank_robots_meta', wp_json_encode($robots));
1722 + update_post_meta($post_id, '_thinkrank_robots_meta', wp_slash(wp_json_encode($robots)));
1408 1723 $wrote = true;
1409 1724 }
1410 1725 if (!empty($advanced)) {
1411 - update_post_meta($post_id, '_thinkrank_advanced_robots_meta', wp_json_encode($advanced));
1726 + update_post_meta($post_id, '_thinkrank_advanced_robots_meta', wp_slash(wp_json_encode($advanced)));
1412 1727 $wrote = true;
1413 1728 }
1414 1729 if ($wrote) {
1415 1730 update_post_meta($post_id, '_thinkrank_robots_meta_enabled', 1);
@@ -1561,8 +1876,13 @@
1561 1876 if ($this->migrate_image_seo($extended)) {
1562 1877 $processed++;
1563 1878 }
1564 1879
1880 + // The pages the source had under active watch.
1881 + if ($this->migrate_focus_pages($extended)) {
1882 + $processed++;
1883 + }
1884 +
1565 1885 // Sitemap inclusion settings.
1566 1886 if ($this->migrate_sitemap($extended)) {
1567 1887 $processed++;
1568 1888 }
@@ -1630,18 +1950,33 @@
1630 1950 $manager = new \ThinkRank\SEO\Site_Identity_Manager();
1631 1951 $current = $manager->get_settings('site');
1632 1952
1633 1953 // ThinkRank default seeds — only overwrite a value the user has not changed.
1634 - $seeds = [
1635 - 'homepage_title' => '%site_title% | %site_description%',
1636 - 'site_name' => get_bloginfo('name'),
1637 - 'logo_url' => '',
1638 - 'breadcrumb_home_text' => 'Home',
1639 - 'breadcrumb_separator' => '>',
1640 - 'business_type' => '',
1641 - 'business_name' => '',
1642 - 'business_phone' => '',
1643 - ];
1954 + //
1955 + // The title formats come from Site_Identity_Manager rather than being
1956 + // restated here. They were restated once, drifted (the homepage seed
1957 + // still used a literal '|' after the shipped default moved to %sep%),
1958 + // and the six per-context formats below were never listed at all — so
1959 + // every shipped default read as "the user chose this" and no imported
1960 + // title format was ever written.
1961 + $seeds = array_merge(
1962 + \ThinkRank\SEO\Site_Identity_Manager::TITLE_FORMAT_DEFAULTS,
1963 + [
1964 + 'site_name' => get_bloginfo('name'),
1965 + 'logo_url' => '',
1966 + 'breadcrumb_home_text' => 'Home',
1967 + // Two shipped values, both untouched. get_default_settings()
1968 + // says '>' and the admin screen seeds '›' (as does the
1969 + // breadcrumb renderer's own fallback), so which one a site
1970 + // holds depends only on whether that screen has ever been
1971 + // saved. Recognising one and not the other would skip the
1972 + // imported separator on half of all installs.
1973 + 'breadcrumb_separator' => ['>', '›'],
1974 + 'business_type' => '',
1975 + 'business_name' => '',
1976 + 'business_phone' => '',
1977 + ]
1978 + );
1644 1979
1645 1980 $updates = [];
1646 1981 $set = static function (string $key, $value) use (&$updates, $current, $seeds): void {
1647 1982 if ($value === '' || $value === null) {
@@ -1647,9 +1982,14 @@
1647 1982 if ($value === '' || $value === null) {
1648 1983 return;
1649 1984 }
1650 1985 $cur = $current[$key] ?? null;
1651 - $is_default = !array_key_exists($key, $current) || $cur === '' || $cur === ($seeds[$key] ?? null);
1986 + // A seed may list several values when more than one shipped default
1987 + // is in circulation for the same field.
1988 + $shipped = array_key_exists($key, $seeds) ? (array) $seeds[$key] : [];
1989 + $is_default = !array_key_exists($key, $current)
1990 + || $cur === ''
1991 + || in_array($cur, $shipped, true);
1652 1992 if ($is_default) {
1653 1993 $updates[$key] = $value;
1654 1994 }
1655 1995 };
@@ -1862,10 +2202,14 @@
1862 2202 $updates = [];
1863 2203 if ($app_id !== '' && empty($current['facebook_app_id'])) {
1864 2204 $updates['facebook_app_id'] = $app_id;
1865 2205 }
1866 - if ($og_image !== '' && empty($current['default_image'])) {
1867 - $updates['default_image'] = $og_image;
2206 + // `default_og_image` is the key Social_Meta_Manager declares for the
2207 + // site context. `default_image` is only a legacy alias the front-end
2208 + // readers still honour — saving under it is discarded, because a key
2209 + // outside the allow-list never reaches the database.
2210 + if ($og_image !== '' && empty($current['default_og_image'])) {
2211 + $updates['default_og_image'] = $og_image;
1868 2212 }
1869 2213
1870 2214 if (empty($updates)) {
1871 2215 return false;
@@ -2116,18 +2460,40 @@
2116 2460 $skipped++;
2117 2461 }
2118 2462 }
2119 2463
2464 + // Report the same has_more every other type does. Hardcoding false
2465 + // here was invisible in the admin, which iterates 1..total_chunks, and
2466 + // silently truncated the MCP/ability import, which loops on has_more
2467 + // alone: a site with more than one chunk of rules got its first
2468 + // hundred and a clean `complete`.
2469 + $has_more = $page < (int) ($this->chunk_total($plugin, 'redirections'));
2470 +
2120 2471 return [
2121 - 'status' => 'complete',
2122 - 'message' => sprintf('Migrated %d redirections, skipped %d (page %d)', $processed, $skipped, $page),
2123 - 'has_more' => false,
2124 - 'processed' => $processed,
2125 - 'skipped' => $skipped,
2472 + 'status' => $has_more ? 'processing' : 'complete',
2473 + 'message' => sprintf('Migrated %d redirections, skipped %d (page %d)', $processed, $skipped, $page),
2474 + 'has_more' => $has_more,
2475 + 'page' => $page,
2476 + 'total_chunks' => $this->chunk_total($plugin, 'redirections'),
2477 + 'processed' => $processed,
2478 + 'skipped' => $skipped,
2126 2479 ];
2127 2480 }
2128 2481
2129 2482 /**
2483 + * How many chunks the manifest declares for a type, or 0 when unknown.
2484 + *
2485 + * @param string $plugin Source slug.
2486 + * @param string $type Snapshot type.
2487 + * @return int
2488 + */
2489 + private function chunk_total(string $plugin, string $type): int {
2490 + $manifest = Snapshot_Store::get_manifest($plugin);
2491 +
2492 + return (int) ($manifest['types'][$type]['total_chunks'] ?? 0);
2493 + }
2494 +
2495 + /**
2130 2496 * Migrate a chunk of logged 404 hits into ThinkRank Pro's 404 Monitor.
2131 2497 * Pro-gated exactly like migrate_redirections().
2132 2498 *
2133 2499 * @param string $plugin Plugin slug
@@ -2177,18 +2543,130 @@
2177 2543 $skipped++;
2178 2544 }
2179 2545 }
2180 2546
2547 + $has_more = $page < $this->chunk_total($plugin, '404_logs');
2548 +
2181 2549 return [
2182 - 'status' => 'complete',
2183 - 'message' => sprintf('Migrated %d 404 logs, skipped %d (page %d)', $processed, $skipped, $page),
2184 - 'has_more' => false,
2185 - 'processed' => $processed,
2186 - 'skipped' => $skipped,
2550 + 'status' => $has_more ? 'processing' : 'complete',
2551 + 'message' => sprintf('Migrated %d 404 logs, skipped %d (page %d)', $processed, $skipped, $page),
2552 + 'has_more' => $has_more,
2553 + 'page' => $page,
2554 + 'total_chunks' => $this->chunk_total($plugin, '404_logs'),
2555 + 'processed' => $processed,
2556 + 'skipped' => $skipped,
2187 2557 ];
2188 2558 }
2189 2559
2190 2560 /**
2561 + * Rewrite a chunk of posts' Rank Math FAQ / HowTo blocks into ThinkRank's
2562 + * own blocks.
2563 + *
2564 + * Unlike every other type here this does not write meta — it edits
2565 + * `post_content` in place, because that is where the blocks live. The
2566 + * snapshot chunk carries post ids only, so the conversion always runs
2567 + * against the post as it stands now rather than a stale copy.
2568 + *
2569 + * The conflict strategy is deliberately ignored. A Rank Math block and a
2570 + * ThinkRank block are not two values competing for one field: the Rank Math
2571 + * one is broken markup that needs replacing, and any ThinkRank block
2572 + * already in the post is simply left alone by the converter.
2573 + *
2574 + * @param string $plugin Plugin slug
2575 + * @param int $page Chunk number
2576 + * @return array Migration result
2577 + */
2578 + private function migrate_content_blocks(string $plugin, int $page): array {
2579 + $chunk = Snapshot_Store::read_chunk($plugin, Block_Converter::TYPE, $page);
2580 + $total_chunks = $this->chunk_total($plugin, Block_Converter::TYPE);
2581 +
2582 + if ($chunk === null || empty($chunk)) {
2583 + $has_more = $page < $total_chunks;
2584 +
2585 + return [
2586 + 'status' => $has_more ? 'processing' : 'complete',
2587 + 'message' => 'No content blocks in chunk',
2588 + 'has_more' => $has_more,
2589 + 'page' => $page,
2590 + 'processed' => 0,
2591 + 'skipped' => 0,
2592 + 'failed' => 0,
2593 + 'failures' => [],
2594 + ];
2595 + }
2596 +
2597 + // Rewriting a few hundred posts is well past the default execution
2598 + // window on shared hosting, and a timeout mid-chunk would leave the
2599 + // migration looking stalled.
2600 + if (function_exists('set_time_limit')) {
2601 + @set_time_limit(300); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2602 + }
2603 +
2604 + $processed = 0;
2605 + $skipped = 0;
2606 + $failed = 0;
2607 + $failures = [];
2608 + $blocks = 0;
2609 +
2610 + foreach ($chunk as $record) {
2611 + $post_id = (int) ($record['object_id'] ?? ($record['data']['post_id'] ?? 0));
2612 + if ($post_id < 1) {
2613 + $skipped++;
2614 + continue;
2615 + }
2616 +
2617 + $result = Block_Converter::convert_post($post_id);
2618 +
2619 + if ('converted' === $result['status']) {
2620 + $processed++;
2621 + $blocks += $result['converted'];
2622 + continue;
2623 + }
2624 +
2625 + // A post the converter refused (broken block markup, a PCRE
2626 + // failure) was left untouched and still holds its Rank Math
2627 + // blocks. Folding it into `skipped` made it indistinguishable from
2628 + // a post that was simply already converted, so it is counted and
2629 + // named on its own.
2630 + if ('error' === $result['status']) {
2631 + $failed++;
2632 + $failures[] = ['post_id' => $post_id, 'message' => $result['message']];
2633 + continue;
2634 + }
2635 +
2636 + // `unchanged` is the normal outcome of a re-run, not a failure.
2637 + $skipped++;
2638 + }
2639 +
2640 + $has_more = $page < $total_chunks;
2641 +
2642 + if (!$has_more) {
2643 + // The detector caches its scan for an hour; without clearing it the
2644 + // Migration screen keeps offering blocks that are no longer there.
2645 + (new Import_Detector())->clear_cache();
2646 + }
2647 +
2648 + return [
2649 + 'status' => $has_more ? 'processing' : 'complete',
2650 + 'message' => sprintf(
2651 + 'Converted %d FAQ/HowTo blocks in %d posts, skipped %d, failed %d (page %d)',
2652 + $blocks,
2653 + $processed,
2654 + $skipped,
2655 + $failed,
2656 + $page
2657 + ),
2658 + 'has_more' => $has_more,
2659 + 'page' => $page,
2660 + 'total_chunks' => $total_chunks,
2661 + 'processed' => $processed,
2662 + 'skipped' => $skipped,
2663 + 'failed' => $failed,
2664 + 'failures' => $failures,
2665 + ];
2666 + }
2667 +
2668 + /**
2191 2669 * Build ThinkRank Pro's Redirections store, when Pro is active.
2192 2670 *
2193 2671 * Split out (and protected) so tests can substitute a fake — the real store
2194 2672 * writes to Pro's tables. Pro lazily creates them via Schema::ensure().
@@ -2498,14 +2976,15 @@
2498 2976 return true;
2499 2977 }
2500 2978
2501 2979 /**
2502 - * Migrate the source plugin's scheduled SEO email report cadence into
2503 - * ThinkRank's Email Reporting config.
2980 + * Carry the source plugin's scheduled SEO email report over as ThinkRank's
2981 + * Email Reporting switch.
2504 2982 *
2505 2983 * Only touches a config the user has not enabled yet, and never turns
2506 2984 * reports ON unless the source had them on — an unexpected recurring email
2507 - * after an import would be worse than a missing one.
2985 + * after an import would be worse than a missing one. The source cadence is
2986 + * not carried: the report's schedule is not a setting this plugin stores.
2508 2987 *
2509 2988 * @param array $extended Canonical settings `extended` payload
2510 2989 * @return bool True if the config was written
2511 2990 */
@@ -2523,15 +3002,9 @@
2523 3002 if (!empty($current['enabled'])) {
2524 3003 return false;
2525 3004 }
2526 3005
2527 - $frequency = (int) ($reports['frequency_days'] ?? 0);
2528 - $update = ['enabled' => true];
2529 - if ($frequency > 0) {
2530 - $update['frequency_days'] = $frequency;
2531 - }
2532 -
2533 - $config_manager->save(array_merge($current, $update));
3006 + $config_manager->save(['enabled' => true]);
2534 3007
2535 3008 return true;
2536 3009 }
2537 3010