| @@ -18,8 +18,26 @@ | ||
| 18 | 18 | |
| 19 | 19 | class Metasync_Term_Plugin_Sync { |
| 20 | 20 | |
| 21 | 21 | /** |
| 22 | + * OTTO-generated term fields and their matching Persistence settings. | |
| 23 | + * | |
| 24 | + * Manually entered term fields are deliberately not gated; callers mark | |
| 25 | + * only the comprehensive OTTO sync as generated below. | |
| 26 | + * | |
| 27 | + * @var array<string,string> | |
| 28 | + */ | |
| 29 | + private const OTTO_PERSISTENCE_KEYS = [ | |
| 30 | + 'title' => 'meta_title', | |
| 31 | + 'desc' => 'meta_description', | |
| 32 | + 'og_title' => 'og_title', | |
| 33 | + 'og_desc' => 'og_description', | |
| 34 | + 'twitter_title' => 'twitter_title', | |
| 35 | + 'twitter_desc' => 'twitter_description', | |
| 36 | + 'canonical' => 'canonical_url', | |
| 37 | + ]; | |
| 38 | + | |
| 39 | + /** | |
| 22 | 40 | * Singleton instance. |
| 23 | 41 | * |
| 24 | 42 | * @var self|null |
| 25 | 43 | */ |
| @@ -51,11 +69,16 @@ | ||
| 51 | 69 | * @param int $term_id Term ID. |
| 52 | 70 | * @param string $taxonomy Taxonomy slug (unused by the plugins but kept |
| 53 | 71 | * in the signature for future use / logging). |
| 54 | 72 | * @param array $data Canonical key/value pairs. |
| 73 | + * @param bool $otto_generated Whether the values came from OTTO. A single | |
| 74 | + * call must not mix OTTO-generated and manually | |
| 75 | + * entered fields: the flag applies to every entry | |
| 76 | + * in $data, so a mixed call could only gate all of | |
| 77 | + * them or none. Split the call instead. | |
| 55 | 78 | * @return array Results keyed by plugin: ['yoast'=>bool,'rankmath'=>bool,'aioseo'=>bool]. |
| 56 | 79 | */ |
| 57 | - public function sync_term($term_id, $taxonomy, array $data) { | |
| 80 | + public function sync_term($term_id, $taxonomy, array $data, $otto_generated = false) { | |
| 58 | 81 | // Explicit static recursion guard — prevents re-entrant calls for the same |
| 59 | 82 | // term (e.g. if a term_meta hook triggers another sync_term() call). |
| 60 | 83 | static $syncing = []; |
| 61 | 84 | if (!empty($syncing[$term_id])) { |
| @@ -69,8 +92,37 @@ | ||
| 69 | 92 | if ($term_id <= 0 || empty($data)) { |
| 70 | 93 | return $results; |
| 71 | 94 | } |
| 72 | 95 | |
| 96 | + // OTTO-generated values may reach third-party storage only while | |
| 97 | + // their matching Persistence setting is enabled. The class_exists | |
| 98 | + // guard is fail-closed so a partial install cannot authorise a | |
| 99 | + // permanent write. Manual term updates use the default false flag | |
| 100 | + // and remain ungated, matching the post-side bridge semantics. | |
| 101 | + if ($otto_generated) { | |
| 102 | + foreach (self::OTTO_PERSISTENCE_KEYS as $field => $setting) { | |
| 103 | + if (array_key_exists($field, $data) | |
| 104 | + && (!class_exists('Metasync_Otto_Persistence_Settings') | |
| 105 | + || !Metasync_Otto_Persistence_Settings::should_persist($setting))) { | |
| 106 | + unset($data[$field]); | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + if (empty($data)) { | |
| 111 | + return $results; | |
| 112 | + } | |
| 113 | + } | |
| 114 | + | |
| 115 | + // Never mirror a corrupted or non-URL canonical into third-party | |
| 116 | + // storage — a nested-array value cast with (string) becomes the | |
| 117 | + // literal "Array" and propagates into Yoast/RankMath/AIOSEO. | |
| 118 | + if (array_key_exists('canonical', $data)) { | |
| 119 | + $data['canonical'] = Metasync_Canonical_Sanitizer::sanitize($data['canonical']); | |
| 120 | + if ($data['canonical'] === '') { | |
| 121 | + unset($data['canonical']); | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 73 | 125 | if ($this->is_yoast_active()) { |
| 74 | 126 | $results['yoast'] = $this->sync_yoast((int) $term_id, $taxonomy, $data); |
| 75 | 127 | } |
| 76 | 128 | |
| @@ -138,8 +190,122 @@ | ||
| 138 | 190 | // Per-plugin sync |
| 139 | 191 | // ------------------------------------------------------------------ |
| 140 | 192 | |
| 141 | 193 | /** |
| 194 | + * Whether third-party SEO storage may be written at all. | |
| 195 | + * | |
| 196 | + * The site owner's consent switch. Terms are covered by it on exactly the | |
| 197 | + * same terms as posts — a category's Rank Math title is another plugin's | |
| 198 | + * data just as much as a page's is. | |
| 199 | + * | |
| 200 | + * @return bool | |
| 201 | + */ | |
| 202 | + private function third_party_writes_allowed() { | |
| 203 | + return class_exists('Metasync_Seo_Backup') && Metasync_Seo_Backup::is_enabled(); | |
| 204 | + } | |
| 205 | + | |
| 206 | + /** | |
| 207 | + * Write one third-party term-meta field, preserving what it held before. | |
| 208 | + * | |
| 209 | + * The backup row lives in term meta, under the same key naming posts use. | |
| 210 | + * Post meta and term meta are separate tables, so the two cannot collide | |
| 211 | + * and the helper stays object-type agnostic. | |
| 212 | + * | |
| 213 | + * @param int $term_id Term ID. | |
| 214 | + * @param string $key Third-party term meta key. | |
| 215 | + * @param mixed $value Value to write. | |
| 216 | + * @return bool True when the write happened. | |
| 217 | + */ | |
| 218 | + private function write_term_field($term_id, $key, $value) { | |
| 219 | + return class_exists('Metasync_Seo_Backup') | |
| 220 | + && Metasync_Seo_Backup::write_term_meta($term_id, $key, $value); | |
| 221 | + } | |
| 222 | + | |
| 223 | + /** | |
| 224 | + * Backup field name for one Yoast taxonomy-meta entry. | |
| 225 | + * | |
| 226 | + * Yoast's `wpseo_taxonomy_meta` option nests taxonomy → term ID → field, so | |
| 227 | + * the same term ID can hold a different original under each taxonomy it | |
| 228 | + * belongs to. The backup rows all live on the term, which has no such | |
| 229 | + * nesting, so the taxonomy has to be carried in the field name or the two | |
| 230 | + * originals collide and write-once keeps only the first. | |
| 231 | + * | |
| 232 | + * @param string $taxonomy Taxonomy slug. | |
| 233 | + * @param string $yoast_key Yoast field name, e.g. 'wpseo_title'. | |
| 234 | + * @return string | |
| 235 | + */ | |
| 236 | + private static function yoast_tax_backup_field($taxonomy, $yoast_key) { | |
| 237 | + return 'yoast_tax_' . $taxonomy . '_' . $yoast_key; | |
| 238 | + } | |
| 239 | + | |
| 240 | + /** | |
| 241 | + * Carry a backup written under the old, taxonomy-less key onto the new one. | |
| 242 | + * | |
| 243 | + * Earlier builds stored these as `yoast_tax_{field}`, with no taxonomy in | |
| 244 | + * the key. Sites that ran one of those hold real customer originals there. | |
| 245 | + * Left alone, the next sync finds nothing at the new key and backs up | |
| 246 | + * whatever is in the option now — which is the value the previous sync | |
| 247 | + * wrote. The original would still exist, under a key nothing reads, while | |
| 248 | + * the backup that a restore trusts would hold this plugin's own output. | |
| 249 | + * | |
| 250 | + * Copying it across first makes the new key win on its own merits: the | |
| 251 | + * legacy value is the older, truer one, and backups are write-once, so a | |
| 252 | + * later call cannot displace it. | |
| 253 | + * | |
| 254 | + * @param int $term_id Term ID. | |
| 255 | + * @param string $taxonomy Taxonomy slug. | |
| 256 | + * @param string $yoast_key Yoast field name. | |
| 257 | + */ | |
| 258 | + private static function adopt_legacy_yoast_tax_backup($term_id, $taxonomy, $yoast_key) { | |
| 259 | + $scoped = self::yoast_tax_backup_field($taxonomy, $yoast_key); | |
| 260 | + $legacy = 'yoast_tax_' . $yoast_key; | |
| 261 | + | |
| 262 | + $existing_scoped = Metasync_Seo_Backup::read_backup('term', $term_id, $scoped); | |
| 263 | + if (!empty($existing_scoped['exists'])) { | |
| 264 | + return; | |
| 265 | + } | |
| 266 | + | |
| 267 | + $legacy_backup = Metasync_Seo_Backup::read_backup('term', $term_id, $legacy); | |
| 268 | + if (empty($legacy_backup['exists'])) { | |
| 269 | + return; | |
| 270 | + } | |
| 271 | + | |
| 272 | + Metasync_Seo_Backup::record_marker('term', $term_id, $scoped, $legacy_backup['value']); | |
| 273 | + } | |
| 274 | + | |
| 275 | + /** | |
| 276 | + * The Yoast taxonomy-meta entry as it is actually stored, without defaults. | |
| 277 | + * | |
| 278 | + * `WPSEO_Taxonomy_Meta::get_term_meta()` returns | |
| 279 | + * `array_merge($defaults_per_term, $stored)`, so all twenty Yoast fields | |
| 280 | + * are always present and `array_key_exists()` can never tell a field the | |
| 281 | + * customer set from one they never touched. Yoast only ever stores the | |
| 282 | + * non-default values -- `validate_term_meta_data()` ends in | |
| 283 | + * `array_diff_assoc($clean, $defaults_per_term)` -- so the raw option is | |
| 284 | + * the only place that distinction survives, and it is exactly the | |
| 285 | + * distinction a restore needs to choose between deleting a key and | |
| 286 | + * blanking it. | |
| 287 | + * | |
| 288 | + * The merged view is still the right thing to build the write from, so | |
| 289 | + * this is used only to decide what to record as the original. | |
| 290 | + * | |
| 291 | + * @param int $term_id Term ID. | |
| 292 | + * @param string $taxonomy Taxonomy slug. | |
| 293 | + * @return array Stored entry, empty when the term has none. | |
| 294 | + */ | |
| 295 | + private static function yoast_stored_tax_meta($term_id, $taxonomy) { | |
| 296 | + $option = get_option('wpseo_taxonomy_meta', []); | |
| 297 | + | |
| 298 | + if (!is_array($option) | |
| 299 | + || !isset($option[$taxonomy][$term_id]) | |
| 300 | + || !is_array($option[$taxonomy][$term_id])) { | |
| 301 | + return []; | |
| 302 | + } | |
| 303 | + | |
| 304 | + return $option[$taxonomy][$term_id]; | |
| 305 | + } | |
| 306 | + | |
| 307 | + /** | |
| 142 | 308 | * Mirror canonical data into Yoast term storage. |
| 143 | 309 | * |
| 144 | 310 | * Yoast stores taxonomy term SEO data in the `wpseo_taxonomy_meta` option |
| 145 | 311 | * (wp_options), NOT in wp_termmeta. The `WPSEO_Taxonomy_Meta::set_value()` |
| @@ -147,11 +313,16 @@ | ||
| 147 | 313 | * |
| 148 | 314 | * @param int $term_id Term ID. |
| 149 | 315 | * @param string $taxonomy Taxonomy slug (required by Yoast API). |
| 150 | 316 | * @param array $data Canonical key/value pairs. |
| 151 | - * @return bool Always true once dispatch completes. | |
| 317 | + * @return bool True when the write happened; false when consent is withheld | |
| 318 | + * or an original could not be preserved. | |
| 152 | 319 | */ |
| 153 | 320 | private function sync_yoast($term_id, $taxonomy, array $data) { |
| 321 | + if (!$this->third_party_writes_allowed()) { | |
| 322 | + return false; | |
| 323 | + } | |
| 324 | + | |
| 154 | 325 | if (!class_exists('WPSEO_Taxonomy_Meta')) { |
| 155 | 326 | return false; |
| 156 | 327 | } |
| 157 | 328 | |
| @@ -186,8 +357,49 @@ | ||
| 186 | 357 | $existing = WPSEO_Taxonomy_Meta::get_term_meta($term_id, $taxonomy); |
| 187 | 358 | if (is_array($existing)) { |
| 188 | 359 | $meta_values = array_merge($existing, $meta_values); |
| 189 | 360 | } |
| 361 | + | |
| 362 | + // What Yoast really has on disk, for the backup only. The merged | |
| 363 | + // view above is the right basis for the write and the wrong one for | |
| 364 | + // deciding what was there before. | |
| 365 | + $stored = self::yoast_stored_tax_meta($term_id, $taxonomy); | |
| 366 | + | |
| 367 | + // Yoast keeps term SEO in the `wpseo_taxonomy_meta` option, not in | |
| 368 | + // term meta, so there is no term-meta row to preserve. Save each | |
| 369 | + // entry we are about to change onto the term itself, keyed by the | |
| 370 | + // taxonomy and the Yoast field name, so a restore can put the | |
| 371 | + // option entry back. | |
| 372 | + // | |
| 373 | + // The taxonomy belongs in the key because the option is keyed by | |
| 374 | + // taxonomy first and term ID second: one term ID can hold a | |
| 375 | + // separate entry under each taxonomy it appears in. Leave it out | |
| 376 | + // and the first taxonomy synced claims the write-once row, and the | |
| 377 | + // second taxonomy's original is overwritten with nothing saved. | |
| 378 | + // | |
| 379 | + // set_values() rewrites the whole option entry at once, so a single | |
| 380 | + // unsaved field cannot be skipped in isolation -- if any original | |
| 381 | + // fails to record, the write is abandoned entirely rather than | |
| 382 | + // destroying a value with nothing to restore it from. | |
| 383 | + $originals_saved = class_exists('Metasync_Seo_Backup'); | |
| 384 | + if ($originals_saved) { | |
| 385 | + foreach ($meta_values as $yoast_key => $yoast_value) { | |
| 386 | + self::adopt_legacy_yoast_tax_backup($term_id, $taxonomy, $yoast_key); | |
| 387 | + | |
| 388 | + $originals_saved = Metasync_Seo_Backup::backup_before_overwrite( | |
| 389 | + 'term', | |
| 390 | + $term_id, | |
| 391 | + self::yoast_tax_backup_field($taxonomy, $yoast_key), | |
| 392 | + $yoast_value, | |
| 393 | + array_key_exists($yoast_key, $stored) ? $stored[$yoast_key] : null | |
| 394 | + ) && $originals_saved; | |
| 395 | + } | |
| 396 | + } | |
| 397 | + | |
| 398 | + if (!$originals_saved) { | |
| 399 | + return false; | |
| 400 | + } | |
| 401 | + | |
| 190 | 402 | WPSEO_Taxonomy_Meta::set_values($term_id, $taxonomy, $meta_values); |
| 191 | 403 | |
| 192 | 404 | // Rebuild the Yoast indexable so the frontend and sitemaps render |
| 193 | 405 | // the updated values. Yoast's Indexable_Term_Watcher listens on |
| @@ -207,28 +419,32 @@ | ||
| 207 | 419 | * @param array $data Canonical key/value pairs. |
| 208 | 420 | * @return bool Always true once dispatch completes. |
| 209 | 421 | */ |
| 210 | 422 | private function sync_rankmath($term_id, array $data) { |
| 423 | + if (!$this->third_party_writes_allowed()) { | |
| 424 | + return false; | |
| 425 | + } | |
| 426 | + | |
| 211 | 427 | if (array_key_exists('title', $data) && $data['title'] !== '') { |
| 212 | - update_term_meta($term_id, 'rank_math_title', (string) $data['title']); | |
| 428 | + $this->write_term_field($term_id, 'rank_math_title', (string) $data['title']); | |
| 213 | 429 | } |
| 214 | 430 | if (array_key_exists('desc', $data) && $data['desc'] !== '') { |
| 215 | - update_term_meta($term_id, 'rank_math_description', (string) $data['desc']); | |
| 431 | + $this->write_term_field($term_id, 'rank_math_description', (string) $data['desc']); | |
| 216 | 432 | } |
| 217 | 433 | if (array_key_exists('og_title', $data) && $data['og_title'] !== '') { |
| 218 | - update_term_meta($term_id, 'rank_math_facebook_title', (string) $data['og_title']); | |
| 434 | + $this->write_term_field($term_id, 'rank_math_facebook_title', (string) $data['og_title']); | |
| 219 | 435 | } |
| 220 | 436 | if (array_key_exists('og_desc', $data) && $data['og_desc'] !== '') { |
| 221 | - update_term_meta($term_id, 'rank_math_facebook_description', (string) $data['og_desc']); | |
| 437 | + $this->write_term_field($term_id, 'rank_math_facebook_description', (string) $data['og_desc']); | |
| 222 | 438 | } |
| 223 | 439 | if (array_key_exists('canonical', $data) && $data['canonical'] !== '') { |
| 224 | - update_term_meta($term_id, 'rank_math_canonical_url', (string) $data['canonical']); | |
| 440 | + $this->write_term_field($term_id, 'rank_math_canonical_url', (string) $data['canonical']); | |
| 225 | 441 | } |
| 226 | 442 | if (array_key_exists('noindex', $data)) { |
| 227 | 443 | // Rank Math stores robots directives as a serialized PHP array (e.g. ['noindex']). |
| 228 | 444 | $is_noindex = ($data['noindex'] === 'noindex' || $data['noindex'] === true || $data['noindex'] === 1 || $data['noindex'] === '1'); |
| 229 | 445 | $robots = $is_noindex ? ['noindex'] : []; |
| 230 | - update_term_meta($term_id, 'rank_math_robots', $robots); | |
| 446 | + $this->write_term_field($term_id, 'rank_math_robots', $robots); | |
| 231 | 447 | } |
| 232 | 448 | |
| 233 | 449 | return true; |
| 234 | 450 | } |
| @@ -233,8 +449,118 @@ | ||
| 233 | 449 | return true; |
| 234 | 450 | } |
| 235 | 451 | |
| 236 | 452 | /** |
| 453 | + * Preserve the AIOSEO term columns a sync is about to overwrite. | |
| 454 | + * | |
| 455 | + * AIOSEO keeps term SEO data in its own `aioseo_terms` table, so there is | |
| 456 | + * no term-meta row to save and no field a restore could delete. The | |
| 457 | + * per-column originals and whether the row pre-existed are recorded as term | |
| 458 | + * meta on the term itself, so a restore can put the original columns back | |
| 459 | + * without touching the rest of the user's row, and can delete outright a | |
| 460 | + * row that only exists because we created it. | |
| 461 | + * | |
| 462 | + * @param int $term_id Term ID. | |
| 463 | + * @param string $table Fully prefixed AIOSEO term table name. | |
| 464 | + * @param array $row Columns and values about to be written. | |
| 465 | + * @param bool $row_existed Whether AIOSEO already had a row for this term. | |
| 466 | + * @param array $created Out-param, filled with the backup fields this | |
| 467 | + * call created, so a failed write can withdraw | |
| 468 | + * exactly its own rows and no one else's. | |
| 469 | + * @return bool True when every original was preserved and the caller may write. | |
| 470 | + */ | |
| 471 | + private function backup_aioseo_columns($term_id, $table, array $row, $row_existed, array &$created) { | |
| 472 | + $created = []; | |
| 473 | + | |
| 474 | + if (!class_exists('Metasync_Seo_Backup')) { | |
| 475 | + return false; | |
| 476 | + } | |
| 477 | + | |
| 478 | + global $wpdb; | |
| 479 | + | |
| 480 | + // Whether the row pre-existed is what a restore uses to choose between | |
| 481 | + // putting the original columns back and deleting a row that only exists | |
| 482 | + // because we made it. A marker that will not record is as disqualifying | |
| 483 | + // as a column that will not. | |
| 484 | + if (!Metasync_Seo_Backup::record_marker( | |
| 485 | + 'term', | |
| 486 | + $term_id, | |
| 487 | + 'aioseo_row_existed', | |
| 488 | + $row_existed ? '1' : '0', | |
| 489 | + $marker_created | |
| 490 | + )) { | |
| 491 | + return false; | |
| 492 | + } | |
| 493 | + | |
| 494 | + if ($marker_created) { | |
| 495 | + $created[] = 'aioseo_row_existed'; | |
| 496 | + } | |
| 497 | + | |
| 498 | + $columns = array_diff(array_keys($row), ['updated', 'created', 'term_id']); | |
| 499 | + if (empty($columns)) { | |
| 500 | + return true; | |
| 501 | + } | |
| 502 | + | |
| 503 | + $current = null; | |
| 504 | + if ($row_existed) { | |
| 505 | + $select = '`' . implode('`, `', array_map('esc_sql', $columns)) . '`'; | |
| 506 | + // Column list is esc_sql()'d above; the table is a fixed literal. | |
| 507 | + $current = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors term meta into third-party plugin storage (Yoast/AIOSEO tables) — no WordPress API for their schemas | |
| 508 | + $wpdb->prepare( | |
| 509 | + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column list esc_sql()'d above, table is a fixed literal | |
| 510 | + "SELECT {$select} FROM {$wpdb->prefix}aioseo_terms WHERE term_id = %d", | |
| 511 | + $term_id | |
| 512 | + ), | |
| 513 | + ARRAY_A | |
| 514 | + ); | |
| 515 | + | |
| 516 | + // The row was there a moment ago, so a null answer now is a failed | |
| 517 | + // read, not an empty row. Recording it as "every column was NULL" | |
| 518 | + // would tell a later restore to delete values it should put back, | |
| 519 | + // which is the exact loss this layer exists to prevent. No write is | |
| 520 | + // happening, so the marker recorded above has to go too — a marker | |
| 521 | + // left describing a write that never ran is a stale story. | |
| 522 | + if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) { | |
| 523 | + Metasync_Seo_Backup::discard_backups('term', $term_id, $created); | |
| 524 | + return false; | |
| 525 | + } | |
| 526 | + } | |
| 527 | + | |
| 528 | + foreach ($columns as $column) { | |
| 529 | + // A missing row and a NULL column mean the same thing to a restore: | |
| 530 | + // there was no value here, so put nothing back. | |
| 531 | + $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null; | |
| 532 | + | |
| 533 | + $field = 'aioseo_' . $column; | |
| 534 | + | |
| 535 | + // One unsaved column is enough to refuse the whole write: a half-original, | |
| 536 | + // half-OTTO row is something no restore can unpick. The refusal also | |
| 537 | + // means the caller writes nothing, so withdraw the marker and the | |
| 538 | + // columns recorded so far — the same rule as the failed-insert path | |
| 539 | + // in the caller. Left behind, a row_existed='0' marker would let a | |
| 540 | + // restore delete a row the customer creates later, and a NULL | |
| 541 | + // column backup would blank a real value in it. | |
| 542 | + if (!Metasync_Seo_Backup::backup_before_overwrite( | |
| 543 | + 'term', | |
| 544 | + $term_id, | |
| 545 | + $field, | |
| 546 | + $row[$column], | |
| 547 | + $current_value, | |
| 548 | + $column_created | |
| 549 | + )) { | |
| 550 | + Metasync_Seo_Backup::discard_backups('term', $term_id, $created); | |
| 551 | + return false; | |
| 552 | + } | |
| 553 | + | |
| 554 | + if ($column_created) { | |
| 555 | + $created[] = $field; | |
| 556 | + } | |
| 557 | + } | |
| 558 | + | |
| 559 | + return true; | |
| 560 | + } | |
| 561 | + | |
| 562 | + /** | |
| 237 | 563 | * Mirror canonical data into the AIOSEO `wp_aioseo_terms` custom table. |
| 238 | 564 | * |
| 239 | 565 | * @param int $term_id Term ID. |
| 240 | 566 | * @param array $data Canonical key/value pairs. |
| @@ -243,12 +569,16 @@ | ||
| 243 | 569 | */ |
| 244 | 570 | private function sync_aioseo($term_id, array $data) { |
| 245 | 571 | global $wpdb; |
| 246 | 572 | |
| 573 | + if (!$this->third_party_writes_allowed()) { | |
| 574 | + return false; | |
| 575 | + } | |
| 576 | + | |
| 247 | 577 | $table = $wpdb->prefix . 'aioseo_terms'; |
| 248 | 578 | |
| 249 | 579 | // Bail if the AIOSEO term table does not exist (plugin not initialised). |
| 250 | - $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); | |
| 580 | + $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors term meta into third-party plugin storage (Yoast/AIOSEO tables) — no WordPress API for their schemas | |
| 251 | 581 | if ($table_exists !== $table) { |
| 252 | 582 | return false; |
| 253 | 583 | } |
| 254 | 584 | |
| @@ -281,18 +611,39 @@ | ||
| 281 | 611 | } |
| 282 | 612 | |
| 283 | 613 | $row['updated'] = current_time('mysql'); |
| 284 | 614 | |
| 285 | - $existing_id = $wpdb->get_var($wpdb->prepare( | |
| 286 | - "SELECT id FROM {$table} WHERE term_id = %d", | |
| 615 | + $existing_id = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors term meta into third-party plugin storage (Yoast/AIOSEO tables) — no WordPress API for their schemas | |
| 616 | + "SELECT id FROM {$wpdb->prefix}aioseo_terms WHERE term_id = %d", | |
| 287 | 617 | $term_id |
| 288 | 618 | )); |
| 289 | 619 | |
| 620 | + // An unreadable probe cannot be treated as "no row". It would commit a | |
| 621 | + // write-once row_existed='0' for a row AIOSEO really has -- which a | |
| 622 | + // restore reads as licence to delete it -- and send an INSERT at a row | |
| 623 | + // that already exists. Leave AIOSEO's row alone and let the next sync | |
| 624 | + // record the truth. | |
| 625 | + if (!Metasync_Seo_Backup::db_read_succeeded()) { | |
| 626 | + return false; | |
| 627 | + } | |
| 628 | + | |
| 629 | + // No original saved means no write. Overwriting anyway is the data loss | |
| 630 | + // this whole layer exists to prevent. | |
| 290 | 631 | if ($existing_id) { |
| 291 | - $updated = $wpdb->update($table, $row, ['term_id' => $term_id]); | |
| 632 | + $backups_created = []; | |
| 633 | + if (!$this->backup_aioseo_columns($term_id, $table, $row, true, $backups_created)) { | |
| 634 | + return false; | |
| 635 | + } | |
| 636 | + | |
| 637 | + $updated = $wpdb->update($table, $row, ['term_id' => $term_id]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors term meta into third-party plugin storage (Yoast/AIOSEO tables) — no WordPress API for their schemas | |
| 292 | 638 | return $updated !== false; |
| 293 | 639 | } |
| 294 | 640 | |
| 641 | + $backups_created = []; | |
| 642 | + if (!$this->backup_aioseo_columns($term_id, $table, $row, false, $backups_created)) { | |
| 643 | + return false; | |
| 644 | + } | |
| 645 | + | |
| 295 | 646 | // New row: include term_id, timestamps, and NOT NULL robot defaults. |
| 296 | 647 | $row['term_id'] = $term_id; |
| 297 | 648 | $row['created'] = current_time('mysql'); |
| 298 | 649 | |
| @@ -311,8 +662,20 @@ | ||
| 311 | 662 | ]; |
| 312 | 663 | // Merge defaults first, then $row on top so our noindex value wins. |
| 313 | 664 | $row = array_merge($robot_defaults, $row); |
| 314 | 665 | |
| 315 | - $inserted = $wpdb->insert($table, $row); | |
| 316 | - return $inserted !== false; | |
| 666 | + $inserted = $wpdb->insert($table, $row); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- mirrors term meta into third-party plugin storage (Yoast/AIOSEO tables) — no WordPress API for their schemas | |
| 667 | + | |
| 668 | + // The row_existed='0' marker was recorded before the insert, because a | |
| 669 | + // marker that will not save has to be able to veto the write. A failed | |
| 670 | + // insert leaves no row of ours, and a marker still claiming one would | |
| 671 | + // let a restore delete a row the customer creates later. The per-column | |
| 672 | + // backups beside it go too, or a column captured as NULL for a row that | |
| 673 | + // never existed would blank a real value the customer later puts in one. | |
| 674 | + if ($inserted === false) { | |
| 675 | + Metasync_Seo_Backup::discard_backups('term', $term_id, $backups_created); | |
| 676 | + return false; | |
| 677 | + } | |
| 678 | + | |
| 679 | + return true; | |
| 317 | 680 | } |
| 318 | 681 | } |