|
@@ -1,7 +1,7 @@ |
|
1
|
1
|
<?php |
|
2
|
2
|
/** |
|
3
|
|
- * Post-Level SEO Plugin Sync (WP-196) |
|
3
|
+ * Post-Level SEO Plugin Sync |
|
4
|
4
|
* |
|
5
|
5
|
* Mirrors MetaSync post meta (`_metasync_*`) into the active third-party |
|
6
|
6
|
* SEO plugins' post storage (Yoast, Rank Math, AIOSEO) so that posts and |
|
7
|
7
|
* pages render MetaSync-managed values regardless of which plugin is |
|
@@ -6,8 +6,12 @@ |
|
6
|
6
|
* SEO plugins' post storage (Yoast, Rank Math, AIOSEO) so that posts and |
|
7
|
7
|
* pages render MetaSync-managed values regardless of which plugin is |
|
8
|
8
|
* actually rendering the frontend. |
|
9
|
9
|
* |
|
10
|
+ * Manually edited fields mirror immediately. Values that OTTO generated for |
|
11
|
+ * itself are permitted into third-party storage only while the matching OTTO |
|
12
|
+ * Persistence setting is enabled — see OTTO_PERSISTENCE_KEYS. |
|
13
|
+ * |
|
10
|
14
|
* @package MetaSync |
|
11
|
15
|
* @subpackage MetaSync/includes |
|
12
|
16
|
* @since 2.8.25 |
|
13
|
17
|
*/ |
|
@@ -33,8 +37,22 @@ |
|
33
|
37
|
*/ |
|
34
|
38
|
private $syncing_json_to_legacy = []; |
|
35
|
39
|
|
|
36
|
40
|
/** |
|
41
|
+ * Guard: tracks post IDs currently being synced from legacy → JSON. |
|
42
|
+ * |
|
43
|
+ * Prevents sync_to_legacy_meta from firing in response to the JSON write |
|
44
|
+ * that sync_legacy_to_json just made. Without this the round trip is |
|
45
|
+ * lossy: the JSON format cannot distinguish "user explicitly chose the |
|
46
|
+ * default" from "nothing set" (the sidebar always emits max_snippet=-1 |
|
47
|
+ * and max_image_preview=large), so the mirror back would discard the |
|
48
|
+ * legacy meta box values it had just derived the JSON from. |
|
49
|
+ * |
|
50
|
+ * @var array |
|
51
|
+ */ |
|
52
|
+ private $syncing_legacy_to_json = []; |
|
53
|
+ |
|
54
|
+ /** |
|
37
|
55
|
* MetaSync meta keys that trigger a sync when written. |
|
38
|
56
|
* |
|
39
|
57
|
* @var array |
|
40
|
58
|
*/ |
|
@@ -65,8 +83,34 @@ |
|
65
|
83
|
'_metasync_otto_keywords', |
|
66
|
84
|
]; |
|
67
|
85
|
|
|
68
|
86
|
/** |
|
87
|
+ * Internal OTTO meta keys mapped to the OTTO Persistence setting that |
|
88
|
+ * governs whether their value may reach third-party SEO plugin storage. |
|
89
|
+ * |
|
90
|
+ * OTTO writes these keys on every sync regardless of any setting, because |
|
91
|
+ * its own render filters and the admin reporting columns read them. Copying |
|
92
|
+ * one into Yoast / Rank Math / AIOSEO is a different act: it is a permanent |
|
93
|
+ * write into another plugin's storage that outlives MetaSync, which is |
|
94
|
+ * exactly what the Persistence settings exist to control. |
|
95
|
+ * |
|
96
|
+ * Manually edited MetaSync fields are deliberately absent from this map. |
|
97
|
+ * Saving those in the editor is an explicit user action, so they sync |
|
98
|
+ * immediately and never consult these settings. |
|
99
|
+ * |
|
100
|
+ * @var array<string,string> |
|
101
|
+ */ |
|
102
|
+ const OTTO_PERSISTENCE_KEYS = [ |
|
103
|
+ '_metasync_otto_title' => 'meta_title', |
|
104
|
+ '_metasync_otto_description' => 'meta_description', |
|
105
|
+ '_metasync_otto_keywords' => 'meta_keywords', |
|
106
|
+ '_metasync_otto_og_title' => 'og_title', |
|
107
|
+ '_metasync_otto_og_description' => 'og_description', |
|
108
|
+ '_metasync_otto_twitter_title' => 'twitter_title', |
|
109
|
+ '_metasync_otto_twitter_description' => 'twitter_description', |
|
110
|
+ ]; |
|
111
|
+ |
|
112
|
+ /** |
|
69
|
113
|
* Get singleton instance. |
|
70
|
114
|
* |
|
71
|
115
|
* @return self |
|
72
|
116
|
*/ |
|
@@ -77,8 +121,35 @@ |
|
77
|
121
|
return self::$instance; |
|
78
|
122
|
} |
|
79
|
123
|
|
|
80
|
124
|
/** |
|
125
|
+ * Whether a meta key's value may be mirrored into third-party SEO storage. |
|
126
|
+ * |
|
127
|
+ * Returns true for every key that is not an internal OTTO key, so manually |
|
128
|
+ * edited MetaSync fields are unaffected. For the OTTO keys the matching |
|
129
|
+ * Persistence setting decides. |
|
130
|
+ * |
|
131
|
+ * The class_exists() guard mirrors the call sites in |
|
132
|
+ * otto/metasync-otto-seo-functions.php, including their fail-closed |
|
133
|
+ * behaviour: a partially updated install can leave this file newer than the |
|
134
|
+ * settings class, and with the class missing there is no setting that could |
|
135
|
+ * authorise a permanent write into another plugin's storage. |
|
136
|
+ * |
|
137
|
+ * @param string $meta_key Meta key being written. |
|
138
|
+ * @return bool True when the value may be synced. |
|
139
|
+ */ |
|
140
|
+ private function otto_persistence_allows($meta_key) { |
|
141
|
+ if (!isset(self::OTTO_PERSISTENCE_KEYS[$meta_key])) { |
|
142
|
+ return true; |
|
143
|
+ } |
|
144
|
+ |
|
145
|
+ return class_exists('Metasync_Otto_Persistence_Settings') |
|
146
|
+ && Metasync_Otto_Persistence_Settings::should_persist( |
|
147
|
+ self::OTTO_PERSISTENCE_KEYS[$meta_key] |
|
148
|
+ ); |
|
149
|
+ } |
|
150
|
+ |
|
151
|
+ /** |
|
81
|
152
|
* Sync MetaSync post meta to every active SEO plugin. |
|
82
|
153
|
* |
|
83
|
154
|
* When $fields is non-empty only those canonical keys are synced. |
|
84
|
155
|
* When empty a full sync of all canonical keys is performed. |
|
@@ -172,9 +243,11 @@ |
|
172
|
243
|
'_metasync_canonical_url' => 'canonical', |
|
173
|
244
|
'_metasync_focus_keyword' => 'focus_keyword', |
|
174
|
245
|
'_metasync_breadcrumb_title' => 'breadcrumb_title', |
|
175
|
246
|
'_metasync_robots_advanced' => '_robots_advanced_json', |
|
176
|
|
- // OTTO volatile keys (SSR writes these even without persistence) |
|
247
|
+ // Internal OTTO keys. OTTO stores these on every sync regardless of |
|
248
|
+ // any setting; whether they may reach third-party storage is decided |
|
249
|
+ // by OTTO_PERSISTENCE_KEYS above. |
|
177
|
250
|
'_metasync_otto_title' => 'title', |
|
178
|
251
|
'_metasync_otto_description' => 'desc', |
|
179
|
252
|
'_metasync_otto_og_title' => 'og_title', |
|
180
|
253
|
'_metasync_otto_og_description' => 'og_desc', |
|
@@ -189,11 +262,20 @@ |
|
189
|
262
|
if (!isset($watched[$meta_key])) { |
|
190
|
263
|
return; |
|
191
|
264
|
} |
|
192
|
265
|
|
|
266
|
+ // An internal OTTO field reaches third-party SEO storage only while its |
|
267
|
+ // OTTO Persistence setting is enabled. With the setting off the value |
|
268
|
+ // stays stored under its own key -- OTTO keeps rendering and reporting |
|
269
|
+ // on it -- but Yoast / Rank Math / AIOSEO are left untouched. Manually |
|
270
|
+ // edited MetaSync fields are not in the map and so are never gated. |
|
271
|
+ if (!$this->otto_persistence_allows($meta_key)) { |
|
272
|
+ return; |
|
273
|
+ } |
|
274
|
+ |
|
193
|
275
|
$canonical_key = $watched[$meta_key]; |
|
194
|
276
|
|
|
195
|
|
- // WP-197 JSON key -- do a full sync + mirror to legacy meta boxes |
|
277
|
+ // JSON key -- do a full sync + mirror to legacy meta boxes |
|
196
|
278
|
if ($canonical_key === '_robots_advanced_json') { |
|
197
|
279
|
$this->sync_post((int) $post_id); |
|
198
|
280
|
$this->sync_to_legacy_meta((int) $post_id, $meta_value); |
|
199
|
281
|
// Re-sync at shutdown only when Yoast is active — Yoast's indexable |
|
@@ -201,8 +283,16 @@ |
|
201
|
283
|
if ($this->is_yoast_active()) { |
|
202
|
284
|
$sync_instance = $this; |
|
203
|
285
|
$sync_post_id = (int) $post_id; |
|
204
|
286
|
add_action('shutdown', function() use ($sync_instance, $sync_post_id) { |
|
287
|
+ // By shutdown this request has typically seeded the OG defaults |
|
288
|
+ // memo for the post (the sync above reads it); the row write |
|
289
|
+ // has since landed, so drop the memo or the re-sync below |
|
290
|
+ // compares against the pre-save title/excerpt. |
|
291
|
+ // @phpstan-ignore-next-line function.alreadyNarrowedType |
|
292
|
+ if (method_exists('Metasync_OpenGraph', 'clear_default_og_values_memo')) { |
|
293
|
+ Metasync_OpenGraph::clear_default_og_values_memo(); |
|
294
|
+ } |
|
205
|
295
|
$sync_instance->sync_post($sync_post_id); |
|
206
|
296
|
}, 0); |
|
207
|
297
|
} |
|
208
|
298
|
return; |
|
@@ -226,8 +316,34 @@ |
|
226
|
316
|
[$canonical_key => $value] |
|
227
|
317
|
); |
|
228
|
318
|
} |
|
229
|
319
|
|
|
320
|
+ /** |
|
321
|
+ * Hook handler for deleted_post_meta. |
|
322
|
+ * |
|
323
|
+ * Only the legacy meta box keys are handled here. Unticking the last |
|
324
|
+ * checkbox in the Common Robots meta box deletes metasync_common_robots |
|
325
|
+ * instead of updating it, so without a delete hook the mirrored |
|
326
|
+ * _metasync_robots_advanced JSON kept the stale directive — and because the |
|
327
|
+ * JSON is the highest-priority source in the output resolver, the page went |
|
328
|
+ * on emitting a directive the editor had just cleared. |
|
329
|
+ * |
|
330
|
+ * Deletes of the JSON key itself are deliberately NOT routed into |
|
331
|
+ * on_meta_updated: that path mirrors the value back onto the legacy meta |
|
332
|
+ * boxes, so an empty value would wipe them. |
|
333
|
+ * |
|
334
|
+ * @param array $meta_ids Meta row IDs (unused). |
|
335
|
+ * @param int $post_id Post ID. |
|
336
|
+ * @param string $meta_key Meta key being deleted. |
|
337
|
+ */ |
|
338
|
+ public function on_meta_deleted($meta_ids, $post_id, $meta_key) { |
|
339
|
+ if ($meta_key !== 'metasync_common_robots' && $meta_key !== 'metasync_advance_robots') { |
|
340
|
+ return; |
|
341
|
+ } |
|
342
|
+ |
|
343
|
+ $this->sync_legacy_to_json((int) $post_id); |
|
344
|
+ } |
|
345
|
+ |
|
230
|
346
|
// ------------------------------------------------------------------ |
|
231
|
347
|
// Data collection |
|
232
|
348
|
// ------------------------------------------------------------------ |
|
233
|
349
|
|
|
@@ -241,27 +357,113 @@ |
|
241
|
357
|
* @param array $fields Optional pre-resolved canonical key/value pairs. |
|
242
|
358
|
* @return array Canonical data array. |
|
243
|
359
|
*/ |
|
244
|
360
|
private function collect_post_data($post_id, array $fields = []) { |
|
245
|
|
- // If caller already resolved specific fields, return them directly |
|
361
|
+ // If caller already resolved specific fields, return them directly. |
|
362
|
+ // Canonical still gets validated — this branch serves the |
|
363
|
+ // updated_post_meta fast-path, which would otherwise mirror a raw |
|
364
|
+ // (possibly corrupted) value into third-party storage. |
|
246
|
365
|
if (!empty($fields)) { |
|
247
|
|
- return $fields; |
|
366
|
+ if (array_key_exists('canonical', $fields)) { |
|
367
|
+ $fields['canonical'] = Metasync_Canonical_Sanitizer::sanitize($fields['canonical']); |
|
368
|
+ if ($fields['canonical'] === '') { |
|
369
|
+ unset($fields['canonical']); |
|
370
|
+ } |
|
371
|
+ } |
|
372
|
+ |
|
373
|
+ // Same reasoning for the social title/description fields: on this |
|
374
|
+ // fast-path the value arrives straight from the meta write, so a stored |
|
375
|
+ // "Auto Draft" pre-fill placeholder would be mirrored verbatim into |
|
376
|
+ // Yoast/RankMath/AIOSEO and emitted there as og:title. Drop it instead so |
|
377
|
+ // each plugin keeps whatever it already has. |
|
378
|
+ // @phpstan-ignore-next-line function.alreadyNarrowedType |
|
379
|
+ if (method_exists('Metasync_OpenGraph', 'is_auto_draft_title')) { |
|
380
|
+ foreach (['og_title', 'og_desc', 'twitter_title', 'twitter_desc'] as $social_field) { |
|
381
|
+ if (array_key_exists($social_field, $fields) |
|
382
|
+ && Metasync_OpenGraph::is_auto_draft_title($fields[$social_field]) |
|
383
|
+ ) { |
|
384
|
+ unset($fields[$social_field]); |
|
385
|
+ } |
|
386
|
+ } |
|
387
|
+ } |
|
388
|
+ |
|
389
|
+ return $this->apply_feature_flags_to_payload($fields); |
|
248
|
390
|
} |
|
249
|
391
|
|
|
250
|
392
|
$all_meta = get_post_custom($post_id); |
|
251
|
393
|
|
|
252
|
|
- $get = function ($key) use ($all_meta) { |
|
394
|
+ $get = function ($key) use ($all_meta, $post_id) { |
|
253
|
395
|
if (!isset($all_meta[$key])) { |
|
254
|
396
|
return ''; |
|
255
|
397
|
} |
|
256
|
|
- return is_array($all_meta[$key]) ? $all_meta[$key][0] : $all_meta[$key]; |
|
398
|
+ $value = is_array($all_meta[$key]) ? $all_meta[$key][0] : $all_meta[$key]; |
|
399
|
+ |
|
400
|
+ // The meta box pre-fills the social title/description fields from the post |
|
401
|
+ // title, which is the "Auto Draft" placeholder on a brand-new post. Collapse |
|
402
|
+ // it to '' here so the placeholder is never mirrored into Yoast/RankMath/ |
|
403
|
+ // AIOSEO storage, where those plugins would emit it as og:title. |
|
404
|
+ // Same sanitize-at-the-source placement as Metasync_Canonical_Sanitizer below. |
|
405
|
+ // |
|
406
|
+ // method_exists (not just class_exists) for the same reason |
|
407
|
+ // sync_layer_handles() checks: this runs on meta writes during front-end |
|
408
|
+ // requests, and a partially updated install can leave an older |
|
409
|
+ // class-metasync-opengraph.php beside this file. |
|
410
|
+ // @phpstan-ignore-next-line function.alreadyNarrowedType |
|
411
|
+ if (method_exists('Metasync_OpenGraph', 'strip_auto_draft_title') |
|
412
|
+ && defined('Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS') |
|
413
|
+ && in_array($key, Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS, true) |
|
414
|
+ ) { |
|
415
|
+ $value = Metasync_OpenGraph::strip_auto_draft_title($value); |
|
416
|
+ } |
|
417
|
+ |
|
418
|
+ // A social title that is a verbatim snapshot of the post title is the |
|
419
|
+ // old pre-fill, not a customization — mirroring it would hand a |
|
420
|
+ // third-party plugin a copy that a rename leaves stale (and that |
|
421
|
+ // outranks OTTO's fresh value there). Collapsed for the same reason |
|
422
|
+ // as the placeholder above; the chain in $first then falls through |
|
423
|
+ // to OTTO's staging key. |
|
424
|
+ // @phpstan-ignore-next-line function.alreadyNarrowedType |
|
425
|
+ if (method_exists('Metasync_OpenGraph', 'strip_title_snapshot') |
|
426
|
+ && defined('Metasync_OpenGraph::TITLE_DEFAULTED_KEYS') |
|
427
|
+ && in_array($key, Metasync_OpenGraph::TITLE_DEFAULTED_KEYS, true) |
|
428
|
+ ) { |
|
429
|
+ $value = Metasync_OpenGraph::strip_title_snapshot($post_id, $key, $value); |
|
430
|
+ } |
|
431
|
+ |
|
432
|
+ // A social description that is a verbatim snapshot of the resolved |
|
433
|
+ // excerpt is the old pre-fill, not a customization — mirroring it |
|
434
|
+ // would hand a third-party plugin a copy that a later excerpt or |
|
435
|
+ // content edit leaves stale. Collapsed for the same reason as the |
|
436
|
+ // title snapshot above. |
|
437
|
+ // @phpstan-ignore-next-line function.alreadyNarrowedType |
|
438
|
+ if (method_exists('Metasync_OpenGraph', 'strip_description_snapshot') |
|
439
|
+ && defined('Metasync_OpenGraph::DESCRIPTION_DEFAULTED_KEYS') |
|
440
|
+ && in_array($key, Metasync_OpenGraph::DESCRIPTION_DEFAULTED_KEYS, true) |
|
441
|
+ ) { |
|
442
|
+ $value = Metasync_OpenGraph::strip_description_snapshot($post_id, $key, $value); |
|
443
|
+ } |
|
444
|
+ |
|
445
|
+ return $value; |
|
257
|
446
|
}; |
|
258
|
447
|
|
|
259
|
448
|
$data = []; |
|
260
|
449
|
|
|
261
|
|
- // Helper: first non-empty value from a list of meta keys |
|
450
|
+ // Helper: first non-empty value from a list of meta keys. |
|
451
|
+ // |
|
452
|
+ // Internal OTTO keys are skipped while their OTTO Persistence setting is |
|
453
|
+ // disabled. Gating here and not only in on_meta_updated() is what makes |
|
454
|
+ // the setting hold for the callers that re-read meta themselves rather |
|
455
|
+ // than passing a resolved field: the sync at the end of OTTO SSR |
|
456
|
+ // processing (metasync_update_comprehensive_seo_fields), the MCP OTTO |
|
457
|
+ // refresh tool, and the robots full-sync branch below. |
|
458
|
+ // |
|
459
|
+ // Each chain lists the manually edited key first and the internal OTTO |
|
460
|
+ // key last, so a customer-entered value is unaffected and still wins. |
|
262
|
461
|
$first = function (...$keys) use ($get) { |
|
263
|
462
|
foreach ($keys as $key) { |
|
463
|
+ if (!$this->otto_persistence_allows($key)) { |
|
464
|
+ continue; |
|
465
|
+ } |
|
264
|
466
|
$val = $get($key); |
|
265
|
467
|
if (!empty($val)) { |
|
266
|
468
|
return $val; |
|
267
|
469
|
} |
|
@@ -268,21 +470,32 @@ |
|
268
|
470
|
} |
|
269
|
471
|
return ''; |
|
270
|
472
|
}; |
|
271
|
473
|
|
|
272
|
|
- // title: sidebar > persisted OTTO > volatile OTTO |
|
273
|
|
- $data['title'] = $first('_metasync_seo_title', '_metasync_metatitle', '_metasync_otto_title'); |
|
474
|
+ // Order comes from Metasync_Seo_Precedence, the one place it is defined. |
|
475
|
+ // |
|
476
|
+ // Imported values come last so a sync triggered by something else still |
|
477
|
+ // reports a title rather than an empty one. They are deliberately NOT in |
|
478
|
+ // WATCHED_KEYS or the on_meta_updated map above: a sync mirrors the |
|
479
|
+ // canonical value into an active third-party plugin's own storage, and |
|
480
|
+ // that plugin renders at a higher effective precedence than OTTO — so |
|
481
|
+ // syncing on an imported write would put the imported title back in |
|
482
|
+ // front of OTTO, which is the exact behaviour this key exists to avoid. |
|
483
|
+ $data['title'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_TITLE)); |
|
274
|
484
|
|
|
275
|
|
- // desc: sidebar > persisted OTTO > volatile OTTO |
|
276
|
|
- $data['desc'] = $first('_metasync_seo_desc', '_metasync_metadesc', '_metasync_otto_description'); |
|
485
|
+ $data['desc'] = $first(...Metasync_Seo_Precedence::keys(Metasync_Seo_Precedence::FIELD_DESCRIPTION)); |
|
277
|
486
|
|
|
278
|
|
- // Robots directives: check _metasync_robots_advanced JSON first (WP-197), |
|
487
|
+ // Robots directives: check _metasync_robots_advanced JSON first, |
|
279
|
488
|
// then fall back to metasync_common_robots array + metasync_advance_robots array |
|
280
|
489
|
$robots_json_raw = $get('_metasync_robots_advanced'); |
|
281
|
490
|
$robots_json = !empty($robots_json_raw) ? json_decode($robots_json_raw, true) : null; |
|
282
|
491
|
|
|
283
|
492
|
if (is_array($robots_json)) { |
|
284
|
|
- $data['noindex'] = !empty($robots_json['noindex']); |
|
493
|
+ $data['noindex'] = $this->resolve_synced_noindex( |
|
494
|
+ $robots_json, |
|
495
|
+ $get('_metasync_robots_index'), |
|
496
|
+ $get('metasync_common_robots') |
|
497
|
+ ); |
|
285
|
498
|
$data['nofollow'] = !empty($robots_json['nofollow']); |
|
286
|
499
|
$data['noarchive'] = !empty($robots_json['noarchive']); |
|
287
|
500
|
$data['nosnippet'] = !empty($robots_json['nosnippet']); |
|
288
|
501
|
$data['noimageindex'] = !empty($robots_json['noimageindex']); |
|
@@ -289,11 +502,15 @@ |
|
289
|
502
|
$data['max_snippet'] = isset($robots_json['max_snippet']) ? (int) $robots_json['max_snippet'] : (isset($robots_json['max-snippet']) ? (int) $robots_json['max-snippet'] : null); |
|
290
|
503
|
$data['max_image_preview'] = $robots_json['max_image_preview'] ?? $robots_json['max-image-preview'] ?? null; |
|
291
|
504
|
$data['max_video_preview'] = isset($robots_json['max_video_preview']) ? (int) $robots_json['max_video_preview'] : (isset($robots_json['max-video-preview']) ? (int) $robots_json['max-video-preview'] : null); |
|
292
|
505
|
} else { |
|
293
|
|
- // noindex from dedicated key |
|
294
|
|
- $robots_index = $get('_metasync_robots_index'); |
|
295
|
|
- $data['noindex'] = ($robots_index === 'noindex'); |
|
506
|
+ // noindex: dedicated key, falling back to the Common Robots checkbox |
|
507
|
+ // array — same sources the front-end emitter honours. |
|
508
|
+ $data['noindex'] = $this->resolve_synced_noindex( |
|
509
|
+ null, |
|
510
|
+ $get('_metasync_robots_index'), |
|
511
|
+ $get('metasync_common_robots') |
|
512
|
+ ); |
|
296
|
513
|
|
|
297
|
514
|
// Common robots array (serialized) |
|
298
|
515
|
$common_raw = $get('metasync_common_robots'); |
|
299
|
516
|
$common_robots = !empty($common_raw) ? maybe_unserialize($common_raw) : []; |
|
@@ -312,11 +529,22 @@ |
|
312
|
529
|
if (!is_array($adv_robots)) { |
|
313
|
530
|
$adv_robots = []; |
|
314
|
531
|
} |
|
315
|
532
|
|
|
316
|
|
- $data['max_snippet'] = isset($adv_robots['max-snippet']) ? (int) $adv_robots['max-snippet'] : null; |
|
317
|
|
- $data['max_image_preview'] = isset($adv_robots['max-image-preview']) ? $adv_robots['max-image-preview'] : null; |
|
318
|
|
- $data['max_video_preview'] = isset($adv_robots['max-video-preview']) ? (int) $adv_robots['max-video-preview'] : null; |
|
533
|
+ // Each advance-robots directive is stored as ['enable' => .., 'length' => ..]. |
|
534
|
+ // Read the length scalar (honouring the enable flag) instead of casting the |
|
535
|
+ // whole sub-array — (int) of a non-empty array is 1, which is what produced the |
|
536
|
+ // "max-snippet:1" instead of "-1" and dropped the image-preview value. Mirrors |
|
537
|
+ // sync_legacy_to_json() so both directions read the legacy format identically. |
|
538
|
+ $data['max_snippet'] = !empty($adv_robots['max-snippet']['enable']) |
|
539
|
+ ? (isset($adv_robots['max-snippet']['length']) ? (int) $adv_robots['max-snippet']['length'] : -1) |
|
540
|
+ : null; |
|
541
|
+ $data['max_image_preview'] = !empty($adv_robots['max-image-preview']['enable']) |
|
542
|
+ ? (isset($adv_robots['max-image-preview']['length']) ? (string) $adv_robots['max-image-preview']['length'] : 'large') |
|
543
|
+ : null; |
|
544
|
+ $data['max_video_preview'] = !empty($adv_robots['max-video-preview']['enable']) |
|
545
|
+ ? (isset($adv_robots['max-video-preview']['length']) ? (int) $adv_robots['max-video-preview']['length'] : -1) |
|
546
|
+ : null; |
|
319
|
547
|
} |
|
320
|
548
|
|
|
321
|
549
|
// Social / OG: persisted > volatile OTTO |
|
322
|
550
|
$data['og_title'] = $first('_metasync_og_title', '_metasync_otto_og_title'); |
|
@@ -328,15 +556,96 @@ |
|
328
|
556
|
$data['twitter_desc'] = $first('_metasync_twitter_description', '_metasync_otto_twitter_description'); |
|
329
|
557
|
$data['twitter_card'] = $get('_metasync_twitter_card'); |
|
330
|
558
|
|
|
331
|
559
|
// Canonical, focus keyword, breadcrumb |
|
332
|
|
- $data['canonical'] = $get('_metasync_canonical_url'); |
|
560
|
+ // Canonical is validated at the source so a corrupted value ("Array") |
|
561
|
+ // never propagates into Yoast/RankMath/AIOSEO storage. |
|
562
|
+ $data['canonical'] = Metasync_Canonical_Sanitizer::sanitize($get('_metasync_canonical_url')); |
|
333
|
563
|
$data['focus_keyword'] = $first('_metasync_focus_keyword', '_metasync_otto_keywords'); |
|
334
|
564
|
$data['breadcrumb_title'] = $get('_metasync_breadcrumb_title'); |
|
335
|
565
|
|
|
566
|
+ return $this->apply_feature_flags_to_payload($data); |
|
567
|
+ } |
|
568
|
+ |
|
569
|
+ /** |
|
570
|
+ * Drop payload keys whose owning feature is switched off in Editor Settings. |
|
571
|
+ * |
|
572
|
+ * A disabled meta box means MetaSync must not interfere with that feature |
|
573
|
+ * anywhere: it emits nothing, suppresses nothing, and — the part this |
|
574
|
+ * guard covers — does not mirror its values into Yoast / Rank Math / |
|
575
|
+ * AIOSEO storage. Without the drop, a sync fired while the feature is off |
|
576
|
+ * resolves the MetaSync value with the feature already stripped and then |
|
577
|
+ * writes that result over the third-party plugin's own value (Rank Math's |
|
578
|
+ * stored noindex replaced by a plain 'index'). The writers are all |
|
579
|
+ * array_key_exists-guarded, so removing a key here skips every write of it. |
|
580
|
+ * |
|
581
|
+ * @param array $data Canonical payload keyed by canonical field name. |
|
582
|
+ * @return array |
|
583
|
+ */ |
|
584
|
+ private function apply_feature_flags_to_payload(array $data) { |
|
585
|
+ if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::COMMON_ROBOTS)) { |
|
586
|
+ unset( |
|
587
|
+ $data['noindex'], |
|
588
|
+ $data['nofollow'], |
|
589
|
+ $data['noarchive'], |
|
590
|
+ $data['nosnippet'], |
|
591
|
+ $data['noimageindex'] |
|
592
|
+ ); |
|
593
|
+ } |
|
594
|
+ if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::ADVANCE_ROBOTS)) { |
|
595
|
+ unset( |
|
596
|
+ $data['max_snippet'], |
|
597
|
+ $data['max_image_preview'], |
|
598
|
+ $data['max_video_preview'] |
|
599
|
+ ); |
|
600
|
+ } |
|
601
|
+ if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) { |
|
602
|
+ unset( |
|
603
|
+ $data['og_title'], |
|
604
|
+ $data['og_desc'], |
|
605
|
+ $data['og_image'], |
|
606
|
+ $data['twitter_title'], |
|
607
|
+ $data['twitter_desc'], |
|
608
|
+ $data['twitter_card'] |
|
609
|
+ ); |
|
610
|
+ } |
|
611
|
+ if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) { |
|
612
|
+ unset($data['canonical']); |
|
613
|
+ } |
|
614
|
+ |
|
336
|
615
|
return $data; |
|
337
|
616
|
} |
|
338
|
617
|
|
|
618
|
+ /** |
|
619
|
+ * Resolve the noindex directive for syncing the way the front-end emitter |
|
620
|
+ * does: any of the three sources saying noindex wins, and none can veto |
|
621
|
+ * another. The JSON rebuilt by sync_legacy_to_json() deliberately omits |
|
622
|
+ * noindex, so reading the JSON alone reported a checkbox-only noindex as |
|
623
|
+ * false — and the writers translated that into an explicit 'index' that |
|
624
|
+ * overwrote a noindex the third-party plugin already held. |
|
625
|
+ * |
|
626
|
+ * @param mixed $robots_json Decoded _metasync_robots_advanced payload (or null). |
|
627
|
+ * @param mixed $robots_index Raw _metasync_robots_index meta value. |
|
628
|
+ * @param mixed $common_raw Raw metasync_common_robots meta value. |
|
629
|
+ * @return bool |
|
630
|
+ */ |
|
631
|
+ private function resolve_synced_noindex($robots_json, $robots_index, $common_raw) { |
|
632
|
+ if (is_array($robots_json) && !empty($robots_json['noindex'])) { |
|
633
|
+ return true; |
|
634
|
+ } |
|
635
|
+ if ($robots_index === 'noindex') { |
|
636
|
+ return true; |
|
637
|
+ } |
|
638
|
+ if (!empty($common_raw)) { |
|
639
|
+ $common = maybe_unserialize($common_raw); |
|
640
|
+ if (is_array($common) && !empty($common['noindex'])) { |
|
641
|
+ return true; |
|
642
|
+ } |
|
643
|
+ } |
|
644
|
+ |
|
645
|
+ return false; |
|
646
|
+ } |
|
647
|
+ |
|
339
|
648
|
// ------------------------------------------------------------------ |
|
340
|
649
|
// Plugin detectors |
|
341
|
650
|
// ------------------------------------------------------------------ |
|
342
|
651
|
|
|
@@ -386,35 +695,85 @@ |
|
386
|
695
|
// Per-plugin sync |
|
387
|
696
|
// ------------------------------------------------------------------ |
|
388
|
697
|
|
|
389
|
698
|
/** |
|
699
|
+ * Whether third-party SEO storage may be written at all. |
|
700
|
+ * |
|
701
|
+ * The site owner's consent switch. Checked once per plugin dispatch rather |
|
702
|
+ * than per field so a sync that is not permitted does no work and, more |
|
703
|
+ * importantly, leaves no half-written row behind. |
|
704
|
+ * |
|
705
|
+ * @return bool |
|
706
|
+ */ |
|
707
|
+ private function third_party_writes_allowed() { |
|
708
|
+ return class_exists('Metasync_Seo_Backup') && Metasync_Seo_Backup::is_enabled(); |
|
709
|
+ } |
|
710
|
+ |
|
711
|
+ /** |
|
712
|
+ * Write one third-party post-meta field, preserving what it held before. |
|
713
|
+ * |
|
714
|
+ * This bridge runs on `updated_post_meta`, which means it fires from inside |
|
715
|
+ * an OTTO sync's `_metasync_otto_*` write — before the same sync reaches its |
|
716
|
+ * own direct Rank Math / Yoast writes further down. It is therefore usually |
|
717
|
+ * the first code to touch the customer's value, and the backup it takes here |
|
718
|
+ * is the one that holds the true original. |
|
719
|
+ * |
|
720
|
+ * @param int $post_id Post ID. |
|
721
|
+ * @param string $key Third-party meta key. |
|
722
|
+ * @param mixed $value Value to write. |
|
723
|
+ * @return bool True when the write happened. |
|
724
|
+ */ |
|
725
|
+ private function write_post_field($post_id, $key, $value) { |
|
726
|
+ return class_exists('Metasync_Seo_Backup') |
|
727
|
+ && Metasync_Seo_Backup::write_post_meta($post_id, $key, $value); |
|
728
|
+ } |
|
729
|
+ |
|
730
|
+ /** |
|
390
|
731
|
* Mirror canonical data into Yoast post meta and indexable cache. |
|
391
|
732
|
* |
|
392
|
733
|
* @param int $post_id Post ID. |
|
393
|
734
|
* @param array $data Canonical key/value pairs. |
|
394
|
|
- * @return bool True once dispatch completes. |
|
735
|
+ * @return bool True when at least one value-bearing field was mirrored, |
|
736
|
+ * false when the payload carried nothing to write. The caller |
|
737
|
+ * stamps `_metasync_plugin_sync_ts` from this, so it must |
|
738
|
+ * describe what was written and not merely that dispatch was |
|
739
|
+ * reached. |
|
740
|
+ * |
|
741
|
+ * Robots directives are excluded from that answer on purpose. |
|
742
|
+ * collect_post_data() synthesises noindex/nofollow on every |
|
743
|
+ * payload — absent any stored value they resolve to plain |
|
744
|
+ * index/follow — so they are written on every sync and say |
|
745
|
+ * nothing about whether a canonical value existed to mirror. |
|
746
|
+ * Counting them would make the result unconditionally true, |
|
747
|
+ * which is the bug this return contract exists to fix. |
|
395
|
748
|
*/ |
|
396
|
749
|
private function sync_yoast($post_id, array $data) { |
|
750
|
+ if (!$this->third_party_writes_allowed()) { |
|
751
|
+ return false; |
|
752
|
+ } |
|
753
|
+ |
|
754
|
+ $wrote = false; |
|
755
|
+ |
|
397
|
756
|
// title |
|
398
|
757
|
if (!empty($data['title'])) { |
|
399
|
|
- update_post_meta($post_id, '_yoast_wpseo_title', (string) $data['title']); |
|
758
|
+ $wrote = $this->write_post_field($post_id, '_yoast_wpseo_title', (string) $data['title']); |
|
400
|
759
|
} |
|
401
|
760
|
|
|
402
|
761
|
// description -- strip newlines first |
|
403
|
762
|
if (!empty($data['desc'])) { |
|
404
|
763
|
$desc = str_replace(["\n", "\r", "\t"], ' ', $data['desc']); |
|
405
|
|
- update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc); |
|
764
|
+ $wrote = $this->write_post_field($post_id, '_yoast_wpseo_metadesc', $desc) || $wrote; |
|
406
|
765
|
} |
|
407
|
766
|
|
|
408
|
767
|
// noindex: '0'=default, '1'=noindex, '2'=index |
|
409
|
768
|
if (array_key_exists('noindex', $data)) { |
|
410
|
769
|
$val = $data['noindex'] ? '1' : '2'; |
|
411
|
|
- update_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', $val); |
|
770
|
+ $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-noindex', $val); |
|
412
|
771
|
} |
|
413
|
772
|
|
|
414
|
773
|
// nofollow: '0'=follow, '1'=nofollow |
|
415
|
774
|
if (array_key_exists('nofollow', $data)) { |
|
416
|
|
- update_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', $data['nofollow'] ? '1' : '0'); |
|
775
|
+ $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-nofollow', $data['nofollow'] ? '1' : '0'); |
|
417
|
776
|
} |
|
418
|
777
|
|
|
419
|
778
|
// advanced robots: comma-separated NO spaces |
|
420
|
779
|
$adv = []; |
|
@@ -426,38 +785,41 @@ |
|
426
|
785
|
} |
|
427
|
786
|
if (!empty($data['noimageindex'])) { |
|
428
|
787
|
$adv[] = 'noimageindex'; |
|
429
|
788
|
} |
|
430
|
|
- update_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv)); |
|
789
|
+ // Written unconditionally so clearing the last directive clears the |
|
790
|
+ // field. Like the other robots writes it carries no canonical value, |
|
791
|
+ // so it does not make this a successful sync. |
|
792
|
+ $this->write_post_field($post_id, '_yoast_wpseo_meta-robots-adv', implode(',', $adv)); |
|
431
|
793
|
|
|
432
|
794
|
// OG |
|
433
|
795
|
if (!empty($data['og_title'])) { |
|
434
|
|
- update_post_meta($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']); |
|
796
|
+ $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-title', $data['og_title']) || $wrote; |
|
435
|
797
|
} |
|
436
|
798
|
if (!empty($data['og_desc'])) { |
|
437
|
|
- update_post_meta($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']); |
|
799
|
+ $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-description', $data['og_desc']) || $wrote; |
|
438
|
800
|
} |
|
439
|
801
|
if (!empty($data['og_image'])) { |
|
440
|
|
- update_post_meta($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image'])); |
|
802
|
+ $wrote = $this->write_post_field($post_id, '_yoast_wpseo_opengraph-image', esc_url_raw($data['og_image'])) || $wrote; |
|
441
|
803
|
} |
|
442
|
804
|
|
|
443
|
805
|
// Twitter |
|
444
|
806
|
if (!empty($data['twitter_title'])) { |
|
445
|
|
- update_post_meta($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']); |
|
807
|
+ $wrote = $this->write_post_field($post_id, '_yoast_wpseo_twitter-title', $data['twitter_title']) || $wrote; |
|
446
|
808
|
} |
|
447
|
809
|
if (!empty($data['twitter_desc'])) { |
|
448
|
|
- update_post_meta($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']); |
|
810
|
+ $wrote = $this->write_post_field($post_id, '_yoast_wpseo_twitter-description', $data['twitter_desc']) || $wrote; |
|
449
|
811
|
} |
|
450
|
812
|
|
|
451
|
813
|
// Canonical, focus keyword, breadcrumb |
|
452
|
814
|
if (!empty($data['canonical'])) { |
|
453
|
|
- update_post_meta($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical'])); |
|
815
|
+ $wrote = $this->write_post_field($post_id, '_yoast_wpseo_canonical', esc_url_raw($data['canonical'])) || $wrote; |
|
454
|
816
|
} |
|
455
|
817
|
if (!empty($data['focus_keyword'])) { |
|
456
|
|
- update_post_meta($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']); |
|
818
|
+ $wrote = $this->write_post_field($post_id, '_yoast_wpseo_focuskw', $data['focus_keyword']) || $wrote; |
|
457
|
819
|
} |
|
458
|
820
|
if (!empty($data['breadcrumb_title'])) { |
|
459
|
|
- update_post_meta($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']); |
|
821
|
+ $wrote = $this->write_post_field($post_id, '_yoast_wpseo_bctitle', $data['breadcrumb_title']) || $wrote; |
|
460
|
822
|
} |
|
461
|
823
|
|
|
462
|
824
|
// Update wp_yoast_indexable cache row for immediate effect |
|
463
|
825
|
global $wpdb; |
|
@@ -520,8 +882,31 @@ |
|
520
|
882
|
"SELECT id FROM {$indexable_table} WHERE object_id = %d AND object_type = 'post'", |
|
521
|
883
|
$post_id |
|
522
|
884
|
)); |
|
523
|
885
|
|
|
886
|
+ // This probe decides two things at once: the value of the write-once |
|
887
|
+ // row_existed marker, and whether the write below is an UPDATE or an |
|
888
|
+ // INSERT. A failed probe answers "no row" for a row that is really |
|
889
|
+ // there, which commits a '0' marker telling a restore to delete the |
|
890
|
+ // customer's row and fires an INSERT against a row that already |
|
891
|
+ // exists. Neither is correctable afterwards, so an unreadable probe |
|
892
|
+ // has to abandon the indexable write entirely. |
|
893
|
+ // |
|
894
|
+ // The post meta above has already landed, so the receipt still |
|
895
|
+ // reports it. Only the indexable is skipped. |
|
896
|
+ if (!Metasync_Seo_Backup::db_read_succeeded()) { |
|
897
|
+ return $wrote; |
|
898
|
+ } |
|
899
|
+ |
|
900
|
+ // No original saved means no write, exactly as on the AIOSEO table. |
|
901
|
+ // This row is what Yoast actually renders from, so a restore that |
|
902
|
+ // cannot reach it puts the meta back and leaves the customer's live |
|
903
|
+ // pages still showing OTTO's values. |
|
904
|
+ $backups_created = []; |
|
905
|
+ if (!$this->backup_yoast_indexable_columns($post_id, $indexable_table, $updates, (bool) $row_exists, $backups_created)) { |
|
906
|
+ return $wrote; |
|
907
|
+ } |
|
908
|
+ |
|
524
|
909
|
if ($row_exists) { |
|
525
|
910
|
$wpdb->update( |
|
526
|
911
|
$indexable_table, |
|
527
|
912
|
$updates, |
|
@@ -543,12 +928,137 @@ |
|
543
|
928
|
'is_cornerstone' => 0, |
|
544
|
929
|
'created_at' => current_time('mysql'), |
|
545
|
930
|
'updated_at' => current_time('mysql'), |
|
546
|
931
|
], $updates); |
|
547
|
|
- $wpdb->insert($indexable_table, $insert); |
|
932
|
+ |
|
933
|
+ // The row_existed='0' marker was recorded before this insert, |
|
934
|
+ // because a marker that will not save has to be able to veto the |
|
935
|
+ // write. A failed insert means no row of ours exists, and a |
|
936
|
+ // marker left saying otherwise would let a restore delete a row |
|
937
|
+ // Yoast or the customer creates afterwards. The per-column |
|
938
|
+ // backups recorded beside it go too: they are write-once, so a |
|
939
|
+ // column captured as NULL because there was no row would stay |
|
940
|
+ // NULL for good and blank a real value the customer later puts |
|
941
|
+ // in that row. |
|
942
|
+ // |
|
943
|
+ // The post meta above has already been written, so the return |
|
944
|
+ // still reports what landed. Claiming nothing was written would |
|
945
|
+ // tell the conflict handler this post is unsynced to Yoast while |
|
946
|
+ // Yoast's own meta holds our values, and it would hand tag |
|
947
|
+ // ownership to the wrong plugin. |
|
948
|
+ if ($wpdb->insert($indexable_table, $insert) === false) { |
|
949
|
+ Metasync_Seo_Backup::discard_backups('post', $post_id, $backups_created); |
|
950
|
+ return $wrote; |
|
951
|
+ } |
|
548
|
952
|
} |
|
549
|
953
|
} |
|
550
|
954
|
|
|
955
|
+ return $wrote; |
|
956
|
+ } |
|
957
|
+ |
|
958
|
+ /** |
|
959
|
+ * Preserve the wp_yoast_indexable columns this sync is about to overwrite. |
|
960
|
+ * |
|
961
|
+ * Yoast serves the frontend from this table, not from post meta, so the |
|
962
|
+ * meta backups taken by write_post_field() do not by themselves make the |
|
963
|
+ * change reversible. Same shape as backup_aioseo_columns(): |
|
964
|
+ * |
|
965
|
+ * - one backup per column being written, so a restore can put the originals |
|
966
|
+ * back and leave every other column of the user's row alone; |
|
967
|
+ * - whether the row existed at all, so a restore can delete a row that only |
|
968
|
+ * exists because we created it instead of leaving an empty shell behind. |
|
969
|
+ * |
|
970
|
+ * @param int $post_id Post ID. |
|
971
|
+ * @param string $table Fully prefixed indexable table name. |
|
972
|
+ * @param array $updates Columns and values about to be written. |
|
973
|
+ * @param bool $row_existed Whether Yoast already had a row for this post. |
|
974
|
+ * @param array $created Out-param, filled with the backup fields this |
|
975
|
+ * call created, so a failed write can withdraw |
|
976
|
+ * exactly its own rows and no one else's. |
|
977
|
+ * @return bool True when every original was preserved and the caller may write. |
|
978
|
+ */ |
|
979
|
+ private function backup_yoast_indexable_columns($post_id, $table, array $updates, $row_existed, array &$created) { |
|
980
|
+ $created = []; |
|
981
|
+ |
|
982
|
+ if (!class_exists('Metasync_Seo_Backup')) { |
|
983
|
+ return false; |
|
984
|
+ } |
|
985
|
+ |
|
986
|
+ global $wpdb; |
|
987
|
+ |
|
988
|
+ if (!Metasync_Seo_Backup::record_marker( |
|
989
|
+ 'post', |
|
990
|
+ $post_id, |
|
991
|
+ 'yoast_indexable_row_existed', |
|
992
|
+ $row_existed ? '1' : '0', |
|
993
|
+ $marker_created |
|
994
|
+ )) { |
|
995
|
+ return false; |
|
996
|
+ } |
|
997
|
+ |
|
998
|
+ if ($marker_created) { |
|
999
|
+ $created[] = 'yoast_indexable_row_existed'; |
|
1000
|
+ } |
|
1001
|
+ |
|
1002
|
+ $columns = array_keys($updates); |
|
1003
|
+ if (empty($columns)) { |
|
1004
|
+ return true; |
|
1005
|
+ } |
|
1006
|
+ |
|
1007
|
+ $current = null; |
|
1008
|
+ if ($row_existed) { |
|
1009
|
+ $select = '`' . implode('`, `', array_map('esc_sql', $columns)) . '`'; |
|
1010
|
+ $current = $wpdb->get_row( |
|
1011
|
+ $wpdb->prepare( |
|
1012
|
+ "SELECT {$select} FROM {$table} WHERE object_id = %d AND object_type = 'post'", |
|
1013
|
+ $post_id |
|
1014
|
+ ), |
|
1015
|
+ ARRAY_A |
|
1016
|
+ ); |
|
1017
|
+ |
|
1018
|
+ // The row was there a moment ago, so a null answer now is a failed |
|
1019
|
+ // read, not an empty row. Recording it as "every column was NULL" |
|
1020
|
+ // would tell a later restore to delete values it should put back, |
|
1021
|
+ // which is the exact loss this layer exists to prevent. No write is |
|
1022
|
+ // happening, so the marker recorded above has to go too — a marker |
|
1023
|
+ // left describing a write that never ran is a stale story. |
|
1024
|
+ if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) { |
|
1025
|
+ Metasync_Seo_Backup::discard_backups('post', $post_id, $created); |
|
1026
|
+ return false; |
|
1027
|
+ } |
|
1028
|
+ } |
|
1029
|
+ |
|
1030
|
+ foreach ($columns as $column) { |
|
1031
|
+ // A missing row and a NULL column are the same thing to a restore: |
|
1032
|
+ // there was no value here, so put nothing back. |
|
1033
|
+ $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null; |
|
1034
|
+ |
|
1035
|
+ $field = 'yoast_indexable_' . $column; |
|
1036
|
+ |
|
1037
|
+ // One unsaved column is enough to refuse the whole write: a |
|
1038
|
+ // half-original, half-OTTO row is something no restore can unpick. |
|
1039
|
+ // The refusal also means the caller writes nothing, so withdraw the |
|
1040
|
+ // marker and the columns recorded so far — the same rule as the |
|
1041
|
+ // failed-insert path in sync_yoast(). Left behind, a row_existed='0' |
|
1042
|
+ // marker would let a restore delete a row the customer creates |
|
1043
|
+ // later, and a NULL column backup would blank a real value in it. |
|
1044
|
+ if (!Metasync_Seo_Backup::backup_before_overwrite( |
|
1045
|
+ 'post', |
|
1046
|
+ $post_id, |
|
1047
|
+ $field, |
|
1048
|
+ $updates[$column], |
|
1049
|
+ $current_value, |
|
1050
|
+ $column_created |
|
1051
|
+ )) { |
|
1052
|
+ Metasync_Seo_Backup::discard_backups('post', $post_id, $created); |
|
1053
|
+ return false; |
|
1054
|
+ } |
|
1055
|
+ |
|
1056
|
+ if ($column_created) { |
|
1057
|
+ $created[] = $field; |
|
1058
|
+ } |
|
1059
|
+ } |
|
1060
|
+ |
|
551
|
1061
|
return true; |
|
552
|
1062
|
} |
|
553
|
1063
|
|
|
554
|
1064
|
/** |
|
@@ -555,17 +1065,25 @@ |
|
555
|
1065
|
* Mirror canonical data into Rank Math post meta. |
|
556
|
1066
|
* |
|
557
|
1067
|
* @param int $post_id Post ID. |
|
558
|
1068
|
* @param array $data Canonical key/value pairs. |
|
559
|
|
- * @return bool True once dispatch completes. |
|
1069
|
+ * @return bool True when at least one value-bearing field was mirrored, |
|
1070
|
+ * false when the payload carried nothing to write. Robots |
|
1071
|
+ * directives are excluded for the reason given on sync_yoast(). |
|
560
|
1072
|
*/ |
|
561
|
1073
|
private function sync_rankmath($post_id, array $data) { |
|
1074
|
+ if (!$this->third_party_writes_allowed()) { |
|
1075
|
+ return false; |
|
1076
|
+ } |
|
1077
|
+ |
|
1078
|
+ $wrote = false; |
|
1079
|
+ |
|
562
|
1080
|
// title, desc |
|
563
|
1081
|
if (!empty($data['title'])) { |
|
564
|
|
- update_post_meta($post_id, 'rank_math_title', $data['title']); |
|
1082
|
+ $wrote = $this->write_post_field($post_id, 'rank_math_title', $data['title']); |
|
565
|
1083
|
} |
|
566
|
1084
|
if (!empty($data['desc'])) { |
|
567
|
|
- update_post_meta($post_id, 'rank_math_description', $data['desc']); |
|
1085
|
+ $wrote = $this->write_post_field($post_id, 'rank_math_description', $data['desc']) || $wrote; |
|
568
|
1086
|
} |
|
569
|
1087
|
|
|
570
|
1088
|
// robots: PHP indexed array |
|
571
|
1089
|
if (array_key_exists('noindex', $data) || array_key_exists('nofollow', $data)) { |
|
@@ -577,9 +1095,9 @@ |
|
577
|
1095
|
} |
|
578
|
1096
|
if (array_key_exists('nofollow', $data)) { |
|
579
|
1097
|
$robots[] = $data['nofollow'] ? 'nofollow' : 'follow'; |
|
580
|
1098
|
} |
|
581
|
|
- update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots))); |
|
1099
|
+ $this->write_post_field($post_id, 'rank_math_robots', array_values(array_unique($robots))); |
|
582
|
1100
|
} |
|
583
|
1101
|
|
|
584
|
1102
|
// Advanced robots: max-* go into rank_math_advanced_robots |
|
585
|
1103
|
$adv_keys = ['max_snippet', 'max_image_preview', 'max_video_preview']; |
|
@@ -594,22 +1112,22 @@ |
|
594
|
1112
|
$existing_adv = get_post_meta($post_id, 'rank_math_advanced_robots', true); |
|
595
|
1113
|
$adv = is_array($existing_adv) ? $existing_adv : []; |
|
596
|
1114
|
if (array_key_exists('max_snippet', $data) && $data['max_snippet'] !== null) { |
|
597
|
1115
|
$val = (int) $data['max_snippet']; |
|
598
|
|
- $adv['max-snippet'] = 'max-snippet:' . $val; |
|
1116
|
+ $adv['max-snippet'] = (string) $val; |
|
599
|
1117
|
} |
|
600
|
1118
|
if (array_key_exists('max_image_preview', $data) && $data['max_image_preview'] !== null) { |
|
601
|
1119
|
$allowed = ['none', 'standard', 'large']; |
|
602
|
1120
|
if (in_array($data['max_image_preview'], $allowed, true)) { |
|
603
|
|
- $adv['max-image-preview'] = 'max-image-preview:' . $data['max_image_preview']; |
|
1121
|
+ $adv['max-image-preview'] = (string) $data['max_image_preview']; |
|
604
|
1122
|
} |
|
605
|
1123
|
} |
|
606
|
1124
|
if (array_key_exists('max_video_preview', $data) && $data['max_video_preview'] !== null) { |
|
607
|
1125
|
$val = (int) $data['max_video_preview']; |
|
608
|
|
- $adv['max-video-preview'] = 'max-video-preview:' . $val; |
|
1126
|
+ $adv['max-video-preview'] = (string) $val; |
|
609
|
1127
|
} |
|
610
|
1128
|
if (!empty($adv)) { |
|
611
|
|
- update_post_meta($post_id, 'rank_math_advanced_robots', $adv); |
|
1129
|
+ $this->write_post_field($post_id, 'rank_math_advanced_robots', $adv); |
|
612
|
1130
|
} |
|
613
|
1131
|
} |
|
614
|
1132
|
|
|
615
|
1133
|
// Sync noarchive/nosnippet/noimageindex into rank_math_robots |
|
@@ -624,51 +1142,159 @@ |
|
624
|
1142
|
if (!empty($data[$dir])) { |
|
625
|
1143
|
$robots[] = $dir; |
|
626
|
1144
|
} |
|
627
|
1145
|
} |
|
628
|
|
- update_post_meta($post_id, 'rank_math_robots', array_values(array_unique($robots))); |
|
1146
|
+ $this->write_post_field($post_id, 'rank_math_robots', array_values(array_unique($robots))); |
|
629
|
1147
|
} |
|
630
|
1148
|
|
|
631
|
1149
|
// OG |
|
632
|
1150
|
if (!empty($data['og_title'])) { |
|
633
|
|
- update_post_meta($post_id, 'rank_math_facebook_title', $data['og_title']); |
|
1151
|
+ $wrote = $this->write_post_field($post_id, 'rank_math_facebook_title', $data['og_title']) || $wrote; |
|
634
|
1152
|
} |
|
635
|
1153
|
if (!empty($data['og_desc'])) { |
|
636
|
|
- update_post_meta($post_id, 'rank_math_facebook_description', $data['og_desc']); |
|
1154
|
+ $wrote = $this->write_post_field($post_id, 'rank_math_facebook_description', $data['og_desc']) || $wrote; |
|
637
|
1155
|
} |
|
638
|
1156
|
if (!empty($data['og_image'])) { |
|
639
|
|
- update_post_meta($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image'])); |
|
1157
|
+ $wrote = $this->write_post_field($post_id, 'rank_math_facebook_image', esc_url_raw($data['og_image'])) || $wrote; |
|
640
|
1158
|
$img_id = attachment_url_to_postid($data['og_image']); |
|
641
|
1159
|
if ($img_id) { |
|
642
|
|
- update_post_meta($post_id, 'rank_math_facebook_image_id', $img_id); |
|
1160
|
+ $wrote = $this->write_post_field($post_id, 'rank_math_facebook_image_id', $img_id) || $wrote; |
|
643
|
1161
|
} |
|
644
|
1162
|
} |
|
645
|
1163
|
|
|
646
|
1164
|
// Twitter |
|
647
|
1165
|
if (!empty($data['twitter_title'])) { |
|
648
|
|
- update_post_meta($post_id, 'rank_math_twitter_title', $data['twitter_title']); |
|
1166
|
+ $wrote = $this->write_post_field($post_id, 'rank_math_twitter_title', $data['twitter_title']) || $wrote; |
|
649
|
1167
|
} |
|
650
|
1168
|
if (!empty($data['twitter_desc'])) { |
|
651
|
|
- update_post_meta($post_id, 'rank_math_twitter_description', $data['twitter_desc']); |
|
1169
|
+ $wrote = $this->write_post_field($post_id, 'rank_math_twitter_description', $data['twitter_desc']) || $wrote; |
|
652
|
1170
|
} |
|
653
|
1171
|
if (!empty($data['twitter_card'])) { |
|
654
|
1172
|
$valid_cards = ['summary', 'summary_large_image', 'app', 'player']; |
|
655
|
1173
|
if (in_array($data['twitter_card'], $valid_cards, true)) { |
|
656
|
|
- update_post_meta($post_id, 'rank_math_twitter_card_type', $data['twitter_card']); |
|
1174
|
+ $wrote = $this->write_post_field($post_id, 'rank_math_twitter_card_type', $data['twitter_card']) || $wrote; |
|
657
|
1175
|
} |
|
658
|
1176
|
} |
|
659
|
1177
|
|
|
660
|
1178
|
// Canonical, focus keyword, breadcrumb |
|
661
|
1179
|
if (!empty($data['canonical'])) { |
|
662
|
|
- update_post_meta($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical'])); |
|
1180
|
+ $wrote = $this->write_post_field($post_id, 'rank_math_canonical_url', esc_url_raw($data['canonical'])) || $wrote; |
|
663
|
1181
|
} |
|
664
|
1182
|
if (!empty($data['focus_keyword'])) { |
|
665
|
|
- update_post_meta($post_id, 'rank_math_focus_keyword', $data['focus_keyword']); |
|
1183
|
+ $wrote = $this->write_post_field($post_id, 'rank_math_focus_keyword', $data['focus_keyword']) || $wrote; |
|
666
|
1184
|
} |
|
667
|
1185
|
if (!empty($data['breadcrumb_title'])) { |
|
668
|
|
- update_post_meta($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']); |
|
1186
|
+ $wrote = $this->write_post_field($post_id, 'rank_math_breadcrumb_title', $data['breadcrumb_title']) || $wrote; |
|
669
|
1187
|
} |
|
670
|
1188
|
|
|
1189
|
+ return $wrote; |
|
1190
|
+ } |
|
1191
|
+ |
|
1192
|
+ /** |
|
1193
|
+ * Preserve the AIOSEO columns a sync is about to overwrite. |
|
1194
|
+ * |
|
1195
|
+ * AIOSEO keeps post SEO data in its own `aioseo_posts` table rather than in |
|
1196
|
+ * post meta, so there is no meta row to save and no field a restore could |
|
1197
|
+ * delete. Two things are recorded instead, both as post meta on the post |
|
1198
|
+ * itself: |
|
1199
|
+ * |
|
1200
|
+ * - one backup per column being written, so a restore can put the original |
|
1201
|
+ * values back and leave every other column of the user's row alone; |
|
1202
|
+ * - whether the row existed at all, so a restore can delete a row that only |
|
1203
|
+ * exists because we created it instead of leaving an empty shell behind. |
|
1204
|
+ * |
|
1205
|
+ * @param int $post_id Post ID. |
|
1206
|
+ * @param string $table Fully prefixed AIOSEO table name. |
|
1207
|
+ * @param array $row Columns and values about to be written. |
|
1208
|
+ * @param bool $row_existed Whether AIOSEO already had a row for this post. |
|
1209
|
+ * @param array $created Out-param, filled with the backup fields this |
|
1210
|
+ * call created, so a failed write can withdraw |
|
1211
|
+ * exactly its own rows and no one else's. |
|
1212
|
+ * @return bool True when every original was preserved and the caller may write. |
|
1213
|
+ */ |
|
1214
|
+ private function backup_aioseo_columns($post_id, $table, array $row, $row_existed, array &$created) { |
|
1215
|
+ $created = []; |
|
1216
|
+ |
|
1217
|
+ if (!class_exists('Metasync_Seo_Backup')) { |
|
1218
|
+ return false; |
|
1219
|
+ } |
|
1220
|
+ |
|
1221
|
+ global $wpdb; |
|
1222
|
+ |
|
1223
|
+ // Whether the row pre-existed is what a restore uses to choose between |
|
1224
|
+ // putting the original columns back and deleting a row that only exists |
|
1225
|
+ // because we made it. A marker that will not record is as disqualifying |
|
1226
|
+ // as a column that will not. |
|
1227
|
+ if (!Metasync_Seo_Backup::record_marker( |
|
1228
|
+ 'post', |
|
1229
|
+ $post_id, |
|
1230
|
+ 'aioseo_row_existed', |
|
1231
|
+ $row_existed ? '1' : '0', |
|
1232
|
+ $marker_created |
|
1233
|
+ )) { |
|
1234
|
+ return false; |
|
1235
|
+ } |
|
1236
|
+ |
|
1237
|
+ if ($marker_created) { |
|
1238
|
+ $created[] = 'aioseo_row_existed'; |
|
1239
|
+ } |
|
1240
|
+ |
|
1241
|
+ $columns = array_diff(array_keys($row), ['updated', 'created', 'post_id']); |
|
1242
|
+ if (empty($columns)) { |
|
1243
|
+ return true; |
|
1244
|
+ } |
|
1245
|
+ |
|
1246
|
+ $current = null; |
|
1247
|
+ if ($row_existed) { |
|
1248
|
+ $select = '`' . implode('`, `', array_map('esc_sql', $columns)) . '`'; |
|
1249
|
+ $current = $wpdb->get_row( |
|
1250
|
+ $wpdb->prepare("SELECT {$select} FROM {$table} WHERE post_id = %d", $post_id), |
|
1251
|
+ ARRAY_A |
|
1252
|
+ ); |
|
1253
|
+ |
|
1254
|
+ // The row was there a moment ago, so a null answer now is a failed |
|
1255
|
+ // read, not an empty row. Recording it as "every column was NULL" |
|
1256
|
+ // would tell a later restore to delete values it should put back, |
|
1257
|
+ // which is the exact loss this layer exists to prevent. No write is |
|
1258
|
+ // happening, so the marker recorded above has to go too — a marker |
|
1259
|
+ // left describing a write that never ran is a stale story. |
|
1260
|
+ if ($current === null || !Metasync_Seo_Backup::db_read_succeeded()) { |
|
1261
|
+ Metasync_Seo_Backup::discard_backups('post', $post_id, $created); |
|
1262
|
+ return false; |
|
1263
|
+ } |
|
1264
|
+ } |
|
1265
|
+ |
|
1266
|
+ foreach ($columns as $column) { |
|
1267
|
+ // A missing row and a NULL column are the same thing to a restore: |
|
1268
|
+ // there was no value here, so put nothing back. |
|
1269
|
+ $current_value = ($current !== null && isset($current[$column])) ? $current[$column] : null; |
|
1270
|
+ |
|
1271
|
+ $field = 'aioseo_' . $column; |
|
1272
|
+ |
|
1273
|
+ // One unsaved column is enough to refuse the whole write: a half-original, |
|
1274
|
+ // half-OTTO row is something no restore can unpick. The refusal also |
|
1275
|
+ // means the caller writes nothing, so withdraw the marker and the |
|
1276
|
+ // columns recorded so far — the same rule as the failed-insert path |
|
1277
|
+ // in sync_aioseo(). Left behind, a row_existed='0' marker would let |
|
1278
|
+ // a restore delete a row the customer creates later, and a NULL |
|
1279
|
+ // column backup would blank a real value in it. |
|
1280
|
+ if (!Metasync_Seo_Backup::backup_before_overwrite( |
|
1281
|
+ 'post', |
|
1282
|
+ $post_id, |
|
1283
|
+ $field, |
|
1284
|
+ $row[$column], |
|
1285
|
+ $current_value, |
|
1286
|
+ $column_created |
|
1287
|
+ )) { |
|
1288
|
+ Metasync_Seo_Backup::discard_backups('post', $post_id, $created); |
|
1289
|
+ return false; |
|
1290
|
+ } |
|
1291
|
+ |
|
1292
|
+ if ($column_created) { |
|
1293
|
+ $created[] = $field; |
|
1294
|
+ } |
|
1295
|
+ } |
|
1296
|
+ |
|
671
|
1297
|
return true; |
|
672
|
1298
|
} |
|
673
|
1299
|
|
|
674
|
1300
|
/** |
|
@@ -675,14 +1301,21 @@ |
|
675
|
1301
|
* Mirror canonical data into the AIOSEO wp_aioseo_posts custom table. |
|
676
|
1302
|
* |
|
677
|
1303
|
* @param int $post_id Post ID. |
|
678
|
1304
|
* @param array $data Canonical key/value pairs. |
|
679
|
|
- * @return bool True when the row was written, false when the table is |
|
680
|
|
- * missing or the write failed. |
|
1305
|
+ * @return bool True when at least one value-bearing field was written, |
|
1306
|
+ * false when the table is missing, the write failed, or the |
|
1307
|
+ * payload carried only robots directives. Robots are excluded |
|
1308
|
+ * for the reason given on sync_yoast(), so the receipt means |
|
1309
|
+ * the same thing for all three plugins. |
|
681
|
1310
|
*/ |
|
682
|
1311
|
private function sync_aioseo($post_id, array $data) { |
|
683
|
1312
|
global $wpdb; |
|
684
|
1313
|
|
|
1314
|
+ if (!$this->third_party_writes_allowed()) { |
|
1315
|
+ return false; |
|
1316
|
+ } |
|
1317
|
+ |
|
685
|
1318
|
$table = $wpdb->prefix . 'aioseo_posts'; |
|
686
|
1319
|
|
|
687
|
1320
|
// Bail if the AIOSEO post table does not exist (plugin not initialised). |
|
688
|
1321
|
$table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); |
|
@@ -690,39 +1323,51 @@ |
|
690
|
1323
|
return false; |
|
691
|
1324
|
} |
|
692
|
1325
|
|
|
693
|
1326
|
$row = []; |
|
1327
|
+ // Robots fill $row too, so a separate flag is needed: the row is still |
|
1328
|
+ // written for a robots-only payload, it just is not a content sync. |
|
1329
|
+ $wrote = false; |
|
694
|
1330
|
|
|
695
|
1331
|
if (!empty($data['title'])) { |
|
696
|
1332
|
$row['title'] = sanitize_text_field($data['title']); |
|
1333
|
+ $wrote = true; |
|
697
|
1334
|
} |
|
698
|
1335
|
if (!empty($data['desc'])) { |
|
699
|
1336
|
$row['description'] = sanitize_text_field($data['desc']); |
|
1337
|
+ $wrote = true; |
|
700
|
1338
|
} |
|
701
|
1339
|
if (!empty($data['og_title'])) { |
|
702
|
1340
|
$row['og_title'] = sanitize_text_field($data['og_title']); |
|
1341
|
+ $wrote = true; |
|
703
|
1342
|
} |
|
704
|
1343
|
if (!empty($data['og_desc'])) { |
|
705
|
1344
|
$row['og_description'] = sanitize_text_field($data['og_desc']); |
|
1345
|
+ $wrote = true; |
|
706
|
1346
|
} |
|
707
|
1347
|
if (!empty($data['og_image'])) { |
|
708
|
1348
|
$row['og_image_type'] = 'custom'; |
|
709
|
1349
|
$row['og_image_custom_url'] = esc_url_raw($data['og_image']); |
|
1350
|
+ $wrote = true; |
|
710
|
1351
|
} |
|
711
|
1352
|
if (!empty($data['twitter_title'])) { |
|
712
|
1353
|
$row['twitter_title'] = sanitize_text_field($data['twitter_title']); |
|
1354
|
+ $wrote = true; |
|
713
|
1355
|
} |
|
714
|
1356
|
if (!empty($data['twitter_desc'])) { |
|
715
|
1357
|
$row['twitter_description'] = sanitize_text_field($data['twitter_desc']); |
|
1358
|
+ $wrote = true; |
|
716
|
1359
|
} |
|
717
|
1360
|
if (!empty($data['twitter_card'])) { |
|
718
|
1361
|
$valid_cards = ['default', 'summary', 'summary_large_image', 'player', 'app']; |
|
719
|
1362
|
if (in_array($data['twitter_card'], $valid_cards, true)) { |
|
720
|
1363
|
$row['twitter_card'] = $data['twitter_card']; |
|
1364
|
+ $wrote = true; |
|
721
|
1365
|
} |
|
722
|
1366
|
} |
|
723
|
1367
|
if (!empty($data['canonical'])) { |
|
724
|
1368
|
$row['canonical_url'] = esc_url_raw($data['canonical']); |
|
1369
|
+ $wrote = true; |
|
725
|
1370
|
} |
|
726
|
1371
|
|
|
727
|
1372
|
// focus keyword as keyphrases JSON |
|
728
|
1373
|
if (!empty($data['focus_keyword'])) { |
|
@@ -733,8 +1378,9 @@ |
|
733
|
1378
|
'analysis' => new \stdClass(), |
|
734
|
1379
|
], |
|
735
|
1380
|
'additional' => [], |
|
736
|
1381
|
]); |
|
1382
|
+ $wrote = true; |
|
737
|
1383
|
} |
|
738
|
1384
|
|
|
739
|
1385
|
// Robots |
|
740
|
1386
|
$has_robots = false; |
|
@@ -785,12 +1431,33 @@ |
|
785
|
1431
|
"SELECT id FROM {$table} WHERE post_id = %d", |
|
786
|
1432
|
$post_id |
|
787
|
1433
|
)); |
|
788
|
1434
|
|
|
1435
|
+ // An unreadable probe cannot be treated as "no row". It would commit a |
|
1436
|
+ // write-once row_existed='0' for a row AIOSEO really has -- which a |
|
1437
|
+ // restore reads as licence to delete it -- and send an INSERT at a row |
|
1438
|
+ // that already exists. Leave AIOSEO's row alone and let the next sync |
|
1439
|
+ // record the truth. |
|
1440
|
+ if (!Metasync_Seo_Backup::db_read_succeeded()) { |
|
1441
|
+ return false; |
|
1442
|
+ } |
|
1443
|
+ |
|
1444
|
+ // No original saved means no write. Overwriting anyway is the data loss |
|
1445
|
+ // this whole layer exists to prevent. |
|
789
|
1446
|
if ($existing_id) { |
|
790
|
|
- return $wpdb->update($table, $row, ['post_id' => $post_id]) !== false; |
|
1447
|
+ $backups_created = []; |
|
1448
|
+ if (!$this->backup_aioseo_columns($post_id, $table, $row, true, $backups_created)) { |
|
1449
|
+ return false; |
|
1450
|
+ } |
|
1451
|
+ |
|
1452
|
+ return ($wpdb->update($table, $row, ['post_id' => $post_id]) !== false) && $wrote; |
|
791
|
1453
|
} |
|
792
|
1454
|
|
|
1455
|
+ $backups_created = []; |
|
1456
|
+ if (!$this->backup_aioseo_columns($post_id, $table, $row, false, $backups_created)) { |
|
1457
|
+ return false; |
|
1458
|
+ } |
|
1459
|
+ |
|
793
|
1460
|
// New row -- must include all NOT NULL columns with no defaults |
|
794
|
1461
|
$row['post_id'] = $post_id; |
|
795
|
1462
|
$row['created'] = current_time('mysql'); |
|
796
|
1463
|
$robot_defaults = [ |
|
@@ -804,9 +1471,23 @@ |
|
804
|
1471
|
'robots_notranslate' => 0, |
|
805
|
1472
|
]; |
|
806
|
1473
|
$row = array_merge($robot_defaults, $row); |
|
807
|
1474
|
|
|
808
|
|
- return $wpdb->insert($table, $row) !== false; |
|
1475
|
+ $inserted = $wpdb->insert($table, $row); |
|
1476
|
+ |
|
1477
|
+ // The row_existed='0' marker was recorded before the insert, because a |
|
1478
|
+ // marker that will not save has to be able to veto the write. If the |
|
1479
|
+ // insert then failed there is no row of ours, and leaving the marker |
|
1480
|
+ // behind would let a restore delete a row the customer creates later. |
|
1481
|
+ // The per-column backups beside it go too, or a column captured as NULL |
|
1482
|
+ // for a row that never existed would blank a real value the customer |
|
1483
|
+ // later puts in one. |
|
1484
|
+ if ($inserted === false) { |
|
1485
|
+ Metasync_Seo_Backup::discard_backups('post', $post_id, $backups_created); |
|
1486
|
+ return false; |
|
1487
|
+ } |
|
1488
|
+ |
|
1489
|
+ return $wrote; |
|
809
|
1490
|
} |
|
810
|
1491
|
|
|
811
|
1492
|
// ------------------------------------------------------------------ |
|
812
|
1493
|
// Two-way sync: sidebar JSON ↔ legacy meta boxes |
|
@@ -825,8 +1506,15 @@ |
|
825
|
1506
|
static $syncing_legacy = []; |
|
826
|
1507
|
if (!empty($syncing_legacy[$post_id])) { |
|
827
|
1508
|
return; |
|
828
|
1509
|
} |
|
1510
|
+ // Our own sync_legacy_to_json() write triggered this. The legacy meta |
|
1511
|
+ // box values are the source of truth in that direction, and mirroring |
|
1512
|
+ // back would drop any value this function treats as a default (-1 / |
|
1513
|
+ // large), so bail out and leave the legacy keys alone. |
|
1514
|
+ if (!empty($this->syncing_legacy_to_json[$post_id])) { |
|
1515
|
+ return; |
|
1516
|
+ } |
|
829
|
1517
|
$syncing_legacy[$post_id] = true; |
|
830
|
1518
|
// Block sync_legacy_to_json from running while we write legacy keys |
|
831
|
1519
|
$this->syncing_json_to_legacy[$post_id] = true; |
|
832
|
1520
|
|
|
@@ -893,8 +1581,10 @@ |
|
893
|
1581
|
if (!empty($syncing_json[$post_id])) { |
|
894
|
1582
|
return; |
|
895
|
1583
|
} |
|
896
|
1584
|
$syncing_json[$post_id] = true; |
|
1585
|
+ // Block sync_to_legacy_meta from reacting to the JSON write below |
|
1586
|
+ $this->syncing_legacy_to_json[$post_id] = true; |
|
897
|
1587
|
|
|
898
|
1588
|
try { |
|
899
|
1589
|
$common = get_post_meta($post_id, 'metasync_common_robots', true); |
|
900
|
1590
|
if (!is_array($common)) { |
|
@@ -938,7 +1628,8 @@ |
|
938
|
1628
|
delete_post_meta($post_id, '_metasync_robots_advanced'); |
|
939
|
1629
|
} |
|
940
|
1630
|
} finally { |
|
941
|
1631
|
unset($syncing_json[$post_id]); |
|
1632
|
+ unset($this->syncing_legacy_to_json[$post_id]); |
|
942
|
1633
|
} |
|
943
|
1634
|
} |
|
944
|
1635
|
} |