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
← All changes | includes/class-metasync-seo-precedence.php +392 -9 2.6.26 → 2.7.2 View file →
@@ -13,10 +13,10 @@
13 13 * value; they no longer restate what beats what.
14 14 *
15 15 * ── Posts ────────────────────────────────────────────────────────────────
16 16 * _metasync_seo_title the customer typed it in the sidebar or box
17 + * _metasync_otto_title OTTO's value for this request
17 18 * _metasync_metatitle OTTO's value, persisted by the sync
18 - * _metasync_otto_title OTTO's value for this request
19 19 * _metasync_imported_seo_title brought in from another SEO plugin
20 20 *
21 21 * ── Terms ────────────────────────────────────────────────────────────────
22 22 * _metasync_metatitle set deliberately, by the MCP taxonomy tool
@@ -62,8 +62,49 @@
62 62 const FIELD_OG_IMAGE = 'og_image';
63 63 const FIELD_TWITTER_IMAGE = 'twitter_image';
64 64
65 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 + /**
66 107 * Source labels, as the admin surfaces render them. An empty label means a
67 108 * value MetaSync itself owns and needs no attribution.
68 109 */
69 110 const SOURCE_METASYNC = '';
@@ -76,10 +117,22 @@
76 117 * Named because one caller deliberately leaves it out — see chain().
77 118 */
78 119 const KEY_PERSISTED_OTTO_TITLE = '_metasync_metatitle';
79 120 const KEY_PERSISTED_OTTO_DESC = '_metasync_metadesc';
121 + const KEY_PERSISTED_OTTO_KEYWORD = '_metasync_metakeywords';
80 122
81 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 + /**
82 135 * The lowest tier: values brought in from another SEO plugin.
83 136 *
84 137 * Named so the importer writes "the last tier" rather than a literal it
85 138 * could get wrong, and so the sidebar can expose the same key to JS without
@@ -88,8 +141,40 @@
88 141 const KEY_IMPORTED_TITLE = '_metasync_imported_seo_title';
89 142 const KEY_IMPORTED_DESC = '_metasync_imported_seo_desc';
90 143
91 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 + /**
92 177 * Social values brought in from another SEO plugin.
93 178 *
94 179 * Social fields need their own imported keys for the same reason the page
95 180 * title and description do: an import is not a per-post decision by the
@@ -111,20 +196,56 @@
111 196 */
112 197 private static function table() {
113 198 return [
114 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.
115 205 self::FIELD_TITLE => [
116 206 '_metasync_seo_title' => self::SOURCE_METASYNC,
207 + '_metasync_otto_title' => self::SOURCE_OTTO,
117 208 '_metasync_metatitle' => self::SOURCE_METASYNC,
118 - '_metasync_otto_title' => self::SOURCE_OTTO,
119 209 self::KEY_IMPORTED_TITLE => self::SOURCE_IMPORTED,
120 210 ],
121 211 self::FIELD_DESCRIPTION => [
122 212 '_metasync_seo_desc' => self::SOURCE_METASYNC,
213 + '_metasync_otto_description' => self::SOURCE_OTTO,
123 214 '_metasync_metadesc' => self::SOURCE_METASYNC,
124 - '_metasync_otto_description' => self::SOURCE_OTTO,
125 215 self::KEY_IMPORTED_DESC => self::SOURCE_IMPORTED,
126 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 + ],
127 248 // Social chains mirror the order Metasync_OpenGraph resolves in
128 249 // — what the customer set, then OTTO's staging key, then a value
129 250 // brought in from another SEO plugin — so the tag we hand a
130 251 // third-party plugin is the one we would have emitted ourselves.
@@ -157,8 +278,21 @@
157 278 self::FIELD_TWITTER_IMAGE => [
158 279 '_metasync_twitter_image' => self::SOURCE_METASYNC,
159 280 self::KEY_IMPORTED_TWITTER_IMAGE => self::SOURCE_IMPORTED,
160 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 + ],
161 295 ],
162 296 self::TYPE_TERM => [
163 297 // OTTO does write per-term values — _metasync_otto_title and
164 298 // _metasync_otto_description exist in term meta — so terms carry
@@ -232,11 +366,18 @@
232 366 $include_metasync = array_key_exists('include_metasync', $options) ? (bool) $options['include_metasync'] : true;
233 367 $include_persisted = array_key_exists('include_persisted_otto', $options) ? (bool) $options['include_persisted_otto'] : true;
234 368 $include_imported = array_key_exists('include_imported', $options) ? (bool) $options['include_imported'] : true;
235 369
236 - $persisted_key = $field === self::FIELD_TITLE
237 - ? self::KEY_PERSISTED_OTTO_TITLE
238 - : self::KEY_PERSISTED_OTTO_DESC;
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] ?? '';
239 380
240 381 foreach ($chain as $key => $source) {
241 382 // A tier labelled OTTO is OTTO's on either object type. The
242 383 // persisted key is only OTTO's on a post — on a term it holds the
@@ -267,12 +408,151 @@
267 408 unset($chain[$key]);
268 409 }
269 410 }
270 411
271 - return $chain;
412 + return self::demote_custom_tier($chain, $field, $object_type, $options);
272 413 }
273 414
274 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 + /**
275 555 * The ordered meta keys for a field, highest tier first.
276 556 *
277 557 * @param string $field
278 558 * @param string $object_type
@@ -320,8 +600,67 @@
320 600 return $empty;
321 601 }
322 602
323 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 + /**
324 663 * The value that applies, or '' when MetaSync holds nothing.
325 664 *
326 665 * @param int $object_id
327 666 * @param string $field
@@ -357,11 +696,21 @@
357 696 }
358 697
359 698 $chain = self::chain($field, $object_type);
360 699
361 - // Drop the top tier — the one the customer owns — and resolve the rest.
362 - array_shift($chain);
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);
363 706
707 + if ($custom_key !== '' && array_key_exists($custom_key, $chain)) {
708 + unset($chain[$custom_key]);
709 + } else {
710 + array_shift($chain);
711 + }
712 +
364 713 if (empty($chain)) {
365 714 return $empty;
366 715 }
367 716
@@ -380,8 +729,42 @@
380 729 }
381 730 }
382 731
383 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 '';
384 767 }
385 768
386 769 /**
387 770 * Read one meta value for either object type.