PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-external-importer.php +159 -654 2.7.2 → 2.7.1 View file →
@@ -212,458 +212,61 @@
212 212 }
213 213
214 214 /**
215 215 * Check if schema data exists for a plugin
216 - *
217 - * Counts only records the importer would actually write: the source record
218 - * must hold a supported, populated schema, and the post must not already
219 - * carry a MetaSync schema block. The per-record rule is shared with the
220 - * importers (see the *_schema_source_is_supported() helpers and
221 - * post_has_metasync_schema()), so the count the Schema Settings page shows
222 - * is the count an import can deliver — the old detector counted every
223 - * provider row and reported posts that were silently skipped.
224 216 */
225 217 private function check_schema_data($plugin, &$count)
226 218 {
219 + global $wpdb;
220 +
227 221 $count = 0;
228 - $importable_posts = [];
229 222
230 223 switch ($plugin) {
231 224 case 'yoast':
232 - foreach ($this->yoast_schema_candidate_rows(true) as $row) {
233 - $post_id = (int) $row->post_id;
234 - if (isset($importable_posts[$post_id])) {
235 - continue;
236 - }
237 - if ($this->yoast_schema_source_is_supported($row->meta_key, $row->meta_value) !== null) {
238 - $importable_posts[$post_id] = true;
239 - }
240 - }
225 + // Check for Yoast schema meta
226 + // Yoast Premium stores schema type in _yoast_wpseo_schema_article_type
227 + // Free version may use _yoast_wpseo_schema (JSON)
228 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
229 + $count = (int) $wpdb->get_var("
230 + SELECT COUNT(DISTINCT post_id)
231 + FROM {$wpdb->postmeta}
232 + WHERE (meta_key = '_yoast_wpseo_schema' OR meta_key = '_yoast_wpseo_schema_article_type')
233 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
234 + ");
241 235 break;
242 236
243 237 case 'rankmath':
244 - foreach ($this->rankmath_schema_candidate_rows(true) as $row) {
245 - $post_id = (int) $row->post_id;
246 - if (isset($importable_posts[$post_id])) {
247 - continue;
248 - }
249 - if ($this->rankmath_schema_source_is_supported($row->meta_key, $row->meta_value) !== null) {
250 - $importable_posts[$post_id] = true;
251 - }
252 - }
238 + // Check for Rank Math schema meta (any schema type)
239 + // Rank Math uses meta keys like: rank_math_schema_Article, rank_math_schema_BlogPosting, rank_math_schema_Product, etc.
240 + // Exclude shortcode schemas (rank_math_shortcode_schema_*)
241 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
242 + $count = (int) $wpdb->get_var("
243 + SELECT COUNT(DISTINCT post_id)
244 + FROM {$wpdb->postmeta}
245 + WHERE meta_key LIKE 'rank_math_schema_%'
246 + AND meta_key NOT LIKE 'rank_math_shortcode_schema_%'
247 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
248 + ");
253 249 break;
254 250
255 251 case 'aioseo':
256 - // AIOSEO's `default` rows carry no per-post schema at all, so
257 - // they are filtered out before eligibility is even evaluated.
258 - foreach ($this->aioseo_schema_candidate_rows(true) as $row) {
259 - $post_id = (int) $row->post_id;
260 - if (isset($importable_posts[$post_id])) {
261 - continue;
262 - }
263 - if ($this->aioseo_schema_source_is_supported($row->schema_type, $row->schema_type_options) !== null) {
264 - $importable_posts[$post_id] = true;
265 - }
252 + // Check for AIOSEO schema in table
253 + $table = $wpdb->prefix . 'aioseo_posts';
254 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
255 + $count = (int) $wpdb->get_var("
256 + SELECT COUNT(post_id)
257 + FROM {$wpdb->prefix}aioseo_posts
258 + WHERE (schema_type IS NOT NULL AND schema_type != '')
259 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
260 + ");
266 261 }
267 262 break;
268 263 }
269 264
270 - $count = count($importable_posts);
271 -
272 265 return $count > 0;
273 266 }
274 267
275 268 /**
276 - * Yoast source rows that could hold per-post schema, for published posts.
277 - *
278 - * Shared by check_schema_data() and import_yoast_schema() so both look at
279 - * exactly the same candidate set.
280 - *
281 - * @param bool $only_new Skip posts that already hold a MetaSync schema block.
282 - * Only the detector sets this: the importers must see
283 - * every candidate so they can report those posts as
284 - * already imported rather than silently dropping them.
285 - * @return array<object> Rows with post_id, meta_key and meta_value.
286 - */
287 - private function yoast_schema_candidate_rows($only_new = false)
288 - {
289 - global $wpdb;
290 -
291 - $query = "SELECT post_id, meta_key, meta_value
292 - FROM {$wpdb->postmeta}
293 - WHERE (meta_key = '_yoast_wpseo_schema' OR meta_key = '_yoast_wpseo_schema_article_type')
294 - AND meta_value != ''
295 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
296 - " . ($only_new ? $this->sql_exclude_posts_with_metasync_schema($wpdb->postmeta) : '') . "
297 - ORDER BY post_id ASC";
298 -
299 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists; table names come from wpdb, exclusion fragment is class-built SQL
300 - return (array) $wpdb->get_results($query);
301 - }
302 -
303 - /**
304 - * Rank Math source rows that could hold per-post schema, for published
305 - * posts. Shortcode schemas are excluded, as the importer always has.
306 - *
307 - * @param bool $only_new Skip posts that already hold a MetaSync schema block.
308 - * @return array<object> Rows with post_id, meta_key and meta_value.
309 - */
310 - private function rankmath_schema_candidate_rows($only_new = false)
311 - {
312 - global $wpdb;
313 -
314 - $query = "SELECT pm.post_id, pm.meta_key, pm.meta_value
315 - FROM {$wpdb->postmeta} pm
316 - WHERE pm.meta_key LIKE 'rank_math_schema_%'
317 - AND pm.meta_key NOT LIKE 'rank_math_shortcode_schema_%'
318 - AND pm.meta_value != ''
319 - AND pm.meta_value != 'a:0:{}'
320 - AND pm.post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
321 - " . ($only_new ? $this->sql_exclude_posts_with_metasync_schema('pm') : '') . "
322 - ORDER BY pm.post_id ASC";
323 -
324 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists; table names come from wpdb, exclusion fragment is class-built SQL
325 - return (array) $wpdb->get_results($query);
326 - }
327 -
328 - /**
329 - * AIOSEO source rows that could hold per-post schema, for published posts.
330 - *
331 - * Returns an empty array when the AIOSEO table is absent (the plugin was
332 - * never installed), matching the previous detector behaviour.
333 - *
334 - * @param bool $only_new Skip posts that already hold a MetaSync schema block.
335 - * @return array<object> Rows with post_id, schema_type and schema_type_options.
336 - */
337 - private function aioseo_schema_candidate_rows($only_new = false)
338 - {
339 - global $wpdb;
340 -
341 - $table = $wpdb->prefix . 'aioseo_posts';
342 - if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) !== $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
343 - return [];
344 - }
345 -
346 - // AIOSEO writes a row for every post it tracks, and the overwhelming
347 - // majority carry schema_type 'default' with empty schema_type_options.
348 - // Those rows represent the post-type default, not per-post schema, so
349 - // they are dropped here rather than being reported as "unsupported" —
350 - // counting them is what made the card claim hundreds of ready posts
351 - // that no import could ever produce.
352 - $query = "SELECT post_id, schema_type, schema_type_options
353 - FROM {$table}
354 - WHERE schema_type IS NOT NULL
355 - AND schema_type != ''
356 - AND schema_type != 'default'
357 - AND schema_type_options IS NOT NULL
358 - AND schema_type_options NOT IN ('', '{}', '[]', 'null')
359 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
360 - " . ($only_new ? $this->sql_exclude_posts_with_metasync_schema($table) : '') . "
361 - ORDER BY post_id ASC";
362 -
363 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists; table name comes from wpdb, exclusion fragment is class-built SQL
364 - return (array) $wpdb->get_results($query);
365 - }
366 -
367 - /**
368 - * SQL clause excluding posts that already hold a MetaSync schema block.
369 - *
370 - * Mirrors post_has_metasync_schema() in SQL: a metasync_schema_markup
371 - * value whose "types" array exists and is non-empty. Values without a
372 - * "types" list (an OTTO-deployed otto_jsonld block, for example) are
373 - * deliberately not excluded — the importer does not skip those either.
374 - * post_has_metasync_schema() is still evaluated per record in PHP, so the
375 - * two checks cannot drift apart.
376 - *
377 - * @param string $alias Table name or alias the candidate query exposes post_id under.
378 - * @return string "AND NOT EXISTS (...)" clause.
379 - */
380 - private function sql_exclude_posts_with_metasync_schema($alias)
381 - {
382 - global $wpdb;
383 -
384 - return "AND NOT EXISTS (
385 - SELECT 1
386 - FROM {$wpdb->postmeta} ms
387 - WHERE ms.post_id = {$alias}.post_id
388 - AND ms.meta_key = 'metasync_schema_markup'
389 - AND ms.meta_value LIKE '%s:5:\"types\";a:%'
390 - AND ms.meta_value NOT LIKE '%s:5:\"types\";a:0:{}%'
391 - )";
392 - }
393 -
394 - /**
395 - * Whether a post already holds a MetaSync schema block an import must not
396 - * overwrite.
397 - *
398 - * Same guard the importers have always applied: an existing
399 - * metasync_schema_markup whose "types" list is populated means the schema
400 - * was already migrated or set by hand, so the source record is skipped
401 - * and reported as already imported rather than overwritten.
402 - *
403 - * @param int $post_id Post ID.
404 - * @return bool
405 - */
406 - private function post_has_metasync_schema($post_id)
407 - {
408 - $existing = get_post_meta($post_id, 'metasync_schema_markup', true);
409 -
410 - return is_array($existing) && !empty($existing['types']);
411 - }
412 -
413 - /**
414 - * Normalise a source schema type string to a supported MetaSync type.
415 - *
416 - * Shared by the Yoast and Rank Math eligibility checks and by the
417 - * converters, so "which source types can we actually carry" has a single
418 - * answer per provider.
419 - *
420 - * @param string $raw_type Source @type / schema type, any case.
421 - * @return string|null MetaSync type slug, or null when unsupported.
422 - */
423 - private function supported_schema_type($raw_type)
424 - {
425 - $type = strtolower(trim((string) $raw_type));
426 - if ($type === '') {
427 - return null;
428 - }
429 -
430 - $map = [
431 - 'article' => 'article',
432 - 'newsarticle' => 'article',
433 - 'blogposting' => 'article',
434 - 'faqpage' => 'FAQPage',
435 - 'product' => 'product',
436 - 'recipe' => 'recipe',
437 - ];
438 -
439 - return $map[$type] ?? null;
440 - }
441 -
442 - /**
443 - * Whether a Yoast source row holds schema the importer can convert.
444 - *
445 - * Returns the resolved MetaSync type when the row is supported, or null
446 - * when it must not be counted or imported (unsupported type, empty or
447 - * invalid payload). Used by both check_schema_data() and
448 - * import_yoast_schema() so the two cannot drift.
449 - *
450 - * @param string $meta_key Source meta key.
451 - * @param string $meta_value Raw meta value.
452 - * @return string|null
453 - */
454 - private function yoast_schema_source_is_supported($meta_key, $meta_value)
455 - {
456 - if ($meta_key === '_yoast_wpseo_schema_article_type') {
457 - // Yoast Premium stores a bare type name; the importer builds an
458 - // article skeleton from it.
459 - return $this->supported_schema_type($meta_value);
460 - }
461 -
462 - if ($meta_key !== '_yoast_wpseo_schema') {
463 - return null;
464 - }
465 -
466 - $value = trim((string) $meta_value);
467 - if ($value === '') {
468 - return null;
469 - }
470 -
471 - $decoded = json_decode($value, true);
472 - if (!is_array($decoded) || $decoded === []) {
473 - return null;
474 - }
475 -
476 - foreach ($this->yoast_schema_graph_items($decoded) as $item) {
477 - if (!is_array($item) || !isset($item['@type'])) {
478 - continue;
479 - }
480 -
481 - $raw_type = $item['@type'];
482 - if (is_array($raw_type)) {
483 - $raw_type = reset($raw_type);
484 - }
485 - if (!is_string($raw_type) || $raw_type === '') {
486 - continue;
487 - }
488 -
489 - $supported = $this->supported_schema_type($raw_type);
490 - if ($supported === null) {
491 - continue;
492 - }
493 -
494 - // A FAQPage whose questions cannot be converted produces an empty
495 - // MetaSync block, so it must not be counted as importable either.
496 - if ($supported === 'FAQPage'
497 - && !$this->faq_questions_are_convertible($item['mainEntity'] ?? [], 'yoast')) {
498 - continue;
499 - }
500 -
501 - return $supported;
502 - }
503 -
504 - return null;
505 - }
506 -
507 - /**
508 - * Iterate the schema entries inside a decoded Yoast payload.
509 - *
510 - * Supports the two storage shapes Yoast has used: the current
511 - * `{"@graph": [...]}` wrapper and the legacy root array of entries.
512 - *
513 - * @param array $decoded Decoded `_yoast_wpseo_schema` value.
514 - * @return array Iterable list of schema item arrays.
515 - */
516 - private function yoast_schema_graph_items($decoded)
517 - {
518 - if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
519 - return $decoded['@graph'];
520 - }
521 -
522 - // A single schema object at the root: {"@type":"Article",...}.
523 - if (isset($decoded['@type'])) {
524 - return [$decoded];
525 - }
526 -
527 - // Legacy payload: a root array of entries, e.g.
528 - // [{"@type":"Article","headline":"..."}, ...]. Only treat it as such
529 - // when every element is an array — otherwise the value is an object
530 - // whose keys are property names, not entries, and no entry carries a
531 - // @type anyway.
532 - $is_entry_list = $decoded !== [];
533 - foreach ($decoded as $entry) {
534 - if (!is_array($entry)) {
535 - $is_entry_list = false;
536 - break;
537 - }
538 - }
539 -
540 - return $is_entry_list ? $decoded : [];
541 - }
542 -
543 - /**
544 - * Whether a Rank Math source row holds schema the importer can convert.
545 - *
546 - * Returns the resolved MetaSync type when supported, null otherwise. The
547 - * type lives in the meta key (rank_math_schema_Article), mirroring
548 - * convert_rankmath_schema_to_metasync().
549 - *
550 - * @param string $meta_key Source meta key.
551 - * @param string $meta_value Raw meta value.
552 - * @return string|null
553 - */
554 - private function rankmath_schema_source_is_supported($meta_key, $meta_value)
555 - {
556 - if (strpos((string) $meta_key, 'rank_math_schema_') !== 0) {
557 - return null;
558 - }
559 -
560 - $schema_type = str_replace('rank_math_schema_', '', (string) $meta_key);
561 - $supported = $this->supported_schema_type($schema_type);
562 -
563 - if ($supported === null) {
564 - return null;
565 - }
566 -
567 - $decoded = maybe_unserialize($meta_value);
568 - if (!is_array($decoded) || $decoded === []) {
569 - return null;
570 - }
571 -
572 - // Keep the check in step with the converter: a FAQPage whose questions
573 - // cannot be converted would yield an empty MetaSync block.
574 - if ($supported === 'FAQPage'
575 - && !$this->faq_questions_are_convertible($decoded['questions'] ?? [], 'rankmath')) {
576 - return null;
577 - }
578 -
579 - return $supported;
580 - }
581 -
582 - /**
583 - * Whether an AIOSEO row holds per-post schema the importer can convert.
584 - *
585 - * Returns the resolved MetaSync type when supported, null otherwise.
586 - * `schema_type = 'default'` rows — the post-type default AIOSEO stamps on
587 - * every tracked post — never reach this check from the candidate queries,
588 - * but it is enforced here as well so the importer cannot regress into
589 - * reporting them as "unsupported" source schemas.
590 - *
591 - * @param string $schema_type Raw schema_type column value.
592 - * @param string $schema_type_options Raw schema_type_options JSON.
593 - * @return string|null
594 - */
595 - private function aioseo_schema_source_is_supported($schema_type, $schema_type_options)
596 - {
597 - $type = strtolower(trim((string) $schema_type));
598 -
599 - if ($type === '' || $type === 'default' || $type === 'none') {
600 - return null;
601 - }
602 -
603 - $supported = $this->supported_schema_type($type);
604 - if ($supported === null) {
605 - return null;
606 - }
607 -
608 - $decoded = json_decode(trim((string) $schema_type_options), true);
609 - if (!is_array($decoded) || $decoded === []) {
610 - return null;
611 - }
612 -
613 - // As above: an unconvertible FAQPage must not be counted as importable.
614 - if ($supported === 'FAQPage'
615 - && !$this->faq_questions_are_convertible($decoded['questions'] ?? [], 'aioseo')) {
616 - return null;
617 - }
618 -
619 - return $supported;
620 - }
621 -
622 - /**
623 - * Whether a FAQ question list holds at least one entry the converter can
624 - * turn into a MetaSync FAQ item, for the given provider's key shape.
625 - *
626 - * Eligibility and conversion must agree: a source counted as importable
627 - * has to produce a non-empty MetaSync block, or the detector over-reports
628 - * again — the very defect this class of fix exists to close.
629 - *
630 - * A scalar `mainEntity`/`questions` value (corrupt or foreign meta) is
631 - * simply not convertible; it must never fatal the eligibility check,
632 - * which runs on every render of the Schema import tab.
633 - *
634 - * @param mixed $questions Question list from the source record.
635 - * @param string $provider 'yoast', 'rankmath' or 'aioseo'.
636 - * @return bool
637 - */
638 - private function faq_questions_are_convertible($questions, $provider)
639 - {
640 - if (!is_array($questions)) {
641 - return false;
642 - }
643 -
644 - foreach ($questions as $question) {
645 - if (!is_array($question)) {
646 - continue;
647 - }
648 -
649 - if ($provider === 'yoast') {
650 - if (isset($question['name'], $question['acceptedAnswer']['text'])) {
651 - return true;
652 - }
653 - } elseif ($provider === 'rankmath') {
654 - if (isset($question['name'], $question['text'])) {
655 - return true;
656 - }
657 - } elseif (isset($question['question'], $question['answer'])) {
658 - return true;
659 - }
660 - }
661 -
662 - return false;
663 - }
664 -
665 - /**
666 269 * Check if SEO metadata (titles and descriptions) exists for a plugin
667 270 */
668 271 private function check_seo_metadata($plugin, &$count)
669 272 {
@@ -1596,27 +1199,24 @@
1596 1199 }
1597 1200
1598 1201 /**
1599 1202 * Import Schema Settings (Per-Post Schema)
1600 - *
1601 - * Composes the user-facing result from the importer's breakdown so the
1602 - * message states what actually happened: no source schema, everything
1603 - * already migrated, everything unsupported/empty, or an import — including
1604 - * the partial case where some posts were skipped alongside the imports.
1605 1203 */
1606 1204 public function import_schema($plugin)
1607 1205 {
1206 + $imported_count = 0;
1207 +
1608 1208 switch ($plugin) {
1609 1209 case 'yoast':
1610 - $breakdown = $this->import_yoast_schema();
1210 + $imported_count = $this->import_yoast_schema();
1611 1211 break;
1612 1212
1613 1213 case 'rankmath':
1614 - $breakdown = $this->import_rankmath_schema();
1214 + $imported_count = $this->import_rankmath_schema();
1615 1215 break;
1616 1216
1617 1217 case 'aioseo':
1618 - $breakdown = $this->import_aioseo_schema();
1218 + $imported_count = $this->import_aioseo_schema();
1619 1219 break;
1620 1220
1621 1221 default:
1622 1222 return ['success' => false, 'message' => 'Invalid plugin specified.'];
@@ -1621,147 +1221,79 @@
1621 1221 default:
1622 1222 return ['success' => false, 'message' => 'Invalid plugin specified.'];
1623 1223 }
1624 1224
1625 - $imported = (int) $breakdown['imported'];
1626 - $already_imported = (int) $breakdown['already_imported'];
1627 - $unsupported = (int) $breakdown['unsupported'];
1628 - $candidates = (int) $breakdown['total_candidates'];
1629 -
1630 - // No source rows at all — nothing was ever detected to begin with.
1631 - if ($candidates === 0) {
1632 - return [
1633 - 'success' => false,
1634 - 'message' => 'No post-level schema settings found to import.',
1635 - 'breakdown' => $breakdown,
1636 - ];
1225 + if ($imported_count > 0) {
1226 + return ['success' => true, 'message' => "Successfully imported schema settings for $imported_count posts."];
1637 1227 }
1638 1228
1639 - if ($imported === 0) {
1640 - // Every eligible source record is already in MetaSync.
1641 - if ($already_imported > 0 && $unsupported === 0) {
1642 - return [
1643 - 'success' => false,
1644 - 'message' => sprintf(
1645 - 'All %d eligible post(s) already have MetaSync schema — nothing left to import.',
1646 - $already_imported
1647 - ),
1648 - 'breakdown' => $breakdown,
1649 - ];
1650 - }
1651 -
1652 - // Source schema exists, but none of it maps to a supported type.
1653 - if ($unsupported > 0 && $already_imported === 0) {
1654 - return [
1655 - 'success' => false,
1656 - 'message' => sprintf(
1657 - 'Found %d post(s) with schema settings, but none use a supported schema type '
1658 - . '(Article, FAQPage, Product, Recipe) or their contents were empty.',
1659 - $unsupported
1660 - ),
1661 - 'breakdown' => $breakdown,
1662 - ];
1663 - }
1664 -
1665 - // Both skip reasons applied, nothing imported.
1666 - return [
1667 - 'success' => false,
1668 - 'message' => sprintf(
1669 - 'No schema settings imported: %d post(s) already had MetaSync schema and %d post(s) had unsupported or empty schema settings.',
1670 - $already_imported,
1671 - $unsupported
1672 - ),
1673 - 'breakdown' => $breakdown,
1674 - ];
1675 - }
1676 -
1677 - $message = sprintf('Successfully imported schema settings for %d post(s).', $imported);
1678 -
1679 - if ($already_imported > 0) {
1680 - $message .= sprintf(' Skipped %d post(s) that already had MetaSync schema.', $already_imported);
1681 - }
1682 - if ($unsupported > 0) {
1683 - $message .= sprintf(' Skipped %d post(s) with unsupported or empty schema settings.', $unsupported);
1684 - }
1685 -
1686 - return ['success' => true, 'message' => $message, 'breakdown' => $breakdown];
1229 + return ['success' => false, 'message' => 'No post-level schema settings found to import.'];
1687 1230 }
1688 1231
1689 1232 /**
1690 - * Fresh per-import counters shared by the three schema importers.
1691 - *
1692 - * `total_candidates` counts distinct posts the importer looked at, so a
1693 - * post with several source rows is one candidate. Posts already holding a
1694 - * MetaSync schema count as `already_imported`; source rows that carry no
1695 - * supported, populated schema count as `unsupported`.
1696 - *
1697 - * @return array{imported:int, already_imported:int, unsupported:int, total_candidates:int}
1698 - */
1699 - private function empty_schema_breakdown()
1700 - {
1701 - return [
1702 - 'imported' => 0,
1703 - 'already_imported' => 0,
1704 - 'unsupported' => 0,
1705 - 'total_candidates' => 0,
1706 - ];
1707 - }
1708 -
1709 - /**
1710 1233 * Import per-post schema from Yoast SEO
1711 - *
1712 - * Rows are grouped per post first, because a post can carry both source
1713 - * keys and the outcome must be decided once per post: a MetaSync schema
1714 - * that already exists wins outright; otherwise the full JSON payload under
1715 - * `_yoast_wpseo_schema` is preferred, and only when it yields nothing does
1716 - * the bare Premium article type under `_yoast_wpseo_schema_article_type`
1717 - * produce a placeholder article schema. Every other combination is
1718 - * unsupported.
1719 - *
1720 - * @return array{imported:int, already_imported:int, unsupported:int, total_candidates:int}
1721 1234 */
1722 1235 private function import_yoast_schema()
1723 1236 {
1724 - $breakdown = $this->empty_schema_breakdown();
1237 + global $wpdb;
1238 + $imported_count = 0;
1725 1239
1726 - // post_id => [meta_key => row]
1727 - $rows_by_post = [];
1728 - foreach ($this->yoast_schema_candidate_rows() as $row) {
1729 - $rows_by_post[(int) $row->post_id][$row->meta_key] = $row;
1730 - }
1240 + // First, try to import from full schema JSON (free version or old approach)
1241 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1242 + $posts = $wpdb->get_results("
1243 + SELECT post_id, meta_value
1244 + FROM {$wpdb->postmeta}
1245 + WHERE meta_key = '_yoast_wpseo_schema'
1246 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1247 + ");
1731 1248
1732 - foreach ($rows_by_post as $post_id => $rows) {
1733 - // A MetaSync schema that is already there outranks whatever the
1734 - // source plugin holds — the schema migration ran before (or an
1735 - // editor set it by hand) and overwriting it is not this tool's job.
1249 + foreach ($posts as $post_obj) {
1250 + $post_id = $post_obj->post_id;
1251 +
1252 + // Check if Metasync schema already exists
1736 1253 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1737 - if ($this->post_has_metasync_schema($post_id)) {
1738 - $breakdown['already_imported']++;
1254 + if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1255 + continue; // Skip if already has Metasync schema
1256 + }
1257 +
1258 + // Decode Yoast schema JSON
1259 + $yoast_schema = json_decode((string)($post_obj->meta_value ?? ''), true);
1260 + if (empty($yoast_schema) || !is_array($yoast_schema)) {
1739 1261 continue;
1740 1262 }
1741 1263
1742 - $schema_row = $rows['_yoast_wpseo_schema'] ?? null;
1743 - $article_type_row = $rows['_yoast_wpseo_schema_article_type'] ?? null;
1264 + // Convert Yoast schema to Metasync format
1265 + $metasync_schema = $this->convert_yoast_schema_to_metasync($yoast_schema, $post_id);
1744 1266
1745 - // Full JSON payload first (free version, or the legacy root array).
1746 - if ($schema_row !== null
1747 - && $this->yoast_schema_source_is_supported($schema_row->meta_key, $schema_row->meta_value) !== null) {
1748 - $yoast_schema = json_decode((string) ($schema_row->meta_value ?? ''), true);
1749 - $metasync_schema = is_array($yoast_schema)
1750 - ? $this->convert_yoast_schema_to_metasync($yoast_schema, $post_id)
1751 - : ['types' => []];
1267 + if (!empty($metasync_schema['types'])) {
1268 + update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
1269 + $imported_count++;
1270 + }
1271 + }
1752 1272
1753 - if (!empty($metasync_schema['types'])) {
1754 - update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
1755 - $breakdown['imported']++;
1756 - continue;
1757 - }
1273 + // Second, try to import from schema type (Premium version)
1274 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1275 + $posts = $wpdb->get_results("
1276 + SELECT post_id, meta_value
1277 + FROM {$wpdb->postmeta}
1278 + WHERE meta_key = '_yoast_wpseo_schema_article_type'
1279 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1280 + ");
1281 +
1282 + foreach ($posts as $post_obj) {
1283 + $post_id = $post_obj->post_id;
1284 +
1285 + // Check if Metasync schema already exists
1286 + $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1287 + if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1288 + continue; // Skip if already has Metasync schema
1758 1289 }
1759 1290
1760 - // Bare Premium article type: build a minimal article schema with
1761 - // placeholders, since Yoast Premium generates schema dynamically.
1762 - if ($article_type_row !== null
1763 - && $this->yoast_schema_source_is_supported($article_type_row->meta_key, $article_type_row->meta_value) !== null) {
1291 + $schema_type = strtolower($post_obj->meta_value);
1292 +
1293 + // Create basic article schema with placeholders
1294 + // Yoast Premium generates schema dynamically, so we create a minimal version
1295 + if ($schema_type === 'article' || $schema_type === 'newsarticle' || $schema_type === 'blogposting') {
1764 1296 $metasync_schema = [
1765 1297 'enabled' => true,
1766 1298 'types' => [
1767 1299 [
@@ -1777,116 +1309,105 @@
1777 1309 ]
1778 1310 ];
1779 1311
1780 1312 update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
1781 - $breakdown['imported']++;
1782 - continue;
1313 + $imported_count++;
1783 1314 }
1784 -
1785 - $breakdown['unsupported']++;
1786 1315 }
1787 1316
1788 - $breakdown['total_candidates'] = count($rows_by_post);
1789 -
1790 - return $breakdown;
1317 + return $imported_count;
1791 1318 }
1792 1319
1793 1320 /**
1794 1321 * Import per-post schema from Rank Math
1795 - *
1796 - * Rows are grouped per post, mirroring check_schema_data(): a post with
1797 - * several `rank_math_schema_*` rows is a single candidate, imported from
1798 - * the first row that carries a supported, populated schema.
1799 - *
1800 - * @return array{imported:int, already_imported:int, unsupported:int, total_candidates:int}
1801 1322 */
1802 1323 private function import_rankmath_schema()
1803 1324 {
1804 - $breakdown = $this->empty_schema_breakdown();
1325 + global $wpdb;
1326 + $imported_count = 0;
1805 1327
1806 - $rows_by_post = [];
1807 - foreach ($this->rankmath_schema_candidate_rows() as $row) {
1808 - $rows_by_post[(int) $row->post_id][] = $row;
1809 - }
1328 + // Get all posts with any Rank Math schema (dynamically detect schema types)
1329 + // Exclude shortcode schemas
1330 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1331 + $posts = $wpdb->get_results("
1332 + SELECT DISTINCT pm.post_id, pm.meta_key, pm.meta_value
1333 + FROM {$wpdb->postmeta} pm
1334 + WHERE pm.meta_key LIKE 'rank_math_schema_%'
1335 + AND pm.meta_key NOT LIKE 'rank_math_shortcode_schema_%'
1336 + AND pm.post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1337 + ORDER BY pm.post_id
1338 + ");
1810 1339
1811 - foreach ($rows_by_post as $post_id => $rows) {
1812 - $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1813 - if ($this->post_has_metasync_schema($post_id)) {
1814 - $breakdown['already_imported']++;
1340 + $processed_posts = [];
1341 +
1342 + foreach ($posts as $post_obj) {
1343 + $post_id = $post_obj->post_id;
1344 +
1345 + // Skip if we already processed this post
1346 + if (in_array($post_id, $processed_posts)) {
1815 1347 continue;
1816 1348 }
1817 1349
1818 - $imported = false;
1350 + // Check if Metasync schema already exists for this post
1351 + $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1352 + if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1353 + continue; // Skip if already has Metasync schema
1354 + }
1819 1355
1820 - foreach ($rows as $row) {
1821 - if ($this->rankmath_schema_source_is_supported($row->meta_key, $row->meta_value) === null) {
1822 - continue;
1823 - }
1356 + // Extract schema type from meta key (e.g., rank_math_schema_BlogPosting -> BlogPosting)
1357 + $schema_type = str_replace('rank_math_schema_', '', $post_obj->meta_key);
1824 1358
1825 - // Extract schema type from meta key (e.g., rank_math_schema_BlogPosting -> BlogPosting)
1826 - $schema_type = str_replace('rank_math_schema_', '', $row->meta_key);
1359 + // Decode Rank Math schema
1360 + $rm_schema = maybe_unserialize($post_obj->meta_value);
1361 + if (empty($rm_schema) || !is_array($rm_schema)) {
1362 + continue;
1363 + }
1827 1364
1828 - // Decode Rank Math schema
1829 - $rm_schema = maybe_unserialize($row->meta_value);
1830 - if (empty($rm_schema) || !is_array($rm_schema)) {
1831 - continue;
1832 - }
1365 + // Convert Rank Math schema to Metasync format
1366 + $metasync_schema = $this->convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id);
1833 1367
1834 - // Convert Rank Math schema to Metasync format
1835 - $metasync_schema = $this->convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id);
1836 -
1837 - if (!empty($metasync_schema['types'])) {
1838 - update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
1839 - $breakdown['imported']++;
1840 - $imported = true;
1841 - break; // One schema block per post.
1842 - }
1368 + if (!empty($metasync_schema['types'])) {
1369 + update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
1370 + $imported_count++;
1371 + $processed_posts[] = $post_id; // Mark post as processed
1843 1372 }
1844 -
1845 - if (!$imported) {
1846 - $breakdown['unsupported']++;
1847 - }
1848 1373 }
1849 1374
1850 - $breakdown['total_candidates'] = count($rows_by_post);
1851 -
1852 - return $breakdown;
1375 + return $imported_count;
1853 1376 }
1854 1377
1855 1378 /**
1856 1379 * Import per-post schema from AIOSEO
1857 - *
1858 - * `schema_type = 'default'` rows and rows whose schema options are empty
1859 - * never reach this loop — they are excluded by aioseo_schema_candidate_rows()
1860 - * for the detector and the importer alike, so the two can never disagree
1861 - * about what counts as a candidate.
1862 - *
1863 - * @return array{imported:int, already_imported:int, unsupported:int, total_candidates:int}
1864 1380 */
1865 1381 private function import_aioseo_schema()
1866 1382 {
1867 - $breakdown = $this->empty_schema_breakdown();
1383 + global $wpdb;
1384 + $imported_count = 0;
1868 1385
1869 - // An absent AIOSEO table means there is nothing to import at all.
1870 - // Rows are keyed per post so the count matches check_schema_data(),
1871 - // which also counts distinct posts.
1872 - $rows_by_post = [];
1873 - foreach ($this->aioseo_schema_candidate_rows() as $aioseo_data) {
1874 - $rows_by_post[(int) $aioseo_data->post_id] = $aioseo_data;
1386 + // Check if AIOSEO table exists
1387 + $table = $wpdb->prefix . 'aioseo_posts';
1388 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) !== $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1389 + return 0;
1875 1390 }
1876 1391
1877 - foreach ($rows_by_post as $post_id => $aioseo_data) {
1392 + // Get all posts with AIOSEO schema
1393 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1394 + $posts = $wpdb->get_results("
1395 + SELECT post_id, schema_type, schema_type_options
1396 + FROM {$wpdb->prefix}aioseo_posts
1397 + WHERE (schema_type IS NOT NULL AND schema_type != '')
1398 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1399 + ");
1400 +
1401 + foreach ($posts as $aioseo_data) {
1402 + $post_id = $aioseo_data->post_id;
1403 +
1404 + // Check if Metasync schema already exists
1878 1405 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1879 - if ($this->post_has_metasync_schema($post_id)) {
1880 - $breakdown['already_imported']++;
1881 - continue;
1406 + if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1407 + continue; // Skip if already has Metasync schema
1882 1408 }
1883 1409
1884 - if ($this->aioseo_schema_source_is_supported($aioseo_data->schema_type, $aioseo_data->schema_type_options) === null) {
1885 - $breakdown['unsupported']++;
1886 - continue;
1887 - }
1888 -
1889 1410 // Decode AIOSEO schema options
1890 1411 $schema_options = json_decode((string)($aioseo_data->schema_type_options ?? ''), true);
1891 1412 if (!is_array($schema_options)) {
1892 1413 $schema_options = [];
@@ -1900,27 +1421,17 @@
1900 1421 );
1901 1422
1902 1423 if (!empty($metasync_schema['types'])) {
1903 1424 update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
1904 - $breakdown['imported']++;
1905 - } else {
1906 - $breakdown['unsupported']++;
1425 + $imported_count++;
1907 1426 }
1908 1427 }
1909 1428
1910 - $breakdown['total_candidates'] = count($rows_by_post);
1911 -
1912 - return $breakdown;
1429 + return $imported_count;
1913 1430 }
1914 1431
1915 1432 /**
1916 1433 * Convert Yoast schema to Metasync format
1917 - *
1918 - * Handles both storage shapes Yoast has used: the current `@graph`
1919 - * wrapper and the legacy root array of entries. Both are iterated through
1920 - * yoast_schema_graph_items(), which is also what the eligibility check
1921 - * walks — so a payload the detector counts is a payload the importer can
1922 - * convert.
1923 1434 */
1924 1435 private function convert_yoast_schema_to_metasync($yoast_schema, $post_id)
1925 1436 {
1926 1437 $metasync_schema = [
@@ -1927,14 +1438,12 @@
1927 1438 'enabled' => true,
1928 1439 'types' => []
1929 1440 ];
1930 1441
1931 - // Yoast stores schema as a graph array (legacy installs: a root array
1932 - // of entries, or a single root object, instead of an @graph wrapper).
1933 - $graph_items = $this->yoast_schema_graph_items($yoast_schema);
1934 - if ($graph_items !== []) {
1935 - foreach ($graph_items as $item) {
1936 - if (!is_array($item) || !isset($item['@type'])) {
1442 + // Yoast stores schema as a graph array
1443 + if (isset($yoast_schema['@graph']) && is_array($yoast_schema['@graph'])) {
1444 + foreach ($yoast_schema['@graph'] as $item) {
1445 + if (!isset($item['@type'])) {
1937 1446 continue;
1938 1447 }
1939 1448
1940 1449 $raw_type = $item['@type'];
@@ -2121,13 +1630,9 @@
2121 1630 'enabled' => true,
2122 1631 'types' => []
2123 1632 ];
2124 1633
2125 - // Normalise through the shared map so the article family
2126 - // (NewsArticle, BlogPosting) imports the same way the Yoast and Rank
2127 - // Math converters handle it — eligibility resolves them to 'article'
2128 - // too, and the two must agree on every type they let through.
2129 - $type = $this->supported_schema_type($schema_type);
1634 + $type = strtolower($schema_type);
2130 1635
2131 1636 if ($type === 'article') {
2132 1637 $metasync_schema['types'][] = [
2133 1638 'type' => 'article',
@@ -2138,9 +1643,9 @@
2138 1643 'organization_name' => isset($schema_options['organizationName']) ? $schema_options['organizationName'] : '',
2139 1644 'organization_logo' => isset($schema_options['organizationLogo']) ? $schema_options['organizationLogo'] : ''
2140 1645 ]
2141 1646 ];
2142 - } elseif ($type === 'FAQPage') {
1647 + } elseif ($type === 'faqpage') {
2143 1648 $faq_items = [];
2144 1649 if (isset($schema_options['questions']) && is_array($schema_options['questions'])) {
2145 1650 foreach ($schema_options['questions'] as $question) {
2146 1651 if (isset($question['question']) && isset($question['answer'])) {