PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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.7.2, at includes/class-metasync-seo-precedence.php

861 lines 37.6 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_otto_title OTTO's value for this request
18 * _metasync_metatitle OTTO's value, persisted by the sync
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 * The focus keyword.
67 *
68 * It reached the front end through one raw key rather than a chain, so
69 * OTTO's suggestion was only ever rendered once something had copied it
70 * into the customer tier. Every other field OTTO fills resolves through
71 * this class and reads OTTO's staging key directly; this one now does too.
72 *
73 * "Focus keyword" names the field, not the shape of the value. The OTTO
74 * tier holds the whole comma-separated content string scraped out of the
75 * payload's keywords tag, not a single keyphrase — do not wire this chain
76 * straight into a single-keyphrase field such as rank_math_focus_keyword or
77 * _yoast_wpseo_focuskw without deciding how to narrow it first.
78 *
79 * Three tiers, mirroring title: the customer key, OTTO's volatile staging
80 * key and a persisted-OTTO key of its own (_metasync_metakeywords), so
81 * chain() can label the persisted tier OTTO's and drop it under
82 * include_otto=false. Previously the meta_keywords persistence flag wrote
83 * its copy to _metasync_focus_keyword — the customer key itself — so the
84 * Disable-OTTO opt-out could not reach it. Installs that ran with the flag
85 * on keep those legacy values where they are, treated as customer-owned.
86 */
87 const FIELD_FOCUS_KEYWORD = 'focus_keyword';
88
89 /**
90 * Canonical URL.
91 *
92 * No imported tier: an import is migration data for a value the customer
93 * decides, and the importer writes its canonical straight into
94 * _metasync_canonical_url rather than a tier of its own.
95 *
96 * OTTO's staging key sits below both manual keys, not between them: OTTO's
97 * SSR injection stands down whenever either _metasync_canonical_url or
98 * meta_canonical holds a value, so a chain that ranked the staging key
99 * above the meta box would render OTTO's suggestion on the non-SSR paths
100 * and the customer's canonical on SSR — the same path-dependent canonical
101 * the staging key exists to eliminate. The order here mirrors the SSR
102 * stand-down order literally.
103 */
104 const FIELD_CANONICAL = 'canonical';
105
106 /**
107 * Source labels, as the admin surfaces render them. An empty label means a
108 * value MetaSync itself owns and needs no attribution.
109 */
110 const SOURCE_METASYNC = '';
111 const SOURCE_OTTO = 'OTTO';
112 const SOURCE_IMPORTED = 'Imported';
113
114 /**
115 * Tier that holds OTTO's value once the sync has persisted it.
116 *
117 * Named because one caller deliberately leaves it out — see chain().
118 */
119 const KEY_PERSISTED_OTTO_TITLE = '_metasync_metatitle';
120 const KEY_PERSISTED_OTTO_DESC = '_metasync_metadesc';
121 const KEY_PERSISTED_OTTO_KEYWORD = '_metasync_metakeywords';
122
123 /**
124 * The one tier whose printing is owned by another emitter:
125 * metasync_output_otto_meta_description() prints this key on the wp_head
126 * paths, while the sidebar emitter prints every other tier. Naming the
127 * key lets both emitters state that ownership rule against the class
128 * instead of restating the literal. See the guard comments in
129 * Metasync_SEO_Sidebar::output_seo_meta_description() and in
130 * metasync_output_otto_meta_description().
131 */
132 const KEY_OTTO_DESC = '_metasync_otto_description';
133
134 /**
135 * The lowest tier: values brought in from another SEO plugin.
136 *
137 * Named so the importer writes "the last tier" rather than a literal it
138 * could get wrong, and so the sidebar can expose the same key to JS without
139 * a second copy of the string.
140 */
141 const KEY_IMPORTED_TITLE = '_metasync_imported_seo_title';
142 const KEY_IMPORTED_DESC = '_metasync_imported_seo_desc';
143
144 /**
145 * The tier the customer typed into the sidebar or meta box.
146 *
147 * Named because the global priority setting moves exactly these keys —
148 * this pair plus the keyword's own below. The other key labelled
149 * SOURCE_METASYNC on a post — the persisted _metasync_metatitle /
150 * _metasync_metadesc pair — holds OTTO's own value written by the sync, so
151 * demoting it under "prioritize OTTO" would push an OTTO value below
152 * OTTO. See demote_custom_tier().
153 */
154 const KEY_CUSTOM_TITLE = '_metasync_seo_title';
155 const KEY_CUSTOM_DESC = '_metasync_seo_desc';
156
157 /**
158 * The customer's focus keyword, moved by the same global priority setting.
159 *
160 * Unlike the pair above this key has no persisted-OTTO twin: OTTO's own
161 * keyword lives only in its staging key, so there is no second
162 * SOURCE_METASYNC-labelled tier to keep clear of. The one caveat is the
163 * meta_keywords persistence flag, which copies OTTO's value straight into
164 * this key — a copy the chain cannot tell apart from typed input. See
165 * demote_custom_tier().
166 */
167 const KEY_CUSTOM_KEYWORD = '_metasync_focus_keyword';
168
169 /**
170 * Option value that hands precedence to OTTO. Anything else, including an
171 * unset or malformed option, means custom-first — the behaviour every site
172 * had before the setting existed.
173 */
174 const PRIORITY_OTTO = 'otto';
175
176 /**
177 * Social values brought in from another SEO plugin.
178 *
179 * Social fields need their own imported keys for the same reason the page
180 * title and description do: an import is not a per-post decision by the
181 * customer, so it must not land on the key that means one. Re-running an
182 * import with "overwrite existing" replaces these, never what the customer
183 * typed.
184 */
185 const KEY_IMPORTED_OG_TITLE = '_metasync_imported_og_title';
186 const KEY_IMPORTED_OG_DESC = '_metasync_imported_og_desc';
187 const KEY_IMPORTED_OG_IMAGE = '_metasync_imported_og_image';
188 const KEY_IMPORTED_TWITTER_TITLE = '_metasync_imported_twitter_title';
189 const KEY_IMPORTED_TWITTER_DESC = '_metasync_imported_twitter_desc';
190 const KEY_IMPORTED_TWITTER_IMAGE = '_metasync_imported_twitter_image';
191
192 /**
193 * The ordered key => source label map for every object type and field.
194 *
195 * @return array<string,array<string,array<string,string>>>
196 */
197 private static function table() {
198 return [
199 self::TYPE_POST => [
200 // OTTO's staging key outranks its persisted copy: staging is
201 // refreshed by the sync (and, since the transient-miss backstop,
202 // by the render path itself), while the persisted key goes stale
203 // whenever OTTO persistence is switched off. A stale copy must
204 // not beat the fresh value on the rendered page.
205 self::FIELD_TITLE => [
206 '_metasync_seo_title' => self::SOURCE_METASYNC,
207 '_metasync_otto_title' => self::SOURCE_OTTO,
208 '_metasync_metatitle' => self::SOURCE_METASYNC,
209 self::KEY_IMPORTED_TITLE => self::SOURCE_IMPORTED,
210 ],
211 self::FIELD_DESCRIPTION => [
212 '_metasync_seo_desc' => self::SOURCE_METASYNC,
213 '_metasync_otto_description' => self::SOURCE_OTTO,
214 '_metasync_metadesc' => self::SOURCE_METASYNC,
215 self::KEY_IMPORTED_DESC => self::SOURCE_IMPORTED,
216 ],
217 // The focus keyword mirrors the title and description shape:
218 // the customer's value first, then OTTO's volatile staging
219 // value, then OTTO's persisted copy — staging outranks the
220 // persisted key for the same reason it does on the title:
221 // staging is refreshed by every sync while the persisted key
222 // only exists while the meta_keywords flag is on. Both OTTO
223 // tiers are dropped under "Disable OTTO" — the persisted tier
224 // having its own key is what lets chain() classify it as
225 // OTTO's. Previously the flag wrote its copy onto
226 // _metasync_focus_keyword (the customer key), so the toggle
227 // could not reach it and a persisted value froze there looking
228 // like customer input.
229 //
230 // Migration: installs that ran with the flag on already hold
231 // OTTO values on _metasync_focus_keyword, indistinguishable
232 // from customer input. They are left where they are and treated
233 // as customer-owned from here — only new writes route to the
234 // new persisted key. Moving them would silently discard real
235 // customer input. No data is migrated. No imported tier either:
236 // another SEO plugin's focus keyword is not migration data, and
237 // rendering one the customer never set would be wrong.
238 //
239 // Deliberately post-only, though OTTO does write
240 // _metasync_otto_keywords to term meta: no caller asks this
241 // class for a term keyword chain. Add a TYPE_TERM entry when
242 // one does.
243 self::FIELD_FOCUS_KEYWORD => [
244 '_metasync_focus_keyword' => self::SOURCE_METASYNC,
245 '_metasync_otto_keywords' => self::SOURCE_OTTO,
246 self::KEY_PERSISTED_OTTO_KEYWORD => self::SOURCE_METASYNC,
247 ],
248 // Social chains mirror the order Metasync_OpenGraph resolves in
249 // — what the customer set, then OTTO's staging key, then a value
250 // brought in from another SEO plugin — so the tag we hand a
251 // third-party plugin is the one we would have emitted ourselves.
252 self::FIELD_OG_TITLE => [
253 '_metasync_og_title' => self::SOURCE_METASYNC,
254 '_metasync_otto_og_title' => self::SOURCE_OTTO,
255 self::KEY_IMPORTED_OG_TITLE => self::SOURCE_IMPORTED,
256 ],
257 self::FIELD_OG_DESCRIPTION => [
258 '_metasync_og_description' => self::SOURCE_METASYNC,
259 '_metasync_otto_og_description' => self::SOURCE_OTTO,
260 self::KEY_IMPORTED_OG_DESC => self::SOURCE_IMPORTED,
261 ],
262 self::FIELD_TWITTER_TITLE => [
263 '_metasync_twitter_title' => self::SOURCE_METASYNC,
264 '_metasync_otto_twitter_title' => self::SOURCE_OTTO,
265 self::KEY_IMPORTED_TWITTER_TITLE => self::SOURCE_IMPORTED,
266 ],
267 self::FIELD_TWITTER_DESCRIPTION => [
268 '_metasync_twitter_description' => self::SOURCE_METASYNC,
269 '_metasync_otto_twitter_description' => self::SOURCE_OTTO,
270 self::KEY_IMPORTED_TWITTER_DESC => self::SOURCE_IMPORTED,
271 ],
272 // Images carry no OTTO staging key; the emitter's own featured-image
273 // fallback sits below these.
274 self::FIELD_OG_IMAGE => [
275 '_metasync_og_image' => self::SOURCE_METASYNC,
276 self::KEY_IMPORTED_OG_IMAGE => self::SOURCE_IMPORTED,
277 ],
278 self::FIELD_TWITTER_IMAGE => [
279 '_metasync_twitter_image' => self::SOURCE_METASYNC,
280 self::KEY_IMPORTED_TWITTER_IMAGE => self::SOURCE_IMPORTED,
281 ],
282 // Canonical: what was persisted or imported
283 // (_metasync_canonical_url) first, then the Canonical meta box
284 // value the customer typed, then OTTO's volatile staging key.
285 // That last position is not decoration — OTTO's SSR injection
286 // stands down when either manual key is set, so ranking the
287 // staging key above the meta box would make the non-SSR paths
288 // render a different canonical than SSR does. The emitter's
289 // permalink fallback sits below the chain.
290 self::FIELD_CANONICAL => [
291 '_metasync_canonical_url' => self::SOURCE_METASYNC,
292 'meta_canonical' => self::SOURCE_METASYNC,
293 '_metasync_otto_canonical' => self::SOURCE_OTTO,
294 ],
295 ],
296 self::TYPE_TERM => [
297 // OTTO does write per-term values — _metasync_otto_title and
298 // _metasync_otto_description exist in term meta — so terms carry
299 // the same three tiers a post does. Leaving OTTO out put an
300 // imported value above an OTTO one, the exact inversion this
301 // class exists to prevent.
302 self::FIELD_TITLE => [
303 self::KEY_PERSISTED_OTTO_TITLE => self::SOURCE_METASYNC,
304 '_metasync_otto_title' => self::SOURCE_OTTO,
305 self::KEY_IMPORTED_TITLE => self::SOURCE_IMPORTED,
306 ],
307 self::FIELD_DESCRIPTION => [
308 self::KEY_PERSISTED_OTTO_DESC => self::SOURCE_METASYNC,
309 '_metasync_otto_description' => self::SOURCE_OTTO,
310 self::KEY_IMPORTED_DESC => self::SOURCE_IMPORTED,
311 ],
312 ],
313 ];
314 }
315
316 /**
317 * The key an import writes to — always the lowest tier.
318 *
319 * @param string $field FIELD_TITLE or FIELD_DESCRIPTION.
320 * @return string '' for fields that have no imported tier.
321 */
322 public static function imported_key($field) {
323 $map = [
324 self::FIELD_TITLE => self::KEY_IMPORTED_TITLE,
325 self::FIELD_DESCRIPTION => self::KEY_IMPORTED_DESC,
326 self::FIELD_OG_TITLE => self::KEY_IMPORTED_OG_TITLE,
327 self::FIELD_OG_DESCRIPTION => self::KEY_IMPORTED_OG_DESC,
328 self::FIELD_OG_IMAGE => self::KEY_IMPORTED_OG_IMAGE,
329 self::FIELD_TWITTER_TITLE => self::KEY_IMPORTED_TWITTER_TITLE,
330 self::FIELD_TWITTER_DESCRIPTION => self::KEY_IMPORTED_TWITTER_DESC,
331 self::FIELD_TWITTER_IMAGE => self::KEY_IMPORTED_TWITTER_IMAGE,
332 ];
333
334 return isset($map[$field]) ? $map[$field] : '';
335 }
336
337 /**
338 * The ordered chain for a field, highest tier first.
339 *
340 * Options, all defaulting to the full chain:
341 * 'include_otto' false drops both OTTO tiers. Used where OTTO
342 * has stood down for the object, so naming its
343 * stored value would report one the page does
344 * not use.
345 * 'include_persisted_otto' false drops only the persisted tier. The admin
346 * columns have always read the volatile key
347 * alone; kept as an explicit opt-out rather than
348 * changed silently as part of consolidating.
349 * 'include_imported' false drops the imported tier.
350 *
351 * @param string $field FIELD_TITLE or FIELD_DESCRIPTION.
352 * @param string $object_type TYPE_POST or TYPE_TERM.
353 * @param array $options See above.
354 * @return array<string,string> Ordered meta key => source label.
355 */
356 public static function chain($field, $object_type = self::TYPE_POST, array $options = []) {
357 $table = self::table();
358
359 if (!isset($table[$object_type][$field])) {
360 return [];
361 }
362
363 $chain = $table[$object_type][$field];
364
365 $include_otto = array_key_exists('include_otto', $options) ? (bool) $options['include_otto'] : true;
366 $include_metasync = array_key_exists('include_metasync', $options) ? (bool) $options['include_metasync'] : true;
367 $include_persisted = array_key_exists('include_persisted_otto', $options) ? (bool) $options['include_persisted_otto'] : true;
368 $include_imported = array_key_exists('include_imported', $options) ? (bool) $options['include_imported'] : true;
369
370 // The persisted-OTTO tier for each field that carries one. Title and
371 // description share the old behaviour; the keyword field has its own
372 // persisted key so the persistence flag no longer writes onto the
373 // customer tier.
374 $persisted_keys = [
375 self::FIELD_TITLE => self::KEY_PERSISTED_OTTO_TITLE,
376 self::FIELD_DESCRIPTION => self::KEY_PERSISTED_OTTO_DESC,
377 self::FIELD_FOCUS_KEYWORD => self::KEY_PERSISTED_OTTO_KEYWORD,
378 ];
379 $persisted_key = $persisted_keys[$field] ?? '';
380
381 foreach ($chain as $key => $source) {
382 // A tier labelled OTTO is OTTO's on either object type. The
383 // persisted key is only OTTO's on a post — on a term it holds the
384 // deliberately-set value, so the OTTO opt-out must not reach it.
385 $is_otto_tier = $source === self::SOURCE_OTTO
386 || ($object_type === self::TYPE_POST && $key === $persisted_key);
387
388 if (!$include_otto && $is_otto_tier) {
389 unset($chain[$key]);
390 continue;
391 }
392
393 if (!$include_persisted && $object_type === self::TYPE_POST && $key === $persisted_key) {
394 unset($chain[$key]);
395 continue;
396 }
397
398 if (!$include_imported && $source === self::SOURCE_IMPORTED) {
399 unset($chain[$key]);
400 continue;
401 }
402
403 // Drops the tier MetaSync itself owns. Used where a non-empty value
404 // does not prove intent: the OG meta box pre-fills its fields from the
405 // post and persists those defaults on save, so the caller checks that
406 // separately and asks for the rest of the chain.
407 if (!$include_metasync && $source === self::SOURCE_METASYNC) {
408 unset($chain[$key]);
409 }
410 }
411
412 return self::demote_custom_tier($chain, $field, $object_type, $options);
413 }
414
415 /**
416 * Move the customer's own value below OTTO's when the site asks for it.
417 *
418 * The global "SEO Meta Priority" setting exists because a
419 * page holding both a custom value and an approved OTTO suggestion renders
420 * the custom one, which reads as a failed OTTO deployment. Flipping the
421 * setting re-orders this chain; it never deletes anything. The custom key
422 * stays in the chain, one tier lower, so it still renders whenever OTTO has
423 * nothing for the object — which is the whole point of demoting rather than
424 * dropping it.
425 *
426 * Only the page title, meta description and focus keyword move. OG and
427 * social tags carry their own chains and are deliberately left alone for
428 * now.
429 *
430 * @param array<string,string> $chain Ordered key => source, already filtered.
431 * @param string $field
432 * @param string $object_type
433 * @param array $options 'seo_priority' overrides the stored option,
434 * so callers and tests can ask for a specific
435 * order without touching global state.
436 * @return array<string,string>
437 */
438 private static function demote_custom_tier(array $chain, $field, $object_type, array $options = []) {
439 if ($object_type !== self::TYPE_POST) {
440 return $chain;
441 }
442
443 // The fields the setting governs, as customer key => OTTO key pairs.
444 // Everything else — OG and social tags especially — keeps its chain.
445 $moved = [
446 self::FIELD_TITLE => [self::KEY_CUSTOM_TITLE, '_metasync_otto_title'],
447 self::FIELD_DESCRIPTION => [self::KEY_CUSTOM_DESC, '_metasync_otto_description'],
448 self::FIELD_FOCUS_KEYWORD => [self::KEY_CUSTOM_KEYWORD, '_metasync_otto_keywords'],
449 ];
450 if (!isset($moved[$field])) {
451 return $chain;
452 }
453
454 $priority = array_key_exists('seo_priority', $options)
455 ? $options['seo_priority']
456 : self::stored_priority();
457
458 if ($priority !== self::PRIORITY_OTTO) {
459 return $chain;
460 }
461
462 [$custom_key, $otto_key] = $moved[$field];
463
464 // Nothing to move when the caller already dropped the custom tier, or
465 // when OTTO's tier is gone — with no OTTO key left there is nothing for
466 // the custom value to sit behind, and re-ordering would only churn.
467 if (!array_key_exists($custom_key, $chain)) {
468 return $chain;
469 }
470
471 if (!array_key_exists($otto_key, $chain)) {
472 return $chain;
473 }
474
475 // Rebuild rather than sort: the remaining tiers keep their documented
476 // order, and the custom key lands below every OTTO tier — above the
477 // imported tier, which must stay last. With staging ranked above the
478 // persisted pair, "immediately after staging" would drop the custom
479 // value between the two OTTO tiers and leave a persisted OTTO value
480 // below it — pushing an OTTO value below OTTO, which this setting
481 // exists to prevent. So the insertion anchor is whichever OTTO tier
482 // sits lowest in the (already filtered) chain: the persisted pair when
483 // both OTTO tiers are present, staging alone when only it remains.
484 $custom_source = $chain[$custom_key];
485 $rebuilt = [];
486
487 $persisted_key = $field === self::FIELD_TITLE
488 ? self::KEY_PERSISTED_OTTO_TITLE
489 : self::KEY_PERSISTED_OTTO_DESC;
490 $anchor_keys = array_key_exists($persisted_key, $chain)
491 ? [$otto_key, $persisted_key]
492 : [$otto_key];
493
494 foreach ($chain as $key => $source) {
495 if ($key === $custom_key) {
496 continue;
497 }
498
499 $rebuilt[$key] = $source;
500
501 if ($key === end($anchor_keys)) {
502 $rebuilt[$custom_key] = $custom_source;
503 }
504 }
505
506 return $rebuilt;
507 }
508
509 /**
510 * The stored global priority, or '' when the plugin option is unreadable.
511 *
512 * Guarded more tightly than otto_is_disabled(): that helper only has to
513 * survive the main plugin class being absent, but this one also runs under
514 * unit tests and admin surfaces that declare a minimal Metasync stub with
515 * no get_option(). A bare class_exists() passes there and then fatals on
516 * the call, so the method itself is checked too.
517 *
518 * The check goes through callable_on() rather than a literal
519 * method_exists('Metasync', 'get_option'): PHPStan resolves the literal
520 * form against the real class, decides it is always true, and fails the
521 * build on function.alreadyNarrowedType — and this repo allows neither
522 * baseline entries nor inline ignores.
523 *
524 * @return string
525 */
526 private static function stored_priority() {
527 if (!self::callable_on('Metasync', 'get_option')) {
528 return '';
529 }
530
531 $general = Metasync::get_option('general');
532
533 if (!is_array($general) || !isset($general['seo_priority'])) {
534 return '';
535 }
536
537 return is_string($general['seo_priority']) ? $general['seo_priority'] : '';
538 }
539
540 /**
541 * Whether a static method is actually callable on a class right now.
542 *
543 * Takes the class and method as parameters so the check is opaque to
544 * static analysis — see stored_priority() for why that matters.
545 *
546 * @param string $class
547 * @param string $method
548 * @return bool
549 */
550 private static function callable_on($class, $method) {
551 return class_exists($class) && method_exists($class, $method);
552 }
553
554 /**
555 * The ordered meta keys for a field, highest tier first.
556 *
557 * @param string $field
558 * @param string $object_type
559 * @param array $options
560 * @return string[]
561 */
562 public static function keys($field, $object_type = self::TYPE_POST, array $options = []) {
563 return array_keys(self::chain($field, $object_type, $options));
564 }
565
566 /**
567 * Resolve the value that actually applies to an object.
568 *
569 * When 'include_otto' is not given, it is answered from the per-post
570 * "Disable OTTO" toggle: with OTTO switched off its stored suggestion is
571 * dead data, and serving it would leak SEO OTTO has stood down from.
572 *
573 * @param int $object_id
574 * @param string $field
575 * @param string $object_type
576 * @param array $options
577 * @return array{key:string,value:string,source:string} Empty strings when
578 * MetaSync holds nothing for this field.
579 */
580 public static function resolve($object_id, $field, $object_type = self::TYPE_POST, array $options = []) {
581 $object_id = (int) $object_id;
582 $empty = ['key' => '', 'value' => '', 'source' => ''];
583
584 if ($object_id <= 0) {
585 return $empty;
586 }
587
588 if (!array_key_exists('include_otto', $options) && $object_type === self::TYPE_POST) {
589 $options['include_otto'] = !self::otto_is_disabled($object_id);
590 }
591
592 foreach (self::chain($field, $object_type, $options) as $key => $source) {
593 $value = self::read($object_id, $object_type, $key);
594
595 if ($value !== '') {
596 return ['key' => $key, 'value' => $value, 'source' => $source];
597 }
598 }
599
600 return $empty;
601 }
602
603 /**
604 * Whether a resolved result is OTTO's value, for provenance marking.
605 *
606 * The source label alone cannot answer this. The persisted tier
607 * (_metasync_metatitle / _metasync_metadesc) is labelled SOURCE_METASYNC
608 * because it is ALSO the legacy native field a customer could have typed
609 * into directly — SEO Health depends on that distinction and deliberately
610 * leaves such a value uncredited. What separates the two is OTTO's staging
611 * key: the sync writes its text to both, so a persisted value that matches
612 * the staging key came from OTTO, and one that does not was typed.
613 *
614 * Rendering needs the same answer, so the test lives here once rather than
615 * being restated by every emitter that stamps a data-metasync-* marker.
616 *
617 * @param int $object_id
618 * @param string $field FIELD_TITLE, FIELD_DESCRIPTION or
619 * FIELD_FOCUS_KEYWORD.
620 * @param array $resolved A resolve() result.
621 * @return bool
622 */
623 public static function is_otto_value($object_id, $field, array $resolved) {
624 if (empty($resolved['key']) || !isset($resolved['source'])) {
625 return false;
626 }
627
628 // The volatile tier is unambiguous.
629 if ($resolved['source'] === self::SOURCE_OTTO) {
630 return true;
631 }
632
633 // Only the persisted tier is ambiguous, and only on a post: on a term
634 // the same key holds a deliberately-set value that is not OTTO's.
635 $persisted_map = [
636 self::FIELD_TITLE => self::KEY_PERSISTED_OTTO_TITLE,
637 self::FIELD_DESCRIPTION => self::KEY_PERSISTED_OTTO_DESC,
638 self::FIELD_FOCUS_KEYWORD => self::KEY_PERSISTED_OTTO_KEYWORD,
639 ];
640 $persisted = $persisted_map[$field] ?? '';
641
642 if ($persisted === '' || $resolved['key'] !== $persisted) {
643 return false;
644 }
645
646 $staging_map = [
647 self::FIELD_TITLE => '_metasync_otto_title',
648 self::FIELD_DESCRIPTION => '_metasync_otto_description',
649 self::FIELD_FOCUS_KEYWORD => '_metasync_otto_keywords',
650 ];
651 $staging = $staging_map[$field] ?? '';
652
653 if ($staging === '') {
654 return false;
655 }
656
657 $staged = self::read((int) $object_id, self::TYPE_POST, $staging);
658
659 return $staged !== '' && $staged === $resolved['value'];
660 }
661
662 /**
663 * The value that applies, or '' when MetaSync holds nothing.
664 *
665 * @param int $object_id
666 * @param string $field
667 * @param string $object_type
668 * @param array $options
669 * @return string
670 */
671 public static function value($object_id, $field, $object_type = self::TYPE_POST, array $options = []) {
672 $resolved = self::resolve($object_id, $field, $object_type, $options);
673
674 return $resolved['value'];
675 }
676
677 /**
678 * The value that would apply if the customer had not set one.
679 *
680 * This is the greyed-out placeholder the editing surfaces show: OTTO's
681 * suggestion, or the imported value where OTTO is silent. It is what the
682 * page will actually render once the field is left blank, so it belongs on
683 * the same chain rather than being assembled per screen.
684 *
685 * @param int $object_id
686 * @param string $field
687 * @param string $object_type
688 * @return array{key:string,value:string,source:string}
689 */
690 public static function fallback($object_id, $field, $object_type = self::TYPE_POST) {
691 $empty = ['key' => '', 'value' => '', 'source' => ''];
692
693 $object_id = (int) $object_id;
694 if ($object_id <= 0) {
695 return $empty;
696 }
697
698 $chain = self::chain($field, $object_type);
699
700 // Drop the tier the customer owns and resolve the rest. It is not
701 // always the first one: under "prioritize OTTO" the custom key is
702 // demoted below OTTO's, so shifting blindly would drop OTTO's value and
703 // offer the customer their own text back as the placeholder for what
704 // renders when they clear the field.
705 $custom_key = self::custom_key_for($field, $object_type);
706
707 if ($custom_key !== '' && array_key_exists($custom_key, $chain)) {
708 unset($chain[$custom_key]);
709 } else {
710 array_shift($chain);
711 }
712
713 if (empty($chain)) {
714 return $empty;
715 }
716
717 $include_otto = $object_type !== self::TYPE_POST || !self::otto_is_disabled($object_id);
718
719 // Only a post can have OTTO switched off, so reaching this with
720 // $include_otto false already means TYPE_POST — no need to re-check it.
721 foreach ($chain as $key => $source) {
722 if (!$include_otto && $source !== self::SOURCE_IMPORTED) {
723 continue;
724 }
725
726 $value = self::read($object_id, $object_type, $key);
727 if ($value !== '') {
728 return ['key' => $key, 'value' => $value, 'source' => $source];
729 }
730 }
731
732 return $empty;
733 }
734
735 /**
736 * The key holding the customer's own value for a field, or '' when the
737 * field has no such tier.
738 *
739 * Only posts have one: a term's top tier is the deliberately-set
740 * _metasync_metatitle, which fallback() already treats as shiftable.
741 *
742 * @param string $field
743 * @param string $object_type
744 * @return string
745 */
746 private static function custom_key_for($field, $object_type) {
747 if ($object_type !== self::TYPE_POST) {
748 return '';
749 }
750
751 if ($field === self::FIELD_TITLE) {
752 return self::KEY_CUSTOM_TITLE;
753 }
754
755 if ($field === self::FIELD_DESCRIPTION) {
756 return self::KEY_CUSTOM_DESC;
757 }
758
759 // The keyword needs the explicit map rather than the array_shift
760 // fallback: under "prioritize OTTO" its customer key is no longer
761 // first in the chain, so shifting would drop the wrong tier.
762 if ($field === self::FIELD_FOCUS_KEYWORD) {
763 return self::KEY_CUSTOM_KEYWORD;
764 }
765
766 return '';
767 }
768
769 /**
770 * Read one meta value for either object type.
771 *
772 * @param int $object_id
773 * @param string $object_type
774 * @param string $key
775 * @return string '' when unset or not a string.
776 */
777 private static function read($object_id, $object_type, $key) {
778 $value = $object_type === self::TYPE_TERM
779 ? get_term_meta($object_id, $key, true)
780 : get_post_meta($object_id, $key, true);
781
782 if (empty($value) || !is_string($value)) {
783 return '';
784 }
785
786 return self::strip_placeholder($object_id, $object_type, $key, $value);
787 }
788
789 /**
790 * Collapse stored values that only echo what the meta box pre-filled.
791 *
792 * Two shapes of that, both on the social keys the meta box owns:
793 *
794 * - the "Auto Draft" placeholder WordPress gives a still-untitled post,
795 * which the pre-fill persisted verbatim; and
796 * - a social title that is a verbatim snapshot of the post title as it
797 * was on save day — stale the moment the post is renamed, and
798 * indistinguishable from a typed title by content alone.
799 *
800 * Either would stop the walk at the tier that holds it, putting a value
801 * the customer never chose above OTTO's — the inversion this class exists
802 * to prevent. Collapsing to '' here, rather than at each consumer, keeps
803 * one chain that every call site sees the same way.
804 *
805 * Posts only: the prone keys are meta box keys and terms carry none of them.
806 *
807 * method_exists (not just class_exists) because this runs on front-end requests
808 * and a partially updated install can pair a newer includes/ file with an older
809 * class-metasync-opengraph.php, where calling a method the loaded class doesn't
810 * define would fatal the page rather than degrade.
811 *
812 * @param int $object_id
813 * @param string $object_type
814 * @param string $key
815 * @param string $value
816 * @return string
817 */
818 private static function strip_placeholder($object_id, $object_type, $key, $value) {
819 if ($object_type !== self::TYPE_POST) {
820 return $value;
821 }
822
823 // @phpstan-ignore-next-line function.alreadyNarrowedType
824 if (method_exists('Metasync_OpenGraph', 'strip_auto_draft_title')
825 && defined('Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS')
826 && in_array($key, Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS, true)
827 ) {
828 $value = Metasync_OpenGraph::strip_auto_draft_title($value);
829 }
830
831 // @phpstan-ignore-next-line function.alreadyNarrowedType
832 if (method_exists('Metasync_OpenGraph', 'strip_title_snapshot')
833 && defined('Metasync_OpenGraph::TITLE_DEFAULTED_KEYS')
834 && in_array($key, Metasync_OpenGraph::TITLE_DEFAULTED_KEYS, true)
835 ) {
836 $value = Metasync_OpenGraph::strip_title_snapshot($object_id, $key, $value);
837 }
838
839 // @phpstan-ignore-next-line function.alreadyNarrowedType
840 if (method_exists('Metasync_OpenGraph', 'strip_description_snapshot')
841 && defined('Metasync_OpenGraph::DESCRIPTION_DEFAULTED_KEYS')
842 && in_array($key, Metasync_OpenGraph::DESCRIPTION_DEFAULTED_KEYS, true)
843 ) {
844 $value = Metasync_OpenGraph::strip_description_snapshot($object_id, $key, $value);
845 }
846
847 return $value;
848 }
849
850 /**
851 * Whether the per-post "Disable OTTO" toggle is set.
852 *
853 * @param int $post_id
854 * @return bool
855 */
856 private static function otto_is_disabled($post_id) {
857 return class_exists('Metasync_Otto_Frontend_Toolbar')
858 && Metasync_Otto_Frontend_Toolbar::is_otto_disabled((int) $post_id);
859 }
860 }
861