| @@ -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'])) { |