PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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-seo-backup.php

class-metasync-seo-backup.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at includes/class-metasync-seo-backup.php

736 lines 25.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Consent gate and write-once backup for third-party SEO plugin storage.
4 *
5 * Copying a MetaSync value into Yoast / Rank Math / AIOSEO storage is a
6 * permanent write into another plugin's data. Two things were missing from it:
7 *
8 * 1. The site owner never agreed to it. `is_enabled()` is that agreement —
9 * a single opt-in switch that every third-party writer consults. It is
10 * off until someone turns it on, so an upgrade never starts writing into
11 * another plugin on its own.
12 * 2. The value being overwritten was gone forever. `backup_before_overwrite()`
13 * copies the pre-existing value onto the same post or term first, so it
14 * can be put back later.
15 *
16 * The backup is deliberately lazy: it happens at the moment of overwrite, on
17 * the one object being overwritten. A site-wide snapshot taken when the switch
18 * is flipped would be thousands of writes on one click and could time out
19 * half-finished.
20 *
21 * @package MetaSync
22 * @subpackage MetaSync/includes
23 */
24
25 if (!defined('ABSPATH')) {
26 exit;
27 }
28
29 class Metasync_Seo_Backup {
30
31 /**
32 * Prefix for the per-field backup rows.
33 *
34 * One row per object AND field — `_metasync_seo_backup_rank_math_title`,
35 * `_metasync_seo_backup_rank_math_description`, and so on.
36 *
37 * A single JSON blob per object would be cheaper, but write-once would then
38 * mean write-once *per object*: enable title syncing today and description
39 * syncing six months from now, and the blob already exists, so the
40 * description's original could never be captured. Per-field rows keep the
41 * write-once guarantee while letting a later-enabled field back up its own
42 * original.
43 *
44 * @var string
45 */
46 const BACKUP_META_PREFIX = '_metasync_seo_backup_';
47
48 /**
49 * Key of the consent switch inside the plugin's `general` settings array.
50 *
51 * @var string
52 */
53 const CONSENT_OPTION_KEY = 'sync_seo_values_to_other_plugins';
54
55 /**
56 * Sentinel for "read the current value yourself".
57 *
58 * A plain null default cannot be used: null is a meaningful current value
59 * here (it means the field is absent), so the sentinel has to be a value no
60 * caller could pass by accident.
61 *
62 * @var string
63 */
64 const CURRENT_UNKNOWN = "\0metasync_seo_backup_current_unknown\0";
65
66 /**
67 * Whether MetaSync values may be written into third-party SEO storage.
68 *
69 * Fail-closed on every uncertainty. A partially updated install where the
70 * settings layer is missing must not be read as consent — there is no
71 * setting that could authorise a permanent write into another plugin.
72 *
73 * @return bool
74 */
75 public static function is_enabled() {
76 if (!class_exists('Metasync')) {
77 return false;
78 }
79
80 $general = Metasync::get_option('general');
81 if (!is_array($general)) {
82 return false;
83 }
84
85 $value = $general[self::CONSENT_OPTION_KEY] ?? false;
86
87 // Checkboxes round-trip through the options array as the string 'true'
88 // on one save path and as a real boolean on the other, so both spellings
89 // have to count. Anything else — absent, '', '0', 'false' — is off.
90 return $value === true || $value === 'true' || $value === '1' || $value === 1;
91 }
92
93 /**
94 * Save the value a third-party field holds right now, then say whether the
95 * caller may overwrite it.
96 *
97 * Call this immediately before every write into Yoast / Rank Math / AIOSEO
98 * storage. "Immediately" is not stylistic. Within a single OTTO sync the
99 * post-meta bridge (`Metasync_Plugin_Sync::on_meta_updated()`) fires
100 * synchronously from the `_metasync_otto_*` write and reaches Rank Math
101 * before the direct write further down the same function does. A backup
102 * taken at the later site would record OTTO's own output as the "original".
103 * Because every writer calls this first and the record is write-once,
104 * whichever writer arrives first captures the true original and the rest
105 * cannot overwrite it.
106 *
107 * @param string $object_type 'post' or 'term'.
108 * @param int $object_id Post or term ID.
109 * @param string $field Third-party field being overwritten, used
110 * verbatim in the backup key.
111 * @param mixed $new_value Value about to be written.
112 * @param mixed $current_value Current stored value, where the caller
113 * already knows it or the value does not live
114 * in meta (an AIOSEO table column, say). Pass
115 * null to mean "the field is absent". Omit it
116 * and the current value is read from meta.
117 * @param bool $created Out-param, set to true only when this call
118 * created the backup row. An existing row is a
119 * legitimate write-once no-op and still returns
120 * true, so the return value alone cannot tell a
121 * caller whether the row is its own to withdraw.
122 * @return bool True when the caller may proceed with the write.
123 */
124 public static function backup_before_overwrite($object_type, $object_id, $field, $new_value, $current_value = self::CURRENT_UNKNOWN, &$created = null) {
125 $created = false;
126
127 if (!self::is_enabled()) {
128 return false;
129 }
130
131 $object_id = (int) $object_id;
132 if ($object_id <= 0 || $field === '') {
133 return false;
134 }
135
136 if (!in_array($object_type, ['post', 'term'], true)) {
137 return false;
138 }
139
140 if ($current_value === self::CURRENT_UNKNOWN) {
141 $current_value = self::read_current_meta($object_type, $object_id, $field);
142 }
143
144 // Nothing is being overwritten, so there is nothing to preserve.
145 // Restoring an identical value would be a no-op anyway.
146 if (!self::values_differ($current_value, $new_value)) {
147 return true;
148 }
149
150 // A failed record() must never be read as permission to overwrite.
151 // add_*_meta() also returns false when the row already exists, which is a
152 // legitimate write-once no-op, so an existing backup counts as success.
153 // Anything else leaves the original unsaved and the write must not proceed.
154 if (!self::record($object_type, $object_id, $field, $current_value)) {
155 if (!self::backup_exists($object_type, $object_id, $field)) {
156 return false;
157 }
158 } else {
159 $created = true;
160 }
161
162 self::queue_change_log($object_type, $object_id, $field);
163
164 return true;
165 }
166
167 /**
168 * Back up and then write one third-party post meta field.
169 *
170 * The only place in the plugin that writes a Yoast / Rank Math / AIOSEO post
171 * meta key. Keeping it to one place is what makes "no third-party field is
172 * ever overwritten without consent and a saved original" a property that can
173 * be checked mechanically instead of trusted.
174 *
175 * @param int $post_id Post ID.
176 * @param string $key Third-party meta key.
177 * @param mixed $value Value to write.
178 * @return bool True when the field holds the intended value afterwards:
179 * written, or already in place. False when the write was
180 * refused or failed.
181 */
182 public static function write_post_meta($post_id, $key, $value) {
183 // Read the current value once and pass it in: backup_before_overwrite()
184 // needs it anyway, and this caller needs it too, to tell
185 // update_post_meta()'s "false because nothing changed" from a failure.
186 $current = self::read_current_meta('post', $post_id, $key);
187
188 if (!self::backup_before_overwrite('post', $post_id, $key, $value, $current)) {
189 return false;
190 }
191
192 // A value that is already in place is a success: update_post_meta()
193 // reports its no-op as false, and callers read false as "the third-party
194 // write failed" — skipping receipts and purges for a field that already
195 // holds exactly what we wanted.
196 if (!self::values_differ($current, $value)) {
197 return true;
198 }
199
200 // update_post_meta() unslashes what it is given, exactly as add_post_meta()
201 // does in record(). Slashing here rather than at each call site keeps one
202 // contract for every caller: pass the literal value you want stored. With
203 // the no-op handled above, a false here is a real failure, and reporting
204 // it stops callers stamping a sync receipt for a write that never landed.
205 return (bool) update_post_meta($post_id, $key, wp_slash($value));
206 }
207
208 /**
209 * Back up and then write one third-party term meta field.
210 *
211 * Term counterpart of write_post_meta().
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 field holds the intended value afterwards:
217 * written, or already in place. False when the write was
218 * refused or failed.
219 */
220 public static function write_term_meta($term_id, $key, $value) {
221 $current = self::read_current_meta('term', $term_id, $key);
222
223 if (!self::backup_before_overwrite('term', $term_id, $key, $value, $current)) {
224 return false;
225 }
226
227 // Same rule as write_post_meta(): an unchanged value is a success,
228 // because update_term_meta() reports its no-op as false.
229 if (!self::values_differ($current, $value)) {
230 return true;
231 }
232
233 // Slashed, and the return trusted, for the same reasons as write_post_meta().
234 return (bool) update_term_meta($term_id, $key, wp_slash($value));
235 }
236
237 /**
238 * Read a saved original.
239 *
240 * Returns an `exists` flag rather than just a value because absent and
241 * empty are different outcomes: a restore has to delete a field that was
242 * absent, not write an empty string into it.
243 *
244 * @param string $object_type 'post' or 'term'.
245 * @param int $object_id Post or term ID.
246 * @param string $field Third-party field name.
247 * @return array{exists:bool,value:mixed} `value` is null when the field was
248 * absent at backup time.
249 */
250 public static function read_backup($object_type, $object_id, $field) {
251 $absent = ['exists' => false, 'value' => null];
252
253 if (!in_array($object_type, ['post', 'term'], true)) {
254 return $absent;
255 }
256
257 $key = self::backup_key($field);
258 $object_id = (int) $object_id;
259
260 if (!metadata_exists($object_type, $object_id, $key)) {
261 return $absent;
262 }
263
264 $stored = ($object_type === 'post')
265 ? get_post_meta($object_id, $key, true)
266 : get_term_meta($object_id, $key, true);
267
268 return [
269 'exists' => true,
270 'value' => self::unwrap($stored),
271 ];
272 }
273
274 /**
275 * The meta key a field's original is stored under.
276 *
277 * @param string $field Third-party field name.
278 * @return string
279 */
280 public static function backup_key($field) {
281 return self::BACKUP_META_PREFIX . $field;
282 }
283
284 /**
285 * How many posts and terms hold at least one saved original.
286 *
287 * Distinct objects, not rows — the status line counts things the user can
288 * picture, and one post can hold a dozen field backups.
289 *
290 * @return array{posts:int,terms:int}
291 */
292 public static function count_objects_with_backups() {
293 global $wpdb;
294
295 $counts = ['posts' => 0, 'terms' => 0];
296
297 if (!isset($wpdb)) {
298 return $counts;
299 }
300
301 $like = $wpdb->esc_like(self::BACKUP_META_PREFIX) . '%';
302
303 $counts['posts'] = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- backup inventory — exact-row bulk counts/keys over arbitrary meta; no bulk WordPress API
304 "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key LIKE %s",
305 $like
306 ));
307
308 $counts['terms'] = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- backup inventory — exact-row bulk counts/keys over arbitrary meta; no bulk WordPress API
309 "SELECT COUNT(DISTINCT term_id) FROM {$wpdb->termmeta} WHERE meta_key LIKE %s",
310 $like
311 ));
312
313 return $counts;
314 }
315
316 /**
317 * Drop every saved original held by a post.
318 *
319 * Used when a post is duplicated: the copy must not inherit the original's
320 * pre-OTTO values, or restoring the copy would write another page's title
321 * onto it.
322 *
323 * The keys are dynamic, so they cannot be enumerated from a constant list
324 * the way the fixed OTTO keys are — the prefix has to be matched in SQL.
325 *
326 * @param int $post_id Post ID.
327 * @return int Number of rows removed.
328 */
329 public static function delete_all_for_post($post_id) {
330 global $wpdb;
331
332 $post_id = (int) $post_id;
333 if ($post_id <= 0 || !isset($wpdb)) {
334 return 0;
335 }
336
337 $keys = $wpdb->get_col($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- backup inventory — exact-row bulk counts/keys over arbitrary meta; no bulk WordPress API
338 "SELECT meta_key FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key LIKE %s",
339 $post_id,
340 $wpdb->esc_like(self::BACKUP_META_PREFIX) . '%'
341 ));
342
343 if (empty($keys)) {
344 return 0;
345 }
346
347 foreach ($keys as $key) {
348 delete_post_meta($post_id, $key);
349 }
350
351 return count($keys);
352 }
353
354 // ------------------------------------------------------------------
355 // Changes Log
356 // ------------------------------------------------------------------
357
358 /**
359 * Fields overwritten during this request, grouped by object.
360 *
361 * @var array<string,array{type:string,id:int,fields:array<int,string>}>
362 */
363 private static $pending_log = [];
364
365 /**
366 * Note an overwrite for the Changes Log.
367 *
368 * Collected per request and written out once at shutdown rather than one
369 * row per field. A single sync can touch a dozen fields on a post, and the
370 * history table keeps only the most recent 1000 records — a row per field
371 * would push a day's real history out of the log by lunchtime.
372 *
373 * @param string $object_type 'post' or 'term'.
374 * @param int $object_id Post or term ID.
375 * @param string $field Third-party field that was overwritten.
376 */
377 private static function queue_change_log($object_type, $object_id, $field) {
378 $bucket = $object_type . ':' . $object_id;
379
380 if (!isset(self::$pending_log[$bucket])) {
381 self::$pending_log[$bucket] = [
382 'type' => $object_type,
383 'id' => (int) $object_id,
384 'fields' => [],
385 ];
386
387 if (function_exists('add_action') && count(self::$pending_log) === 1) {
388 add_action('shutdown', [__CLASS__, 'flush_change_log'], 20);
389 }
390 }
391
392 if (!in_array($field, self::$pending_log[$bucket]['fields'], true)) {
393 self::$pending_log[$bucket]['fields'][] = $field;
394 }
395 }
396
397 /**
398 * Write the queued overwrites to the Changes Log.
399 *
400 * Public because it runs on `shutdown`. Failures here are swallowed: a
401 * logging problem must never break the sync it is describing.
402 */
403 public static function flush_change_log() {
404 $pending = self::$pending_log;
405 self::$pending_log = [];
406
407 if (empty($pending) || !class_exists('Metasync_Sync_History_Database')) {
408 return;
409 }
410
411 try {
412 $history = new Metasync_Sync_History_Database();
413 $product = class_exists('Metasync') ? Metasync::get_effective_plugin_name() : 'Search Atlas';
414
415 foreach ($pending as $entry) {
416 $is_post = ($entry['type'] === 'post');
417 $label = $is_post ? get_the_title($entry['id']) : '';
418 if (empty($label)) {
419 $label = ucfirst($entry['type']) . ' #' . $entry['id'];
420 }
421
422 $url = null;
423 if ($is_post) {
424 $url = get_permalink($entry['id']);
425 } elseif (function_exists('get_term_link')) {
426 $link = get_term_link($entry['id']);
427 $url = is_string($link) ? $link : null;
428 }
429
430 $history->add([
431 'title' => sprintf(
432 '%s wrote %d SEO field(s) into other plugins on "%s" — originals saved',
433 $product,
434 count($entry['fields']),
435 $label
436 ),
437 'source' => 'SEO Plugin Sync',
438 'status' => 'published',
439 'content_type' => $entry['type'],
440 'url' => $url,
441 'meta_data' => wp_json_encode([
442 'object_type' => $entry['type'],
443 'object_id' => $entry['id'],
444 'fields' => $entry['fields'],
445 ]),
446 'created_at' => current_time('mysql'),
447 ]);
448 }
449 } catch (Exception $e) {
450 // Never let a logging failure break the sync it describes.
451 return;
452 }
453 }
454
455 // ------------------------------------------------------------------
456 // Internals
457 // ------------------------------------------------------------------
458
459 /**
460 * Write the backup row, once and only once.
461 *
462 * `$unique = true` is the whole point. A read-then-update would leave a
463 * window in which two concurrent syncs both see "no backup yet" and the
464 * second one saves OTTO's value as the original.
465 *
466 * @param string $object_type 'post' or 'term'.
467 * @param int $object_id Post or term ID.
468 * @param string $field Third-party field name.
469 * @param mixed $value The value being overwritten, or null if absent.
470 * @return bool True when this call created the row.
471 */
472 /**
473 * Record a write-once marker whose value IS the fact being recorded.
474 *
475 * backup_before_overwrite() preserves the value it is replacing. That is the
476 * right shape for a field and the wrong one for a marker: a marker has no
477 * prior value, so routing one through that method stores whatever the caller
478 * passed as the current value and discards the fact. Markers are write-once
479 * like backups, so a wrong one can never be corrected afterwards.
480 *
481 * @param string $object_type 'post' or 'term'.
482 * @param int $object_id Object ID.
483 * @param string $field Marker name.
484 * @param mixed $value Value to preserve verbatim.
485 * @param bool $created Out-param, true only when this call created the
486 * marker. This method is idempotent, so a caller
487 * can be told "the marker is present" about a
488 * marker a concurrent sync recorded. Withdrawing
489 * that one on a local failure would delete a
490 * marker that correctly describes someone else's
491 * successful write.
492 * @return bool True when the marker is present after the call.
493 */
494 public static function record_marker($object_type, $object_id, $field, $value, &$created = null) {
495 $created = false;
496
497 if (!self::is_enabled()) {
498 return false;
499 }
500
501 $object_id = (int) $object_id;
502 if ($object_id <= 0 || $field === '' || !in_array($object_type, ['post', 'term'], true)) {
503 return false;
504 }
505
506 if (self::backup_exists($object_type, $object_id, $field)) {
507 return true;
508 }
509
510 $created = self::record($object_type, $object_id, $field, $value);
511
512 return $created;
513 }
514
515 /**
516 * Drop a marker recorded for a table write that then failed.
517 *
518 * A `*_row_existed` marker is recorded before the write, because a marker
519 * that will not record has to be able to veto the write. That ordering
520 * leaves a marker behind when the write itself fails, and a stale `'0'` is
521 * the dangerous kind: it says "this row exists only because we made it", so
522 * a restore reading it would delete a row the customer created by hand
523 * afterwards. Markers are write-once and cannot be corrected in place, so
524 * the only way back is to remove the row and let the next attempt record it
525 * afresh.
526 *
527 * Only ever withdraw a marker this run created. `record_marker()` is
528 * idempotent, so a concurrent sync that arrives second is told the marker is
529 * present without having written it; if that sync's own insert then fails,
530 * discarding on its behalf would delete the marker correctly describing the
531 * first sync's successful write.
532 *
533 * That ownership rule is enforced by the callers, not here: a field name
534 * only enters the list passed to `discard_backups()` when the
535 * `record_marker()` / `backup_before_overwrite()` `$created` out-param came
536 * back true, so a row this run merely found is never named for withdrawal.
537 * `$owned` is a second latch for callers that discard a single field
538 * directly and have their own ownership signal to pass.
539 *
540 * @param string $object_type 'post' or 'term'.
541 * @param int $object_id Object ID.
542 * @param string $field Marker name.
543 * @param bool $owned Whether this caller created the marker. False
544 * leaves it alone.
545 * @return void
546 */
547 public static function discard_marker($object_type, $object_id, $field, $owned = true) {
548 if (!$owned) {
549 return;
550 }
551
552 $object_id = (int) $object_id;
553 if ($object_id <= 0 || $field === '' || !in_array($object_type, ['post', 'term'], true)) {
554 return;
555 }
556
557 $key = self::backup_key($field);
558
559 if ($object_type === 'post') {
560 delete_post_meta($object_id, $key);
561 return;
562 }
563
564 delete_term_meta($object_id, $key);
565 }
566
567 /**
568 * Withdraw the per-column backups recorded beside a marker, after the table
569 * write they describe failed.
570 *
571 * Discarding the marker alone is not enough. The per-column rows recorded
572 * next to it are write-once too, so a column captured as NULL because the
573 * row did not exist stays NULL for good. The customer then creates that row
574 * by hand, the next sync correctly marks it as pre-existing, and a restore
575 * reads the stale NULL beside the fresh marker and blanks a real value.
576 * Both halves of a failed attempt have to go, so the next sync can capture
577 * the truth.
578 *
579 * Ownership is the caller's to establish: `$fields` must name only rows this
580 * run actually created, which is what the `$created` out-params on
581 * `record_marker()` and `backup_before_overwrite()` are for. A row that
582 * already existed holds an older, truer original that write-once exists to
583 * protect, so passing it here would destroy the thing being guarded.
584 *
585 * @param string $object_type 'post' or 'term'.
586 * @param int $object_id Object ID.
587 * @param array<string> $fields Backup field names this run created.
588 * @return void
589 */
590 public static function discard_backups($object_type, $object_id, array $fields) {
591 foreach ($fields as $field) {
592 self::discard_marker($object_type, $object_id, $field);
593 }
594 }
595
596 /**
597 * Whether the $wpdb read that just ran can be trusted.
598 *
599 * `get_row()` answers "no such row" and "the query failed" with the same
600 * null. Reading the second as the first is how a customer's real value gets
601 * recorded as "there was nothing here" — and a restore then deletes what it
602 * should have put back. wpdb clears `last_error` at the start of every
603 * query, so immediately after a read it describes that read and no other.
604 *
605 * An empty `last_error` is not proof of success on its own. When the server
606 * has gone away and the reconnect also fails, wpdb::query() returns false
607 * from a branch above the line that assigns `last_error`, so the read failed
608 * while the error string is still empty. The connection handle is checked
609 * too, because that is the state that branch leaves behind.
610 *
611 * @return bool True when the read completed cleanly.
612 */
613 public static function db_read_succeeded() {
614 global $wpdb;
615
616 if (!isset($wpdb)) {
617 return false;
618 }
619
620 if (!empty($wpdb->last_error)) {
621 return false;
622 }
623
624 // A dropped connection reports no error at all, so treat a missing
625 // handle as a failed read rather than an empty result.
626 if (property_exists($wpdb, 'dbh') && empty($wpdb->dbh)) {
627 return false;
628 }
629
630 return true;
631 }
632
633 /**
634 * Whether a backup row already exists for this object and field.
635 *
636 * @param string $object_type 'post' or 'term'.
637 * @param int $object_id Object ID.
638 * @param string $field Field name.
639 * @return bool
640 */
641 private static function backup_exists($object_type, $object_id, $field) {
642 return metadata_exists($object_type, $object_id, self::backup_key($field));
643 }
644
645 private static function record($object_type, $object_id, $field, $value) {
646 $key = self::backup_key($field);
647
648 // add_*_meta() unslashes what it is given, so a value read straight out
649 // of the database has to be re-slashed on the way back in. Without this
650 // a title like `C:\Users\Docs` is stored as `C:UsersDocs` and the backup
651 // no longer matches the original it exists to preserve. wp_slash()
652 // recurses, which is what the ['value' => ...] wrapper needs.
653 $wrapped = wp_slash(self::wrap($value));
654
655 if ($object_type === 'post') {
656 return (bool) add_post_meta($object_id, $key, $wrapped, true);
657 }
658
659 return (bool) add_term_meta($object_id, $key, $wrapped, true);
660 }
661
662 /**
663 * Current stored value of a meta field, with absent distinguished from empty.
664 *
665 * `get_*_meta(..., true)` returns '' for both, which is exactly the
666 * distinction a restore needs, hence the `metadata_exists()` probe.
667 *
668 * @param string $object_type 'post' or 'term'.
669 * @param int $object_id Post or term ID.
670 * @param string $field Meta key.
671 * @return mixed Null when the field is absent.
672 */
673 private static function read_current_meta($object_type, $object_id, $field) {
674 if (!metadata_exists($object_type, $object_id, $field)) {
675 return null;
676 }
677
678 return ($object_type === 'post')
679 ? get_post_meta($object_id, $field, true)
680 : get_term_meta($object_id, $field, true);
681 }
682
683 /**
684 * Whether a write would actually change the stored value.
685 *
686 * Absent (null) is never equal to empty string: writing '' into a field
687 * that did not exist does change it, and a restore has to know to delete
688 * rather than blank it.
689 *
690 * @param mixed $current Current stored value, null when absent.
691 * @param mixed $new Value about to be written.
692 * @return bool
693 */
694 private static function values_differ($current, $new) {
695 if ($current === null) {
696 return true;
697 }
698
699 if (is_scalar($current) && is_scalar($new)) {
700 return (string) $current !== (string) $new;
701 }
702
703 return $current != $new;
704 }
705
706 /**
707 * Wrap a value for storage.
708 *
709 * WordPress cannot store a PHP null in meta — it serialises to '', which
710 * would collapse "the field was absent" into "the field was empty" and take
711 * the restore's delete-vs-blank decision away from it. Wrapping in an array
712 * keeps a real null a real null, with no sentinel string a genuine value
713 * could ever collide with.
714 *
715 * @param mixed $value Value to wrap.
716 * @return array
717 */
718 private static function wrap($value) {
719 return ['value' => $value];
720 }
721
722 /**
723 * Unwrap a stored backup value.
724 *
725 * @param mixed $stored Raw meta value.
726 * @return mixed
727 */
728 private static function unwrap($stored) {
729 if (is_array($stored) && array_key_exists('value', $stored)) {
730 return $stored['value'];
731 }
732
733 return $stored;
734 }
735 }
736