| @@ -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 |