| @@ -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. |