PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.25
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.25
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-precedence.php

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

455 lines 19.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The one place that decides which stored SEO value wins.
4 *
5 * MetaSync keeps several values for the same field and has to pick one. The
6 * order was previously restated at every call site — the render filters, the
7 * outbound sync payload, the admin columns, SEO Health, the meta box and the
8 * unified SEO suite each carried their own copy. They had already drifted, and
9 * a bulk import landing one tier too high is invisible until a customer notices
10 * their pages stopped taking OTTO's recommendation.
11 *
12 * The order lives here now, once. Call sites ask for a chain or a resolved
13 * value; they no longer restate what beats what.
14 *
15 * ── Posts ────────────────────────────────────────────────────────────────
16 * _metasync_seo_title the customer typed it in the sidebar or box
17 * _metasync_metatitle OTTO's value, persisted by the sync
18 * _metasync_otto_title OTTO's value for this request
19 * _metasync_imported_seo_title brought in from another SEO plugin
20 *
21 * ── Terms ────────────────────────────────────────────────────────────────
22 * _metasync_metatitle set deliberately, by the MCP taxonomy tool
23 * _metasync_imported_seo_title brought in from another SEO plugin
24 *
25 * The same key sits in a different tier on each object type, which is safe
26 * because the two chains never read each other and terms carry no OTTO meta at
27 * all: OTTO reaches a taxonomy archive through the output-buffer pass, which
28 * runs after the render filters rather than through a stored value.
29 *
30 * The invariant the whole ticket rests on: an imported value is last. It is
31 * migration data, not a per-post decision by the customer, so it renders only
32 * where nothing else has anything to say.
33 *
34 * @package Search Atlas SEO
35 */
36
37 // Abort if this file is accessed directly.
38 if (!defined('ABSPATH')) {
39 exit;
40 }
41
42 final class Metasync_Seo_Precedence
43 {
44 const TYPE_POST = 'post';
45 const TYPE_TERM = 'term';
46
47 const FIELD_TITLE = 'title';
48 const FIELD_DESCRIPTION = 'description';
49
50 /**
51 * Social fields.
52 *
53 * These carry their own values rather than deriving from the page title and
54 * description, so they need their own chains: a post can have an OG title
55 * and no SEO title, and asking the page-title chain about it answers "we
56 * hold nothing" for a value we do hold.
57 */
58 const FIELD_OG_TITLE = 'og_title';
59 const FIELD_OG_DESCRIPTION = 'og_description';
60 const FIELD_TWITTER_TITLE = 'twitter_title';
61 const FIELD_TWITTER_DESCRIPTION = 'twitter_description';
62 const FIELD_OG_IMAGE = 'og_image';
63 const FIELD_TWITTER_IMAGE = 'twitter_image';
64
65 /**
66 * Source labels, as the admin surfaces render them. An empty label means a
67 * value MetaSync itself owns and needs no attribution.
68 */
69 const SOURCE_METASYNC = '';
70 const SOURCE_OTTO = 'OTTO';
71 const SOURCE_IMPORTED = 'Imported';
72
73 /**
74 * Tier that holds OTTO's value once the sync has persisted it.
75 *
76 * Named because one caller deliberately leaves it out — see chain().
77 */
78 const KEY_PERSISTED_OTTO_TITLE = '_metasync_metatitle';
79 const KEY_PERSISTED_OTTO_DESC = '_metasync_metadesc';
80
81 /**
82 * The lowest tier: values brought in from another SEO plugin.
83 *
84 * Named so the importer writes "the last tier" rather than a literal it
85 * could get wrong, and so the sidebar can expose the same key to JS without
86 * a second copy of the string.
87 */
88 const KEY_IMPORTED_TITLE = '_metasync_imported_seo_title';
89 const KEY_IMPORTED_DESC = '_metasync_imported_seo_desc';
90
91 /**
92 * Social values brought in from another SEO plugin.
93 *
94 * Social fields need their own imported keys for the same reason the page
95 * title and description do: an import is not a per-post decision by the
96 * customer, so it must not land on the key that means one. Re-running an
97 * import with "overwrite existing" replaces these, never what the customer
98 * typed.
99 */
100 const KEY_IMPORTED_OG_TITLE = '_metasync_imported_og_title';
101 const KEY_IMPORTED_OG_DESC = '_metasync_imported_og_desc';
102 const KEY_IMPORTED_OG_IMAGE = '_metasync_imported_og_image';
103 const KEY_IMPORTED_TWITTER_TITLE = '_metasync_imported_twitter_title';
104 const KEY_IMPORTED_TWITTER_DESC = '_metasync_imported_twitter_desc';
105 const KEY_IMPORTED_TWITTER_IMAGE = '_metasync_imported_twitter_image';
106
107 /**
108 * The ordered key => source label map for every object type and field.
109 *
110 * @return array<string,array<string,array<string,string>>>
111 */
112 private static function table() {
113 return [
114 self::TYPE_POST => [
115 self::FIELD_TITLE => [
116 '_metasync_seo_title' => self::SOURCE_METASYNC,
117 '_metasync_metatitle' => self::SOURCE_METASYNC,
118 '_metasync_otto_title' => self::SOURCE_OTTO,
119 self::KEY_IMPORTED_TITLE => self::SOURCE_IMPORTED,
120 ],
121 self::FIELD_DESCRIPTION => [
122 '_metasync_seo_desc' => self::SOURCE_METASYNC,
123 '_metasync_metadesc' => self::SOURCE_METASYNC,
124 '_metasync_otto_description' => self::SOURCE_OTTO,
125 self::KEY_IMPORTED_DESC => self::SOURCE_IMPORTED,
126 ],
127 // Social chains mirror the order Metasync_OpenGraph resolves in
128 // — what the customer set, then OTTO's staging key, then a value
129 // brought in from another SEO plugin — so the tag we hand a
130 // third-party plugin is the one we would have emitted ourselves.
131 self::FIELD_OG_TITLE => [
132 '_metasync_og_title' => self::SOURCE_METASYNC,
133 '_metasync_otto_og_title' => self::SOURCE_OTTO,
134 self::KEY_IMPORTED_OG_TITLE => self::SOURCE_IMPORTED,
135 ],
136 self::FIELD_OG_DESCRIPTION => [
137 '_metasync_og_description' => self::SOURCE_METASYNC,
138 '_metasync_otto_og_description' => self::SOURCE_OTTO,
139 self::KEY_IMPORTED_OG_DESC => self::SOURCE_IMPORTED,
140 ],
141 self::FIELD_TWITTER_TITLE => [
142 '_metasync_twitter_title' => self::SOURCE_METASYNC,
143 '_metasync_otto_twitter_title' => self::SOURCE_OTTO,
144 self::KEY_IMPORTED_TWITTER_TITLE => self::SOURCE_IMPORTED,
145 ],
146 self::FIELD_TWITTER_DESCRIPTION => [
147 '_metasync_twitter_description' => self::SOURCE_METASYNC,
148 '_metasync_otto_twitter_description' => self::SOURCE_OTTO,
149 self::KEY_IMPORTED_TWITTER_DESC => self::SOURCE_IMPORTED,
150 ],
151 // Images carry no OTTO staging key; the emitter's own featured-image
152 // fallback sits below these.
153 self::FIELD_OG_IMAGE => [
154 '_metasync_og_image' => self::SOURCE_METASYNC,
155 self::KEY_IMPORTED_OG_IMAGE => self::SOURCE_IMPORTED,
156 ],
157 self::FIELD_TWITTER_IMAGE => [
158 '_metasync_twitter_image' => self::SOURCE_METASYNC,
159 self::KEY_IMPORTED_TWITTER_IMAGE => self::SOURCE_IMPORTED,
160 ],
161 ],
162 self::TYPE_TERM => [
163 // OTTO does write per-term values — _metasync_otto_title and
164 // _metasync_otto_description exist in term meta — so terms carry
165 // the same three tiers a post does. Leaving OTTO out put an
166 // imported value above an OTTO one, the exact inversion this
167 // class exists to prevent.
168 self::FIELD_TITLE => [
169 self::KEY_PERSISTED_OTTO_TITLE => self::SOURCE_METASYNC,
170 '_metasync_otto_title' => self::SOURCE_OTTO,
171 self::KEY_IMPORTED_TITLE => self::SOURCE_IMPORTED,
172 ],
173 self::FIELD_DESCRIPTION => [
174 self::KEY_PERSISTED_OTTO_DESC => self::SOURCE_METASYNC,
175 '_metasync_otto_description' => self::SOURCE_OTTO,
176 self::KEY_IMPORTED_DESC => self::SOURCE_IMPORTED,
177 ],
178 ],
179 ];
180 }
181
182 /**
183 * The key an import writes to — always the lowest tier.
184 *
185 * @param string $field FIELD_TITLE or FIELD_DESCRIPTION.
186 * @return string '' for fields that have no imported tier.
187 */
188 public static function imported_key($field) {
189 $map = [
190 self::FIELD_TITLE => self::KEY_IMPORTED_TITLE,
191 self::FIELD_DESCRIPTION => self::KEY_IMPORTED_DESC,
192 self::FIELD_OG_TITLE => self::KEY_IMPORTED_OG_TITLE,
193 self::FIELD_OG_DESCRIPTION => self::KEY_IMPORTED_OG_DESC,
194 self::FIELD_OG_IMAGE => self::KEY_IMPORTED_OG_IMAGE,
195 self::FIELD_TWITTER_TITLE => self::KEY_IMPORTED_TWITTER_TITLE,
196 self::FIELD_TWITTER_DESCRIPTION => self::KEY_IMPORTED_TWITTER_DESC,
197 self::FIELD_TWITTER_IMAGE => self::KEY_IMPORTED_TWITTER_IMAGE,
198 ];
199
200 return isset($map[$field]) ? $map[$field] : '';
201 }
202
203 /**
204 * The ordered chain for a field, highest tier first.
205 *
206 * Options, all defaulting to the full chain:
207 * 'include_otto' false drops both OTTO tiers. Used where OTTO
208 * has stood down for the object, so naming its
209 * stored value would report one the page does
210 * not use.
211 * 'include_persisted_otto' false drops only the persisted tier. The admin
212 * columns have always read the volatile key
213 * alone; kept as an explicit opt-out rather than
214 * changed silently as part of consolidating.
215 * 'include_imported' false drops the imported tier.
216 *
217 * @param string $field FIELD_TITLE or FIELD_DESCRIPTION.
218 * @param string $object_type TYPE_POST or TYPE_TERM.
219 * @param array $options See above.
220 * @return array<string,string> Ordered meta key => source label.
221 */
222 public static function chain($field, $object_type = self::TYPE_POST, array $options = []) {
223 $table = self::table();
224
225 if (!isset($table[$object_type][$field])) {
226 return [];
227 }
228
229 $chain = $table[$object_type][$field];
230
231 $include_otto = array_key_exists('include_otto', $options) ? (bool) $options['include_otto'] : true;
232 $include_metasync = array_key_exists('include_metasync', $options) ? (bool) $options['include_metasync'] : true;
233 $include_persisted = array_key_exists('include_persisted_otto', $options) ? (bool) $options['include_persisted_otto'] : true;
234 $include_imported = array_key_exists('include_imported', $options) ? (bool) $options['include_imported'] : true;
235
236 $persisted_key = $field === self::FIELD_TITLE
237 ? self::KEY_PERSISTED_OTTO_TITLE
238 : self::KEY_PERSISTED_OTTO_DESC;
239
240 foreach ($chain as $key => $source) {
241 // A tier labelled OTTO is OTTO's on either object type. The
242 // persisted key is only OTTO's on a post — on a term it holds the
243 // deliberately-set value, so the OTTO opt-out must not reach it.
244 $is_otto_tier = $source === self::SOURCE_OTTO
245 || ($object_type === self::TYPE_POST && $key === $persisted_key);
246
247 if (!$include_otto && $is_otto_tier) {
248 unset($chain[$key]);
249 continue;
250 }
251
252 if (!$include_persisted && $object_type === self::TYPE_POST && $key === $persisted_key) {
253 unset($chain[$key]);
254 continue;
255 }
256
257 if (!$include_imported && $source === self::SOURCE_IMPORTED) {
258 unset($chain[$key]);
259 continue;
260 }
261
262 // Drops the tier MetaSync itself owns. Used where a non-empty value
263 // does not prove intent: the OG meta box pre-fills its fields from the
264 // post and persists those defaults on save, so the caller checks that
265 // separately and asks for the rest of the chain.
266 if (!$include_metasync && $source === self::SOURCE_METASYNC) {
267 unset($chain[$key]);
268 }
269 }
270
271 return $chain;
272 }
273
274 /**
275 * The ordered meta keys for a field, highest tier first.
276 *
277 * @param string $field
278 * @param string $object_type
279 * @param array $options
280 * @return string[]
281 */
282 public static function keys($field, $object_type = self::TYPE_POST, array $options = []) {
283 return array_keys(self::chain($field, $object_type, $options));
284 }
285
286 /**
287 * Resolve the value that actually applies to an object.
288 *
289 * When 'include_otto' is not given, it is answered from the per-post
290 * "Disable OTTO" toggle: with OTTO switched off its stored suggestion is
291 * dead data, and serving it would leak SEO OTTO has stood down from.
292 *
293 * @param int $object_id
294 * @param string $field
295 * @param string $object_type
296 * @param array $options
297 * @return array{key:string,value:string,source:string} Empty strings when
298 * MetaSync holds nothing for this field.
299 */
300 public static function resolve($object_id, $field, $object_type = self::TYPE_POST, array $options = []) {
301 $object_id = (int) $object_id;
302 $empty = ['key' => '', 'value' => '', 'source' => ''];
303
304 if ($object_id <= 0) {
305 return $empty;
306 }
307
308 if (!array_key_exists('include_otto', $options) && $object_type === self::TYPE_POST) {
309 $options['include_otto'] = !self::otto_is_disabled($object_id);
310 }
311
312 foreach (self::chain($field, $object_type, $options) as $key => $source) {
313 $value = self::read($object_id, $object_type, $key);
314
315 if ($value !== '') {
316 return ['key' => $key, 'value' => $value, 'source' => $source];
317 }
318 }
319
320 return $empty;
321 }
322
323 /**
324 * The value that applies, or '' when MetaSync holds nothing.
325 *
326 * @param int $object_id
327 * @param string $field
328 * @param string $object_type
329 * @param array $options
330 * @return string
331 */
332 public static function value($object_id, $field, $object_type = self::TYPE_POST, array $options = []) {
333 $resolved = self::resolve($object_id, $field, $object_type, $options);
334
335 return $resolved['value'];
336 }
337
338 /**
339 * The value that would apply if the customer had not set one.
340 *
341 * This is the greyed-out placeholder the editing surfaces show: OTTO's
342 * suggestion, or the imported value where OTTO is silent. It is what the
343 * page will actually render once the field is left blank, so it belongs on
344 * the same chain rather than being assembled per screen.
345 *
346 * @param int $object_id
347 * @param string $field
348 * @param string $object_type
349 * @return array{key:string,value:string,source:string}
350 */
351 public static function fallback($object_id, $field, $object_type = self::TYPE_POST) {
352 $empty = ['key' => '', 'value' => '', 'source' => ''];
353
354 $object_id = (int) $object_id;
355 if ($object_id <= 0) {
356 return $empty;
357 }
358
359 $chain = self::chain($field, $object_type);
360
361 // Drop the top tier — the one the customer owns — and resolve the rest.
362 array_shift($chain);
363
364 if (empty($chain)) {
365 return $empty;
366 }
367
368 $include_otto = $object_type !== self::TYPE_POST || !self::otto_is_disabled($object_id);
369
370 // Only a post can have OTTO switched off, so reaching this with
371 // $include_otto false already means TYPE_POST — no need to re-check it.
372 foreach ($chain as $key => $source) {
373 if (!$include_otto && $source !== self::SOURCE_IMPORTED) {
374 continue;
375 }
376
377 $value = self::read($object_id, $object_type, $key);
378 if ($value !== '') {
379 return ['key' => $key, 'value' => $value, 'source' => $source];
380 }
381 }
382
383 return $empty;
384 }
385
386 /**
387 * Read one meta value for either object type.
388 *
389 * @param int $object_id
390 * @param string $object_type
391 * @param string $key
392 * @return string '' when unset or not a string.
393 */
394 private static function read($object_id, $object_type, $key) {
395 $value = $object_type === self::TYPE_TERM
396 ? get_term_meta($object_id, $key, true)
397 : get_post_meta($object_id, $key, true);
398
399 if (empty($value) || !is_string($value)) {
400 return '';
401 }
402
403 return self::strip_placeholder($object_type, $key, $value);
404 }
405
406 /**
407 * Collapse the "Auto Draft" placeholder on the social keys that can capture it.
408 *
409 * The meta box pre-fills its social title/description fields from the post title
410 * and persists them on save, so a brand-new post can store WordPress's own
411 * placeholder there. A truthy placeholder would stop the walk at the tier that
412 * holds it, which puts a value the customer never chose above OTTO's — the
413 * inversion this class exists to prevent. Collapsing it to '' here, rather than
414 * at each consumer, keeps one chain that every call site sees the same way.
415 *
416 * Posts only: the prone keys are meta box keys and terms carry none of them.
417 *
418 * method_exists (not just class_exists) because this runs on front-end requests
419 * and a partially updated install can pair a newer includes/ file with an older
420 * class-metasync-opengraph.php, where calling a method the loaded class doesn't
421 * define would fatal the page rather than degrade.
422 *
423 * @param string $object_type
424 * @param string $key
425 * @param string $value
426 * @return string
427 */
428 private static function strip_placeholder($object_type, $key, $value) {
429 if ($object_type !== self::TYPE_POST) {
430 return $value;
431 }
432
433 // @phpstan-ignore-next-line function.alreadyNarrowedType
434 if (!method_exists('Metasync_OpenGraph', 'strip_auto_draft_title')
435 || !defined('Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS')
436 || !in_array($key, Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS, true)
437 ) {
438 return $value;
439 }
440
441 return Metasync_OpenGraph::strip_auto_draft_title($value);
442 }
443
444 /**
445 * Whether the per-post "Disable OTTO" toggle is set.
446 *
447 * @param int $post_id
448 * @return bool
449 */
450 private static function otto_is_disabled($post_id) {
451 return class_exists('Metasync_Otto_Frontend_Toolbar')
452 && Metasync_Otto_Frontend_Toolbar::is_otto_disabled((int) $post_id);
453 }
454 }
455