PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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 +738 -207 2.6.25 → 2.7.2 View file →
@@ -176,8 +176,9 @@
176 176 $count = 0;
177 177
178 178 switch ($plugin) {
179 179 case 'yoast':
180 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
180 181 $count = (int) $wpdb->get_var("
181 182 SELECT COUNT(DISTINCT post_id)
182 183 FROM {$wpdb->postmeta}
183 184 WHERE meta_key LIKE '_yoast_wpseo_%'
@@ -185,22 +186,23 @@
185 186 ");
186 187 break;
187 188
188 189 case 'rankmath':
189 - $count = (int) $wpdb->get_var("
190 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
191 + $count = (int) $wpdb->get_var($wpdb->prepare("
190 192 SELECT COUNT(DISTINCT post_id)
191 193 FROM {$wpdb->postmeta}
192 - WHERE meta_key LIKE 'rank_math_%'
194 + WHERE meta_key LIKE %s
193 195 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
194 - ");
196 + ", 'rank_math_%'));
195 197 break;
196 198
197 199 case 'aioseo':
198 200 $table = $wpdb->prefix . 'aioseo_posts';
199 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
201 + 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
200 202 $count = (int) $wpdb->get_var("
201 203 SELECT COUNT(post_id)
202 - FROM {$table}
204 + FROM {$wpdb->prefix}aioseo_posts
203 205 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
204 206 ");
205 207 }
206 208 break;
@@ -210,59 +212,458 @@
210 212 }
211 213
212 214 /**
213 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.
214 224 */
215 225 private function check_schema_data($plugin, &$count)
216 226 {
217 - global $wpdb;
218 -
219 227 $count = 0;
228 + $importable_posts = [];
220 229
221 230 switch ($plugin) {
222 231 case 'yoast':
223 - // Check for Yoast schema meta
224 - // Yoast Premium stores schema type in _yoast_wpseo_schema_article_type
225 - // Free version may use _yoast_wpseo_schema (JSON)
226 - $count = (int) $wpdb->get_var("
227 - SELECT COUNT(DISTINCT post_id)
228 - FROM {$wpdb->postmeta}
229 - WHERE (meta_key = '_yoast_wpseo_schema' OR meta_key = '_yoast_wpseo_schema_article_type')
230 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
231 - ");
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 + }
232 241 break;
233 242
234 243 case 'rankmath':
235 - // Check for Rank Math schema meta (any schema type)
236 - // Rank Math uses meta keys like: rank_math_schema_Article, rank_math_schema_BlogPosting, rank_math_schema_Product, etc.
237 - // Exclude shortcode schemas (rank_math_shortcode_schema_*)
238 - $count = (int) $wpdb->get_var("
239 - SELECT COUNT(DISTINCT post_id)
240 - FROM {$wpdb->postmeta}
241 - WHERE meta_key LIKE 'rank_math_schema_%'
242 - AND meta_key NOT LIKE 'rank_math_shortcode_schema_%'
243 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
244 - ");
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 + }
245 253 break;
246 254
247 255 case 'aioseo':
248 - // Check for AIOSEO schema in table
249 - $table = $wpdb->prefix . 'aioseo_posts';
250 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
251 - $count = (int) $wpdb->get_var("
252 - SELECT COUNT(post_id)
253 - FROM {$table}
254 - WHERE (schema_type IS NOT NULL AND schema_type != '')
255 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
256 - ");
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 + }
257 266 }
258 267 break;
259 268 }
260 269
270 + $count = count($importable_posts);
271 +
261 272 return $count > 0;
262 273 }
263 274
264 275 /**
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 + /**
265 666 * Check if SEO metadata (titles and descriptions) exists for a plugin
266 667 */
267 668 private function check_seo_metadata($plugin, &$count)
268 669 {
@@ -272,8 +673,9 @@
272 673
273 674 switch ($plugin) {
274 675 case 'yoast':
275 676 // Check for Yoast SEO title or description
677 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
276 678 $count = (int) $wpdb->get_var("
277 679 SELECT COUNT(DISTINCT post_id)
278 680 FROM {$wpdb->postmeta}
279 681 WHERE (meta_key = '_yoast_wpseo_title' OR meta_key = '_yoast_wpseo_metadesc')
@@ -283,8 +685,9 @@
283 685 break;
284 686
285 687 case 'rankmath':
286 688 // Check for Rank Math title or description
689 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
287 690 $count = (int) $wpdb->get_var("
288 691 SELECT COUNT(DISTINCT post_id)
289 692 FROM {$wpdb->postmeta}
290 693 WHERE (meta_key = 'rank_math_title' OR meta_key = 'rank_math_description')
@@ -295,12 +698,12 @@
295 698
296 699 case 'aioseo':
297 700 // Check for AIOSEO title or description in their custom table
298 701 $table = $wpdb->prefix . 'aioseo_posts';
299 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
702 + 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
300 703 $count = (int) $wpdb->get_var("
301 704 SELECT COUNT(post_id)
302 - FROM {$table}
705 + FROM {$wpdb->prefix}aioseo_posts
303 706 WHERE ((title IS NOT NULL AND title != '') OR (description IS NOT NULL AND description != ''))
304 707 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
305 708 ");
306 709
@@ -315,9 +718,9 @@
315 718 if (!empty($templates)) {
316 719 $post_types = array_keys($templates);
317 720 $type_placeholders = implode(', ', array_fill(0, count($post_types), '%s'));
318 721
319 - $template_count = (int) $wpdb->get_var($wpdb->prepare(
722 + $template_count = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
320 723 "SELECT COUNT(*)
321 724 FROM {$wpdb->posts}
322 725 WHERE post_status = 'publish'
323 726 AND post_type IN ({$type_placeholders})",
@@ -347,8 +750,9 @@
347 750 $count = 0;
348 751
349 752 switch ($plugin) {
350 753 case 'yoast':
754 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
351 755 $count = (int) $wpdb->get_var("
352 756 SELECT COUNT(DISTINCT post_id)
353 757 FROM {$wpdb->postmeta}
354 758 WHERE meta_key = '_yoast_wpseo_primary_category'
@@ -357,8 +761,9 @@
357 761 ");
358 762 break;
359 763
360 764 case 'rankmath':
765 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
361 766 $count = (int) $wpdb->get_var("
362 767 SELECT COUNT(DISTINCT post_id)
363 768 FROM {$wpdb->postmeta}
364 769 WHERE meta_key = 'rank_math_primary_category'
@@ -367,8 +772,9 @@
367 772 ");
368 773 break;
369 774
370 775 case 'aioseo':
776 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
371 777 $count = (int) $wpdb->get_var("
372 778 SELECT COUNT(DISTINCT post_id)
373 779 FROM {$wpdb->postmeta}
374 780 WHERE meta_key = '_aioseo_primary_category'
@@ -429,8 +835,9 @@
429 835 $batch_size = intval($options['batch_size']);
430 836 $offset = intval($options['offset']);
431 837 $overwrite = (bool) $options['overwrite_existing'];
432 838
839 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
433 840 $total_posts = (int) $wpdb->get_var("
434 841 SELECT COUNT(DISTINCT post_id)
435 842 FROM {$wpdb->postmeta}
436 843 WHERE meta_key = '_yoast_wpseo_primary_category'
@@ -437,8 +844,9 @@
437 844 AND meta_value != ''
438 845 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
439 846 ");
440 847
848 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
441 849 $posts = $wpdb->get_results($wpdb->prepare("
442 850 SELECT DISTINCT post_id
443 851 FROM {$wpdb->postmeta}
444 852 WHERE meta_key = '_yoast_wpseo_primary_category'
@@ -492,8 +900,9 @@
492 900 $batch_size = intval($options['batch_size']);
493 901 $offset = intval($options['offset']);
494 902 $overwrite = (bool) $options['overwrite_existing'];
495 903
904 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
496 905 $total_posts = (int) $wpdb->get_var("
497 906 SELECT COUNT(DISTINCT post_id)
498 907 FROM {$wpdb->postmeta}
499 908 WHERE meta_key = 'rank_math_primary_category'
@@ -500,8 +909,9 @@
500 909 AND meta_value != ''
501 910 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
502 911 ");
503 912
913 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
504 914 $posts = $wpdb->get_results($wpdb->prepare("
505 915 SELECT DISTINCT post_id
506 916 FROM {$wpdb->postmeta}
507 917 WHERE meta_key = 'rank_math_primary_category'
@@ -555,8 +965,9 @@
555 965 $batch_size = intval($options['batch_size']);
556 966 $offset = intval($options['offset']);
557 967 $overwrite = (bool) $options['overwrite_existing'];
558 968
969 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
559 970 $total_posts = (int) $wpdb->get_var("
560 971 SELECT COUNT(DISTINCT post_id)
561 972 FROM {$wpdb->postmeta}
562 973 WHERE meta_key = '_aioseo_primary_category'
@@ -563,8 +974,9 @@
563 974 AND meta_value != ''
564 975 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
565 976 ");
566 977
978 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
567 979 $posts = $wpdb->get_results($wpdb->prepare("
568 980 SELECT DISTINCT post_id
569 981 FROM {$wpdb->postmeta}
570 982 WHERE meta_key = '_aioseo_primary_category'
@@ -762,24 +1174,26 @@
762 1174 $batch_size = intval($options['batch_size']);
763 1175 $offset = intval($options['offset']);
764 1176
765 1177 // Get total count (for progress tracking)
766 - $total = (int) $wpdb->get_var("
1178 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1179 + $total = (int) $wpdb->get_var($wpdb->prepare("
767 1180 SELECT COUNT(DISTINCT post_id)
768 1181 FROM {$wpdb->postmeta}
769 - WHERE meta_key LIKE '_yoast_wpseo_%'
1182 + WHERE meta_key LIKE %s
770 1183 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
771 - ");
1184 + ", '_yoast_wpseo_%'));
772 1185
773 1186 // Get batch of posts with Yoast robots meta
1187 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
774 1188 $posts = $wpdb->get_results($wpdb->prepare("
775 1189 SELECT DISTINCT post_id
776 1190 FROM {$wpdb->postmeta}
777 - WHERE meta_key LIKE '_yoast_wpseo_%'
1191 + WHERE meta_key LIKE %s
778 1192 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
779 1193 ORDER BY post_id ASC
780 1194 LIMIT %d OFFSET %d
781 - ", $batch_size, $offset));
1195 + ", '_yoast_wpseo_%', $batch_size, $offset));
782 1196
783 1197 foreach ($posts as $post_obj) {
784 1198 $post_id = $post_obj->post_id;
785 1199 $has_changes = false;
@@ -903,24 +1317,26 @@
903 1317 $batch_size = intval($options['batch_size']);
904 1318 $offset = intval($options['offset']);
905 1319
906 1320 // Get total count (for progress tracking)
907 - $total = (int) $wpdb->get_var("
1321 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1322 + $total = (int) $wpdb->get_var($wpdb->prepare("
908 1323 SELECT COUNT(DISTINCT post_id)
909 1324 FROM {$wpdb->postmeta}
910 - WHERE meta_key LIKE 'rank_math_%'
1325 + WHERE meta_key LIKE %s
911 1326 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
912 - ");
1327 + ", 'rank_math_%'));
913 1328
914 1329 // Get batch of posts with Rank Math robots meta
1330 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
915 1331 $posts = $wpdb->get_results($wpdb->prepare("
916 1332 SELECT DISTINCT post_id
917 1333 FROM {$wpdb->postmeta}
918 - WHERE meta_key LIKE 'rank_math_%'
1334 + WHERE meta_key LIKE %s
919 1335 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
920 1336 ORDER BY post_id ASC
921 1337 LIMIT %d OFFSET %d
922 - ", $batch_size, $offset));
1338 + ", 'rank_math_%', $batch_size, $offset));
923 1339
924 1340 foreach ($posts as $post_obj) {
925 1341 $post_id = $post_obj->post_id;
926 1342 $has_changes = false;
@@ -1043,9 +1459,9 @@
1043 1459 // AIOSEO stores data in a custom table
1044 1460 $aioseo_table = $wpdb->prefix . 'aioseo_posts';
1045 1461
1046 1462 // Check if table exists
1047 - $table_exists = $wpdb->get_var("SHOW TABLES LIKE '$aioseo_table'") === $aioseo_table;
1463 + $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($aioseo_table))) === $aioseo_table; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1048 1464
1049 1465 if (!$table_exists) {
1050 1466 return ['success' => false, 'message' => 'AIOSEO table not found.'];
1051 1467 }
@@ -1050,21 +1466,23 @@
1050 1466 return ['success' => false, 'message' => 'AIOSEO table not found.'];
1051 1467 }
1052 1468
1053 1469 // Get total count (for progress tracking)
1470 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1054 1471 $total = (int) $wpdb->get_var("
1055 1472 SELECT COUNT(*)
1056 - FROM {$aioseo_table}
1473 + FROM {$wpdb->prefix}aioseo_posts
1057 1474 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1058 1475 ");
1059 1476
1060 1477 // Get batch of posts with AIOSEO settings
1478 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1061 1479 $posts = $wpdb->get_results($wpdb->prepare("
1062 1480 SELECT post_id, robots_default, robots_noindex, robots_nofollow,
1063 1481 robots_noarchive, robots_nosnippet, robots_noimageindex,
1064 1482 robots_max_snippet, robots_max_imagepreview, robots_max_videopreview,
1065 1483 canonical_url
1066 - FROM {$aioseo_table}
1484 + FROM {$wpdb->prefix}aioseo_posts
1067 1485 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1068 1486 ORDER BY post_id ASC
1069 1487 LIMIT %d OFFSET %d
1070 1488 ", $batch_size, $offset));
@@ -1178,24 +1596,27 @@
1178 1596 }
1179 1597
1180 1598 /**
1181 1599 * 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.
1182 1605 */
1183 1606 public function import_schema($plugin)
1184 1607 {
1185 - $imported_count = 0;
1186 -
1187 1608 switch ($plugin) {
1188 1609 case 'yoast':
1189 - $imported_count = $this->import_yoast_schema();
1610 + $breakdown = $this->import_yoast_schema();
1190 1611 break;
1191 1612
1192 1613 case 'rankmath':
1193 - $imported_count = $this->import_rankmath_schema();
1614 + $breakdown = $this->import_rankmath_schema();
1194 1615 break;
1195 1616
1196 1617 case 'aioseo':
1197 - $imported_count = $this->import_aioseo_schema();
1618 + $breakdown = $this->import_aioseo_schema();
1198 1619 break;
1199 1620
1200 1621 default:
1201 1622 return ['success' => false, 'message' => 'Invalid plugin specified.'];
@@ -1200,77 +1621,147 @@
1200 1621 default:
1201 1622 return ['success' => false, 'message' => 'Invalid plugin specified.'];
1202 1623 }
1203 1624
1204 - if ($imported_count > 0) {
1205 - return ['success' => true, 'message' => "Successfully imported schema settings for $imported_count posts."];
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 + ];
1206 1637 }
1207 1638
1208 - return ['success' => false, 'message' => 'No post-level schema settings found to import.'];
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];
1209 1687 }
1210 1688
1211 1689 /**
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 + /**
1212 1710 * 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}
1213 1721 */
1214 1722 private function import_yoast_schema()
1215 1723 {
1216 - global $wpdb;
1217 - $imported_count = 0;
1724 + $breakdown = $this->empty_schema_breakdown();
1218 1725
1219 - // First, try to import from full schema JSON (free version or old approach)
1220 - $posts = $wpdb->get_results("
1221 - SELECT post_id, meta_value
1222 - FROM {$wpdb->postmeta}
1223 - WHERE meta_key = '_yoast_wpseo_schema'
1224 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1225 - ");
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 + }
1226 1731
1227 - foreach ($posts as $post_obj) {
1228 - $post_id = $post_obj->post_id;
1229 -
1230 - // Check if Metasync schema already exists
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.
1231 1736 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1232 - if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1233 - continue; // Skip if already has Metasync schema
1234 - }
1235 -
1236 - // Decode Yoast schema JSON
1237 - $yoast_schema = json_decode((string)($post_obj->meta_value ?? ''), true);
1238 - if (empty($yoast_schema) || !is_array($yoast_schema)) {
1737 + if ($this->post_has_metasync_schema($post_id)) {
1738 + $breakdown['already_imported']++;
1239 1739 continue;
1240 1740 }
1241 1741
1242 - // Convert Yoast schema to Metasync format
1243 - $metasync_schema = $this->convert_yoast_schema_to_metasync($yoast_schema, $post_id);
1742 + $schema_row = $rows['_yoast_wpseo_schema'] ?? null;
1743 + $article_type_row = $rows['_yoast_wpseo_schema_article_type'] ?? null;
1244 1744
1245 - if (!empty($metasync_schema['types'])) {
1246 - update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1247 - $imported_count++;
1248 - }
1249 - }
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' => []];
1250 1752
1251 - // Second, try to import from schema type (Premium version)
1252 - $posts = $wpdb->get_results("
1253 - SELECT post_id, meta_value
1254 - FROM {$wpdb->postmeta}
1255 - WHERE meta_key = '_yoast_wpseo_schema_article_type'
1256 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1257 - ");
1258 -
1259 - foreach ($posts as $post_obj) {
1260 - $post_id = $post_obj->post_id;
1261 -
1262 - // Check if Metasync schema already exists
1263 - $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1264 - if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1265 - continue; // Skip if already has Metasync schema
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 + }
1266 1758 }
1267 1759
1268 - $schema_type = strtolower($post_obj->meta_value);
1269 -
1270 - // Create basic article schema with placeholders
1271 - // Yoast Premium generates schema dynamically, so we create a minimal version
1272 - if ($schema_type === 'article' || $schema_type === 'newsarticle' || $schema_type === 'blogposting') {
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) {
1273 1764 $metasync_schema = [
1274 1765 'enabled' => true,
1275 1766 'types' => [
1276 1767 [
@@ -1285,102 +1776,115 @@
1285 1776 ]
1286 1777 ]
1287 1778 ];
1288 1779
1289 - update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1290 - $imported_count++;
1780 + 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;
1291 1783 }
1784 +
1785 + $breakdown['unsupported']++;
1292 1786 }
1293 1787
1294 - return $imported_count;
1788 + $breakdown['total_candidates'] = count($rows_by_post);
1789 +
1790 + return $breakdown;
1295 1791 }
1296 1792
1297 1793 /**
1298 1794 * 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}
1299 1801 */
1300 1802 private function import_rankmath_schema()
1301 1803 {
1302 - global $wpdb;
1303 - $imported_count = 0;
1804 + $breakdown = $this->empty_schema_breakdown();
1304 1805
1305 - // Get all posts with any Rank Math schema (dynamically detect schema types)
1306 - // Exclude shortcode schemas
1307 - $posts = $wpdb->get_results("
1308 - SELECT DISTINCT pm.post_id, pm.meta_key, pm.meta_value
1309 - FROM {$wpdb->postmeta} pm
1310 - WHERE pm.meta_key LIKE 'rank_math_schema_%'
1311 - AND pm.meta_key NOT LIKE 'rank_math_shortcode_schema_%'
1312 - AND pm.post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1313 - ORDER BY pm.post_id
1314 - ");
1806 + $rows_by_post = [];
1807 + foreach ($this->rankmath_schema_candidate_rows() as $row) {
1808 + $rows_by_post[(int) $row->post_id][] = $row;
1809 + }
1315 1810
1316 - $processed_posts = [];
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']++;
1815 + continue;
1816 + }
1317 1817
1318 - foreach ($posts as $post_obj) {
1319 - $post_id = $post_obj->post_id;
1818 + $imported = false;
1320 1819
1321 - // Skip if we already processed this post
1322 - if (in_array($post_id, $processed_posts)) {
1323 - continue;
1324 - }
1820 + foreach ($rows as $row) {
1821 + if ($this->rankmath_schema_source_is_supported($row->meta_key, $row->meta_value) === null) {
1822 + continue;
1823 + }
1325 1824
1326 - // Check if Metasync schema already exists for this post
1327 - $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1328 - if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1329 - continue; // Skip if already has Metasync schema
1330 - }
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);
1331 1827
1332 - // Extract schema type from meta key (e.g., rank_math_schema_BlogPosting -> BlogPosting)
1333 - $schema_type = str_replace('rank_math_schema_', '', $post_obj->meta_key);
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 + }
1334 1833
1335 - // Decode Rank Math schema
1336 - $rm_schema = maybe_unserialize($post_obj->meta_value);
1337 - if (empty($rm_schema) || !is_array($rm_schema)) {
1338 - continue;
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 + }
1339 1843 }
1340 1844
1341 - // Convert Rank Math schema to Metasync format
1342 - $metasync_schema = $this->convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id);
1343 -
1344 - if (!empty($metasync_schema['types'])) {
1345 - update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1346 - $imported_count++;
1347 - $processed_posts[] = $post_id; // Mark post as processed
1845 + if (!$imported) {
1846 + $breakdown['unsupported']++;
1348 1847 }
1349 1848 }
1350 1849
1351 - return $imported_count;
1850 + $breakdown['total_candidates'] = count($rows_by_post);
1851 +
1852 + return $breakdown;
1352 1853 }
1353 1854
1354 1855 /**
1355 1856 * 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}
1356 1864 */
1357 1865 private function import_aioseo_schema()
1358 1866 {
1359 - global $wpdb;
1360 - $imported_count = 0;
1867 + $breakdown = $this->empty_schema_breakdown();
1361 1868
1362 - // Check if AIOSEO table exists
1363 - $table = $wpdb->prefix . 'aioseo_posts';
1364 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
1365 - return 0;
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;
1366 1875 }
1367 1876
1368 - // Get all posts with AIOSEO schema
1369 - $posts = $wpdb->get_results("
1370 - SELECT post_id, schema_type, schema_type_options
1371 - FROM {$table}
1372 - WHERE (schema_type IS NOT NULL AND schema_type != '')
1373 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1374 - ");
1877 + foreach ($rows_by_post as $post_id => $aioseo_data) {
1878 + $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;
1882 + }
1375 1883
1376 - foreach ($posts as $aioseo_data) {
1377 - $post_id = $aioseo_data->post_id;
1378 -
1379 - // Check if Metasync schema already exists
1380 - $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1381 - if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1382 - continue; // Skip if already has Metasync schema
1884 + if ($this->aioseo_schema_source_is_supported($aioseo_data->schema_type, $aioseo_data->schema_type_options) === null) {
1885 + $breakdown['unsupported']++;
1886 + continue;
1383 1887 }
1384 1888
1385 1889 // Decode AIOSEO schema options
1386 1890 $schema_options = json_decode((string)($aioseo_data->schema_type_options ?? ''), true);
@@ -1395,18 +1899,28 @@
1395 1899 $post_id
1396 1900 );
1397 1901
1398 1902 if (!empty($metasync_schema['types'])) {
1399 - update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1400 - $imported_count++;
1903 + 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']++;
1401 1907 }
1402 1908 }
1403 1909
1404 - return $imported_count;
1910 + $breakdown['total_candidates'] = count($rows_by_post);
1911 +
1912 + return $breakdown;
1405 1913 }
1406 1914
1407 1915 /**
1408 1916 * 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.
1409 1923 */
1410 1924 private function convert_yoast_schema_to_metasync($yoast_schema, $post_id)
1411 1925 {
1412 1926 $metasync_schema = [
@@ -1413,12 +1927,14 @@
1413 1927 'enabled' => true,
1414 1928 'types' => []
1415 1929 ];
1416 1930
1417 - // Yoast stores schema as a graph array
1418 - if (isset($yoast_schema['@graph']) && is_array($yoast_schema['@graph'])) {
1419 - foreach ($yoast_schema['@graph'] as $item) {
1420 - if (!isset($item['@type'])) {
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'])) {
1421 1937 continue;
1422 1938 }
1423 1939
1424 1940 $raw_type = $item['@type'];
@@ -1605,9 +2121,13 @@
1605 2121 'enabled' => true,
1606 2122 'types' => []
1607 2123 ];
1608 2124
1609 - $type = strtolower($schema_type);
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);
1610 2130
1611 2131 if ($type === 'article') {
1612 2132 $metasync_schema['types'][] = [
1613 2133 'type' => 'article',
@@ -1618,9 +2138,9 @@
1618 2138 'organization_name' => isset($schema_options['organizationName']) ? $schema_options['organizationName'] : '',
1619 2139 'organization_logo' => isset($schema_options['organizationLogo']) ? $schema_options['organizationLogo'] : ''
1620 2140 ]
1621 2141 ];
1622 - } elseif ($type === 'faqpage') {
2142 + } elseif ($type === 'FAQPage') {
1623 2143 $faq_items = [];
1624 2144 if (isset($schema_options['questions']) && is_array($schema_options['questions'])) {
1625 2145 foreach ($schema_options['questions'] as $question) {
1626 2146 if (isset($question['question']) && isset($question['answer'])) {
@@ -2701,9 +3221,9 @@
2701 3221 'resolved' => (string) $resolved,
2702 3222 ];
2703 3223
2704 3224 if (defined('WP_DEBUG') && WP_DEBUG) {
2705 - error_log(sprintf(
3225 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
2706 3226 'MetaSync importer: unresolved %s placeholder for post %d (%s) — %s',
2707 3227 $plugin,
2708 3228 (int) $post_id,
2709 3229 $meta_key,
@@ -3102,22 +3622,23 @@
3102 3622 ];
3103 3623 }
3104 3624
3105 3625 // Get total count (for progress tracking)
3106 - $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
3107 - $total_posts = (int) $wpdb->get_var($wpdb->prepare("
3626 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3627 + $total_posts = (int) $wpdb->get_var($wpdb->prepare('
3108 3628 SELECT COUNT(DISTINCT post_id)
3109 - FROM {$wpdb->postmeta}
3110 - WHERE meta_key IN ($meta_keys_placeholders)
3629 + FROM ' . $wpdb->postmeta . '
3630 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
3111 3631 AND meta_value != ''
3112 3632 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
3113 3633 ", $meta_keys));
3114 3634
3115 3635 // Get batch of posts with Yoast data
3116 - $posts = $wpdb->get_results($wpdb->prepare("
3636 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3637 + $posts = $wpdb->get_results($wpdb->prepare('
3117 3638 SELECT DISTINCT post_id
3118 - FROM {$wpdb->postmeta}
3119 - WHERE meta_key IN ($meta_keys_placeholders)
3639 + FROM ' . $wpdb->postmeta . '
3640 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
3120 3641 AND meta_value != ''
3121 3642 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
3122 3643 ORDER BY post_id ASC
3123 3644 LIMIT %d OFFSET %d
@@ -3304,22 +3825,23 @@
3304 3825 ];
3305 3826 }
3306 3827
3307 3828 // Get total count
3308 - $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
3309 - $total_posts = (int) $wpdb->get_var($wpdb->prepare("
3829 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3830 + $total_posts = (int) $wpdb->get_var($wpdb->prepare('
3310 3831 SELECT COUNT(DISTINCT post_id)
3311 - FROM {$wpdb->postmeta}
3312 - WHERE meta_key IN ($meta_keys_placeholders)
3832 + FROM ' . $wpdb->postmeta . '
3833 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
3313 3834 AND meta_value != ''
3314 3835 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
3315 3836 ", $meta_keys));
3316 3837
3317 3838 // Get batch of posts
3318 - $posts = $wpdb->get_results($wpdb->prepare("
3839 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3840 + $posts = $wpdb->get_results($wpdb->prepare('
3319 3841 SELECT DISTINCT post_id
3320 - FROM {$wpdb->postmeta}
3321 - WHERE meta_key IN ($meta_keys_placeholders)
3842 + FROM ' . $wpdb->postmeta . '
3843 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
3322 3844 AND meta_value != ''
3323 3845 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
3324 3846 ORDER BY post_id ASC
3325 3847 LIMIT %d OFFSET %d
@@ -3481,9 +4003,9 @@
3481 4003 $overwrite = (bool) $options['overwrite_existing'];
3482 4004
3483 4005 // Check if AIOSEO table exists
3484 4006 $table = $wpdb->prefix . 'aioseo_posts';
3485 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
4007 + 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
3486 4008 return [
3487 4009 'success' => false,
3488 4010 'message' => 'AIOSEO database table not found.'
3489 4011 ];
@@ -3573,16 +4095,18 @@
3573 4095 // one registered non-public — can still carry per-post overrides that
3574 4096 // the override-only walk always imported. The second arm keeps every
3575 4097 // row holding real AIOSEO data in scope whatever its post type, so
3576 4098 // adding a template never removes anything from the import.
4099 + // $joined_has_value is built only from the fixed $value_columns
4100 + // allowlist above; table names are inline literals.
3577 4101 $from_clause = "FROM {$wpdb->posts} p
3578 - LEFT JOIN {$table} a ON a.post_id = p.ID
4102 + LEFT JOIN {$wpdb->prefix}aioseo_posts a ON a.post_id = p.ID
3579 4103 WHERE p.post_status = 'publish'
3580 - AND (p.post_type IN ({$type_placeholders}) OR ({$joined_has_value}))";
4104 + AND (p.post_type IN (" . implode(', ', array_fill(0, count($template_post_types), '%s')) . ") OR ({$joined_has_value}))";
3581 4105
3582 - $total_posts = (int) $wpdb->get_var(
3583 - $wpdb->prepare("SELECT COUNT(*) {$from_clause}", $template_post_types)
3584 - );
4106 + // Assembled from the literal fragments above; post types bind via prepare().
4107 + $count_query = "SELECT COUNT(*) {$from_clause}";
4108 + $total_posts = (int) $wpdb->get_var($wpdb->prepare($count_query, $template_post_types)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- fragments built from fixed allowlists, values bound via prepare()
3585 4109
3586 4110 $joined_columns = array_map(
3587 4111 function ($column) {
3588 4112 return "a.{$column}";
@@ -3594,15 +4118,14 @@
3594 4118 // least one value column to select.
3595 4119 $joined_select = 'p.ID AS post_id, p.post_type AS post_type, '
3596 4120 . implode(', ', $joined_columns);
3597 4121
3598 - $posts = $wpdb->get_results($wpdb->prepare(
3599 - "SELECT {$joined_select}
4122 + // Assembled from the literal fragments above; post types bind via prepare().
4123 + $posts_query = "SELECT {$joined_select}
3600 4124 {$from_clause}
3601 4125 ORDER BY p.ID ASC
3602 - LIMIT %d OFFSET %d",
3603 - array_merge($template_post_types, [$batch_size, $offset])
3604 - ));
4126 + LIMIT %d OFFSET %d";
4127 + $posts = $wpdb->get_results($wpdb->prepare($posts_query, array_merge($template_post_types, [$batch_size, $offset]))); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- fragments built from fixed allowlists, values bound via prepare()
3605 4128 } else {
3606 4129 // No usable templates — e.g. only the social options are selected, or
3607 4130 // aioseo_options_dynamic is missing. Fall back to the original
3608 4131 // override-only walk so those sites behave exactly as before.
@@ -3607,23 +4130,31 @@
3607 4130 // aioseo_options_dynamic is missing. Fall back to the original
3608 4131 // override-only walk so those sites behave exactly as before.
3609 4132 $select_clause = implode(', ', array_merge(['post_id'], $value_columns));
3610 4133
4134 + // $where_clause / $select_clause are built only from the fixed
4135 + // $value_columns allowlist above.
4136 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where_clause is built only from the fixed $value_columns allowlist above
4137 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3611 4138 $total_posts = (int) $wpdb->get_var("
3612 4139 SELECT COUNT(post_id)
3613 - FROM {$table}
4140 + FROM {$wpdb->prefix}aioseo_posts
3614 4141 WHERE ({$where_clause})
3615 4142 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
3616 4143 ");
4144 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
3617 4145
4146 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $select_clause / $where_clause are built only from the fixed $value_columns allowlist above
4147 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3618 4148 $posts = $wpdb->get_results($wpdb->prepare("
3619 4149 SELECT {$select_clause}
3620 - FROM {$table}
4150 + FROM {$wpdb->prefix}aioseo_posts
3621 4151 WHERE ({$where_clause})
3622 4152 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
3623 4153 ORDER BY post_id ASC
3624 4154 LIMIT %d OFFSET %d
3625 4155 ", $batch_size, $offset));
4156 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
3626 4157 }
3627 4158
3628 4159 $imported_count = 0;
3629 4160 $skipped_count = 0;
@@ -4097,9 +4628,9 @@
4097 4628 $overwrite = (bool) $options['overwrite_existing'];
4098 4629
4099 4630 $table = $wpdb->prefix . 'aioseo_terms';
4100 4631
4101 - $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
4632 + $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
4102 4633 if ($table_exists !== $table) {
4103 4634 return [
4104 4635 'success' => false,
4105 4636 'message' => 'AIOSEO terms table not found.',
@@ -4105,11 +4636,11 @@
4105 4636 'message' => 'AIOSEO terms table not found.',
4106 4637 ];
4107 4638 }
4108 4639
4109 - $rows = $wpdb->get_results($wpdb->prepare(
4640 + $rows = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
4110 4641 "SELECT term_id, title, description, og_title, og_description, canonical_url, robots_noindex
4111 - FROM {$table}
4642 + FROM {$wpdb->prefix}aioseo_terms
4112 4643 ORDER BY term_id ASC
4113 4644 LIMIT %d OFFSET %d",
4114 4645 $batch_size,
4115 4646 $offset