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

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