PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.0
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.0
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / includes / class-metasync-term-plugin-sync.php

class-metasync-term-plugin-sync.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.0, at includes/class-metasync-term-plugin-sync.php

677 lines 27.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Term-Level SEO Plugin Sync
4 *
5 * Mirrors MetaSync term meta (`_metasync_*`) into the active third-party
6 * SEO plugins' term storage (Yoast, Rank Math, AIOSEO) so that category,
7 * tag, and custom-taxonomy archive pages render MetaSync-managed values
8 * regardless of which plugin is actually rendering the archive.
9 *
10 * @package MetaSync
11 * @subpackage MetaSync/includes
12 * @since 2.8.24
13 */
14
15 if (!defined('ABSPATH')) {
16 exit;
17 }
18
19 class Metasync_Term_Plugin_Sync {
20
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 /**
40 * Singleton instance.
41 *
42 * @var self|null
43 */
44 private static $instance = null;
45
46 /**
47 * Get singleton instance.
48 *
49 * @return self
50 */
51 public static function get_instance() {
52 if (self::$instance === null) {
53 self::$instance = new self();
54 }
55 return self::$instance;
56 }
57
58 /**
59 * Propagate MetaSync term meta to every active SEO plugin.
60 *
61 * Canonical $data keys recognised by this method:
62 * title, desc, og_title, og_desc, og_image,
63 * twitter_title, twitter_desc, canonical, noindex
64 *
65 * Callers may include any subset; empty values are skipped by each
66 * plugin-specific sync method so a partial update does not clobber
67 * fields that were not passed in.
68 *
69 * @param int $term_id Term ID.
70 * @param string $taxonomy Taxonomy slug (unused by the plugins but kept
71 * in the signature for future use / logging).
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.
78 * @return array Results keyed by plugin: ['yoast'=>bool,'rankmath'=>bool,'aioseo'=>bool].
79 */
80 public function sync_term($term_id, $taxonomy, array $data, $otto_generated = false) {
81 // Explicit static recursion guard — prevents re-entrant calls for the same
82 // term (e.g. if a term_meta hook triggers another sync_term() call).
83 static $syncing = [];
84 if (!empty($syncing[$term_id])) {
85 return [];
86 }
87 $syncing[$term_id] = true;
88
89 try {
90 $results = [];
91
92 if ($term_id <= 0 || empty($data)) {
93 return $results;
94 }
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
125 if ($this->is_yoast_active()) {
126 $results['yoast'] = $this->sync_yoast((int) $term_id, $taxonomy, $data);
127 }
128
129 if ($this->is_rankmath_active()) {
130 $results['rankmath'] = $this->sync_rankmath((int) $term_id, $data);
131 }
132
133 if ($this->is_aioseo_active()) {
134 $results['aioseo'] = $this->sync_aioseo((int) $term_id, $data);
135 }
136
137 return $results;
138 } finally {
139 unset($syncing[$term_id]);
140 }
141 }
142
143 // ------------------------------------------------------------------
144 // Plugin detectors
145 // ------------------------------------------------------------------
146
147 /**
148 * Check whether Yoast SEO (free or premium) is active.
149 *
150 * @return bool
151 */
152 private function is_yoast_active() {
153 $this->ensure_plugin_api();
154 return is_plugin_active('wordpress-seo/wp-seo.php')
155 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php');
156 }
157
158 /**
159 * Check whether Rank Math SEO is active.
160 *
161 * @return bool
162 */
163 private function is_rankmath_active() {
164 $this->ensure_plugin_api();
165 return is_plugin_active('seo-by-rank-math/rank-math.php')
166 || is_plugin_active('seo-by-rankmath/rank-math.php');
167 }
168
169 /**
170 * Check whether AIOSEO (free or pro) is active.
171 *
172 * @return bool
173 */
174 private function is_aioseo_active() {
175 $this->ensure_plugin_api();
176 return is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php')
177 || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php');
178 }
179
180 /**
181 * Ensure is_plugin_active() is loaded on the frontend.
182 */
183 private function ensure_plugin_api() {
184 if (!function_exists('is_plugin_active')) {
185 require_once ABSPATH . 'wp-admin/includes/plugin.php';
186 }
187 }
188
189 // ------------------------------------------------------------------
190 // Per-plugin sync
191 // ------------------------------------------------------------------
192
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 /**
308 * Mirror canonical data into Yoast term storage.
309 *
310 * Yoast stores taxonomy term SEO data in the `wpseo_taxonomy_meta` option
311 * (wp_options), NOT in wp_termmeta. The `WPSEO_Taxonomy_Meta::set_value()`
312 * API is the correct way to write to this storage.
313 *
314 * @param int $term_id Term ID.
315 * @param string $taxonomy Taxonomy slug (required by Yoast API).
316 * @param array $data Canonical key/value pairs.
317 * @return bool True when the write happened; false when consent is withheld
318 * or an original could not be preserved.
319 */
320 private function sync_yoast($term_id, $taxonomy, array $data) {
321 if (!$this->third_party_writes_allowed()) {
322 return false;
323 }
324
325 if (!class_exists('WPSEO_Taxonomy_Meta')) {
326 return false;
327 }
328
329 $field_map = [
330 'title' => 'wpseo_title',
331 'desc' => 'wpseo_desc',
332 'og_title' => 'wpseo_opengraph-title',
333 'og_desc' => 'wpseo_opengraph-description',
334 'og_image' => 'wpseo_opengraph-image',
335 'twitter_title' => 'wpseo_twitter-title',
336 'twitter_desc' => 'wpseo_twitter-description',
337 'canonical' => 'wpseo_canonical',
338 ];
339
340 $meta_values = [];
341
342 foreach ($field_map as $canonical_key => $yoast_key) {
343 if (array_key_exists($canonical_key, $data) && $data[$canonical_key] !== '') {
344 $meta_values[$yoast_key] = (string) $data[$canonical_key];
345 }
346 }
347
348 if (array_key_exists('noindex', $data)) {
349 $is_noindex = ($data['noindex'] === 'noindex' || $data['noindex'] === true || $data['noindex'] === 1 || $data['noindex'] === '1');
350 $meta_values['wpseo_noindex'] = $is_noindex ? 'noindex' : 'default';
351 }
352
353 if (!empty($meta_values)) {
354 // Yoast's set_values() replaces the entire term entry. Merge our
355 // new values with the existing stored values so we don't clobber
356 // previously synced fields.
357 $existing = WPSEO_Taxonomy_Meta::get_term_meta($term_id, $taxonomy);
358 if (is_array($existing)) {
359 $meta_values = array_merge($existing, $meta_values);
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
402 WPSEO_Taxonomy_Meta::set_values($term_id, $taxonomy, $meta_values);
403
404 // Rebuild the Yoast indexable so the frontend and sitemaps render
405 // the updated values. Yoast's Indexable_Term_Watcher listens on
406 // `edited_term`; we fire it to trigger the rebuild.
407 $term_obj = get_term($term_id, $taxonomy);
408 $tt_id = ($term_obj && !is_wp_error($term_obj)) ? (int) $term_obj->term_taxonomy_id : 0;
409 do_action('edited_term', $term_id, $tt_id, $taxonomy);
410 }
411
412 return true;
413 }
414
415 /**
416 * Mirror canonical data into Rank Math term meta (wp_termmeta).
417 *
418 * @param int $term_id Term ID.
419 * @param array $data Canonical key/value pairs.
420 * @return bool Always true once dispatch completes.
421 */
422 private function sync_rankmath($term_id, array $data) {
423 if (!$this->third_party_writes_allowed()) {
424 return false;
425 }
426
427 if (array_key_exists('title', $data) && $data['title'] !== '') {
428 $this->write_term_field($term_id, 'rank_math_title', (string) $data['title']);
429 }
430 if (array_key_exists('desc', $data) && $data['desc'] !== '') {
431 $this->write_term_field($term_id, 'rank_math_description', (string) $data['desc']);
432 }
433 if (array_key_exists('og_title', $data) && $data['og_title'] !== '') {
434 $this->write_term_field($term_id, 'rank_math_facebook_title', (string) $data['og_title']);
435 }
436 if (array_key_exists('og_desc', $data) && $data['og_desc'] !== '') {
437 $this->write_term_field($term_id, 'rank_math_facebook_description', (string) $data['og_desc']);
438 }
439 if (array_key_exists('canonical', $data) && $data['canonical'] !== '') {
440 $this->write_term_field($term_id, 'rank_math_canonical_url', (string) $data['canonical']);
441 }
442 if (array_key_exists('noindex', $data)) {
443 // Rank Math stores robots directives as a serialized PHP array (e.g. ['noindex']).
444 $is_noindex = ($data['noindex'] === 'noindex' || $data['noindex'] === true || $data['noindex'] === 1 || $data['noindex'] === '1');
445 $robots = $is_noindex ? ['noindex'] : [];
446 $this->write_term_field($term_id, 'rank_math_robots', $robots);
447 }
448
449 return true;
450 }
451
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 $current = $wpdb->get_row(
507 $wpdb->prepare("SELECT {$select} FROM {$table} WHERE term_id = %d", $term_id),
508 ARRAY_A
509 );
510
511 // The row was there a moment ago, so a null answer now is a failed
512 // read, not an empty row. Recording it as "every column was NULL"
513 // would tell a later restore to delete values it should put back,
514 // which is the exact loss this layer exists to prevent. No write is
515 // happening, so the marker recorded above has to go too — a marker
516 // left describing a write that never ran is a stale story.
517 if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) {
518 Metasync_Seo_Backup::discard_backups('term', $term_id, $created);
519 return false;
520 }
521 }
522
523 foreach ($columns as $column) {
524 // A missing row and a NULL column mean the same thing to a restore:
525 // there was no value here, so put nothing back.
526 $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null;
527
528 $field = 'aioseo_' . $column;
529
530 // One unsaved column is enough to refuse the whole write: a half-original,
531 // half-OTTO row is something no restore can unpick. The refusal also
532 // means the caller writes nothing, so withdraw the marker and the
533 // columns recorded so far — the same rule as the failed-insert path
534 // in the caller. Left behind, a row_existed='0' marker would let a
535 // restore delete a row the customer creates later, and a NULL
536 // column backup would blank a real value in it.
537 if (!Metasync_Seo_Backup::backup_before_overwrite(
538 'term',
539 $term_id,
540 $field,
541 $row[$column],
542 $current_value,
543 $column_created
544 )) {
545 Metasync_Seo_Backup::discard_backups('term', $term_id, $created);
546 return false;
547 }
548
549 if ($column_created) {
550 $created[] = $field;
551 }
552 }
553
554 return true;
555 }
556
557 /**
558 * Mirror canonical data into the AIOSEO `wp_aioseo_terms` custom table.
559 *
560 * @param int $term_id Term ID.
561 * @param array $data Canonical key/value pairs.
562 * @return bool True when the row was written, false when the table is
563 * missing or the write failed.
564 */
565 private function sync_aioseo($term_id, array $data) {
566 global $wpdb;
567
568 if (!$this->third_party_writes_allowed()) {
569 return false;
570 }
571
572 $table = $wpdb->prefix . 'aioseo_terms';
573
574 // Bail if the AIOSEO term table does not exist (plugin not initialised).
575 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
576 if ($table_exists !== $table) {
577 return false;
578 }
579
580 $row = [];
581 if (array_key_exists('title', $data) && $data['title'] !== '') {
582 $row['title'] = (string) $data['title'];
583 }
584 if (array_key_exists('desc', $data) && $data['desc'] !== '') {
585 $row['description'] = (string) $data['desc'];
586 }
587 if (array_key_exists('og_title', $data) && $data['og_title'] !== '') {
588 $row['og_title'] = (string) $data['og_title'];
589 }
590 if (array_key_exists('og_desc', $data) && $data['og_desc'] !== '') {
591 $row['og_description'] = (string) $data['og_desc'];
592 }
593 if (array_key_exists('canonical', $data) && $data['canonical'] !== '') {
594 $row['canonical_url'] = (string) $data['canonical'];
595 }
596 if (array_key_exists('noindex', $data)) {
597 $is_noindex = ($data['noindex'] === 'noindex' || $data['noindex'] === true || $data['noindex'] === 1 || $data['noindex'] === '1');
598 $row['robots_noindex'] = $is_noindex ? 1 : 0;
599 // When explicitly setting noindex, disable AIOSEO's global-defaults
600 // fallback so the explicit value takes effect.
601 $row['robots_default'] = 0;
602 }
603
604 if (empty($row)) {
605 return false;
606 }
607
608 $row['updated'] = current_time('mysql');
609
610 $existing_id = $wpdb->get_var($wpdb->prepare(
611 "SELECT id FROM {$table} WHERE term_id = %d",
612 $term_id
613 ));
614
615 // An unreadable probe cannot be treated as "no row". It would commit a
616 // write-once row_existed='0' for a row AIOSEO really has -- which a
617 // restore reads as licence to delete it -- and send an INSERT at a row
618 // that already exists. Leave AIOSEO's row alone and let the next sync
619 // record the truth.
620 if (!Metasync_Seo_Backup::db_read_succeeded()) {
621 return false;
622 }
623
624 // No original saved means no write. Overwriting anyway is the data loss
625 // this whole layer exists to prevent.
626 if ($existing_id) {
627 $backups_created = [];
628 if (!$this->backup_aioseo_columns($term_id, $table, $row, true, $backups_created)) {
629 return false;
630 }
631
632 $updated = $wpdb->update($table, $row, ['term_id' => $term_id]);
633 return $updated !== false;
634 }
635
636 $backups_created = [];
637 if (!$this->backup_aioseo_columns($term_id, $table, $row, false, $backups_created)) {
638 return false;
639 }
640
641 // New row: include term_id, timestamps, and NOT NULL robot defaults.
642 $row['term_id'] = $term_id;
643 $row['created'] = current_time('mysql');
644
645 // AIOSEO's robots_* columns are NOT NULL with no DB default.
646 // Use robots_default=1 so AIOSEO falls back to global settings,
647 // then only override robots_noindex when explicitly set above.
648 $robot_defaults = [
649 'robots_default' => isset($row['robots_noindex']) ? 0 : 1,
650 'robots_noindex' => 0,
651 'robots_noarchive' => 0,
652 'robots_nosnippet' => 0,
653 'robots_nofollow' => 0,
654 'robots_noimageindex' => 0,
655 'robots_noodp' => 0,
656 'robots_notranslate' => 0,
657 ];
658 // Merge defaults first, then $row on top so our noindex value wins.
659 $row = array_merge($robot_defaults, $row);
660
661 $inserted = $wpdb->insert($table, $row);
662
663 // The row_existed='0' marker was recorded before the insert, because a
664 // marker that will not save has to be able to veto the write. A failed
665 // insert leaves no row of ours, and a marker still claiming one would
666 // let a restore delete a row the customer creates later. The per-column
667 // backups beside it go too, or a column captured as NULL for a row that
668 // never existed would blank a real value the customer later puts in one.
669 if ($inserted === false) {
670 Metasync_Seo_Backup::discard_backups('term', $term_id, $backups_created);
671 return false;
672 }
673
674 return true;
675 }
676 }
677