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 / admin / class-metasync-seo-columns.php

class-metasync-seo-columns.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at admin/class-metasync-seo-columns.php

774 lines 27.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * SEO meta columns for the posts list table.
9 *
10 * Adds four opt-in columns (SEO Title, SEO Meta Desc, Noindex, Nofollow) to the
11 * Posts / Pages / public-CPT list tables so editors can audit SEO meta at a glance
12 * instead of opening each post individually.
13 *
14 * Values are read from post meta only — never from the OTTO API. OTTO persists its
15 * suggestions to `_metasync_otto_title` / `_metasync_otto_description` whenever it
16 * syncs a page (see otto/metasync-otto-seo-functions.php), so the list screen needs
17 * no network calls.
18 *
19 * @since 3.0.0
20 * @package Metasync
21 * @subpackage Metasync/admin
22 */
23 class Metasync_SEO_Columns
24 {
25 /** @var self|null */
26 private static $instance = null;
27
28 /** Column IDs. */
29 const COL_TITLE = 'metasync_seo_title';
30 const COL_DESC = 'metasync_seo_desc';
31 const COL_NOINDEX = 'metasync_noindex';
32 const COL_NOFOLLOW = 'metasync_nofollow';
33
34 /** Recommended maximum lengths, used for the character counters. */
35 const TITLE_MAX_LEN = 60;
36 const DESC_MAX_LEN = 160;
37
38 /**
39 * Get the singleton instance.
40 *
41 * @return self
42 */
43 public static function get_instance()
44 {
45 if (self::$instance === null) {
46 self::$instance = new self();
47 }
48 return self::$instance;
49 }
50
51 private function __construct()
52 {
53 }
54
55 /**
56 * All column IDs this class owns.
57 *
58 * @return array
59 */
60 public static function get_column_ids()
61 {
62 return array(self::COL_TITLE, self::COL_DESC, self::COL_NOINDEX, self::COL_NOFOLLOW);
63 }
64
65 /**
66 * A companion plugin that already renders these exact columns.
67 *
68 * WebJIVE's "LNR SEO Columns" was written to fill this gap before the feature
69 * existed, and it uses byte-identical column IDs. With both active WordPress
70 * shows one column but runs both render callbacks, so every cell would print
71 * two stacked values.
72 */
73 const COMPANION_PLUGIN_CLASS = 'WebJIVE_Metasync_SEO_Columns';
74
75 /**
76 * Whether a companion plugin owns these columns already.
77 *
78 * When it does we stand down entirely rather than double-render, and surface a
79 * notice telling the site owner they can retire it. Nothing breaks either way —
80 * this only avoids duplicated cell content.
81 *
82 * @return bool
83 */
84 public static function companion_plugin_active()
85 {
86 return class_exists(self::COMPANION_PLUGIN_CLASS);
87 }
88
89 /**
90 * Whether the columns should appear for a given post type.
91 *
92 * Reuses the SEO Health post-type list so the two features never drift: posts,
93 * pages, WooCommerce products, plus every public + publicly_queryable CPT, minus
94 * builder-internal types (Elementor library, WP templates, Oxygen, Divi layouts).
95 *
96 * @param string $post_type Post type slug.
97 * @return bool
98 */
99 public static function is_supported_post_type($post_type)
100 {
101 if (empty($post_type) || !class_exists('Metasync_SEO_Health')) {
102 return false;
103 }
104 if (self::companion_plugin_active()) {
105 return false;
106 }
107 return in_array($post_type, Metasync_SEO_Health::get_supported_post_types(), true);
108 }
109
110 /**
111 * Tell the site owner they no longer need the companion plugin.
112 *
113 * @return void
114 */
115 public function companion_plugin_notice()
116 {
117 if (!self::companion_plugin_active() || !current_user_can('activate_plugins')) {
118 return;
119 }
120
121 $screen = function_exists('get_current_screen') ? get_current_screen() : null;
122 if (!$screen || empty($screen->base) || $screen->base !== 'edit') {
123 return;
124 }
125
126 printf(
127 '<div class="notice notice-info is-dismissible"><p>%s</p></div>',
128 esc_html__(
129 'SEO columns are now built into this plugin. The separate "LNR SEO Columns" plugin is still active and is providing them instead — you can deactivate it to use the built-in version.',
130 'metasync'
131 )
132 );
133 }
134
135 /**
136 * Label prefix for the column headings.
137 *
138 * Honours white labelling so a reseller sees their own brand in the column
139 * headers rather than "SEO".
140 *
141 * @return string Company name when white labelled, otherwise an empty string.
142 */
143 private function get_label_prefix()
144 {
145 if (!class_exists('Metasync')) {
146 return '';
147 }
148 $company = Metasync::get_whitelabel_company_name();
149 return !empty($company) ? $company . ' ' : '';
150 }
151
152 /**
153 * Add the columns to the list table.
154 *
155 * Inserted after the Title column so they read left-to-right with the post name.
156 *
157 * @param array $columns Existing columns.
158 * @param string $post_type Post type (only passed on the `manage_posts_columns` filter).
159 * @return array
160 */
161 public function add_columns($columns, $post_type = '')
162 {
163 if (empty($post_type)) {
164 $screen = function_exists('get_current_screen') ? get_current_screen() : null;
165 $post_type = ($screen && !empty($screen->post_type)) ? $screen->post_type : 'page';
166 }
167
168 if (!self::is_supported_post_type($post_type)) {
169 return $columns;
170 }
171
172 $prefix = $this->get_label_prefix();
173
174 $new_columns = array();
175 foreach ($columns as $key => $value) {
176 $new_columns[$key] = $value;
177 if ($key === 'title') {
178 $new_columns[self::COL_TITLE] = esc_html($prefix . __('SEO Title', 'metasync'));
179 $new_columns[self::COL_DESC] = esc_html($prefix . __('SEO Meta Desc', 'metasync'));
180 $new_columns[self::COL_NOINDEX] = esc_html($prefix . __('Noindex', 'metasync'));
181 $new_columns[self::COL_NOFOLLOW] = esc_html($prefix . __('Nofollow', 'metasync'));
182 }
183 }
184
185 // Defensive: if the table has no `title` column, still expose ours.
186 foreach (self::get_column_ids() as $id) {
187 if (!isset($new_columns[$id])) {
188 $new_columns[$id] = esc_html($prefix . $id);
189 }
190 }
191
192 return $new_columns;
193 }
194
195 /**
196 * Hide the columns by default for a user who has never set column preferences.
197 *
198 * Core only calls this filter when `manage{$screen->id}columnshidden` is absent
199 * for the user (wp-admin/includes/screen.php, get_hidden_columns()). Anyone who
200 * has already touched Screen Options on this screen skips it entirely, which is
201 * what hidden_columns() below exists to cover.
202 *
203 * @param array $hidden Column IDs hidden by default.
204 * @param WP_Screen|null $screen Current screen.
205 * @return array
206 */
207 public function default_hidden_columns($hidden, $screen)
208 {
209 if (!$this->screen_wants_columns($screen)) {
210 return $hidden;
211 }
212 return array_values(array_unique(array_merge($hidden, self::get_column_ids())));
213 }
214
215 /**
216 * Hide the columns once for users whose saved preferences predate this feature.
217 *
218 * Core runs this filter unconditionally and passes `$use_defaults`, so it is the
219 * only place a new column can be hidden for an existing user. Without it, anyone
220 * who had ever adjusted Screen Options got all four columns switched on the
221 * moment they upgraded — the opposite of the opt-in this feature promises.
222 *
223 * A filtered value is not saved by core, so returning the merged list alone would
224 * hide the columns for a single request and then let them reappear for good. We
225 * therefore write it to the same user-meta key core reads and Screen Options
226 * saves to (`wp-admin/includes/ajax-actions.php`, wp_ajax_hidden_columns()).
227 *
228 * The marker is per screen, because posts and pages keep separate preferences.
229 * Once set we never touch that user's choice again, so a later deliberate opt-in
230 * survives.
231 *
232 * @param array $hidden Hidden column IDs.
233 * @param WP_Screen|null $screen Current screen.
234 * @param bool $use_defaults Whether core fell back to the defaults.
235 * @return array
236 */
237 public function hidden_columns($hidden, $screen, $use_defaults = false)
238 {
239 if (!$this->screen_wants_columns($screen)) {
240 return $hidden;
241 }
242
243 $user_id = get_current_user_id();
244 if (!$user_id) {
245 return $hidden;
246 }
247
248 $flag = 'metasync_seo_columns_init_' . $screen->id;
249 if (!empty(get_user_meta($user_id, $flag, true))) {
250 return $hidden;
251 }
252
253 // No stored preference: default_hidden_columns() already hid our columns this
254 // request. Record it so the user's first deliberate opt-in is not re-hidden.
255 if ($use_defaults) {
256 update_user_meta($user_id, $flag, '1');
257 return $hidden;
258 }
259
260 // Stored preference predating this feature — persist the merge, then mark.
261 $hidden = array_values(array_unique(array_merge($hidden, self::get_column_ids())));
262 $pref_key = 'manage' . $screen->id . 'columnshidden';
263 update_user_meta($user_id, $pref_key, $hidden);
264
265 // Only seal the migration once the preference has demonstrably stored. If the
266 // write failed — a transient DB error, or an update_user_metadata filter
267 // rejecting this key — marking anyway would leave the columns visible from the
268 // next request onward with no possibility of a retry.
269 //
270 // Read back rather than trust the return value: update_user_meta() also
271 // returns false when the stored value was already identical.
272 $stored = get_user_meta($user_id, $pref_key, true);
273 if (is_array($stored) && !array_diff(self::get_column_ids(), $stored)) {
274 update_user_meta($user_id, $flag, '1');
275 }
276
277 return $hidden;
278 }
279
280 /**
281 * Whether the current screen is a list table that carries our columns.
282 *
283 * Mirrors add_columns(): a screen we never add columns to must not get migration
284 * state either, or a post type that later becomes supported would find a stale
285 * marker and show all four columns immediately.
286 *
287 * @param WP_Screen|null $screen Current screen.
288 * @return bool
289 */
290 private function screen_wants_columns($screen)
291 {
292 if ($screen === null || empty($screen->base) || $screen->base !== 'edit') {
293 return false;
294 }
295 if (empty($screen->post_type)) {
296 return false;
297 }
298 return self::is_supported_post_type($screen->post_type);
299 }
300
301 /**
302 * Render a cell.
303 *
304 * Reads the whole meta array once per row. WP_Query primes the post meta cache for
305 * the list table (`update_post_meta_cache` defaults to true), so this is an object
306 * cache read rather than a query, and costs the same regardless of how many of our
307 * columns are enabled.
308 *
309 * @param string $column_name Column being rendered.
310 * @param int $post_id Post ID.
311 * @return void
312 */
313 public function render_column($column_name, $post_id)
314 {
315 if (!in_array($column_name, self::get_column_ids(), true)) {
316 return;
317 }
318 if (self::companion_plugin_active()) {
319 return;
320 }
321
322 $all_meta = get_post_meta($post_id);
323 if (!is_array($all_meta)) {
324 $all_meta = array();
325 }
326
327 switch ($column_name) {
328 case self::COL_TITLE:
329 echo $this->render_text_cell($post_id, 'title'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper
330 break;
331 case self::COL_DESC:
332 echo $this->render_text_cell($post_id, 'description'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper
333 break;
334 case self::COL_NOINDEX:
335 echo $this->render_robots_cell($all_meta, 'noindex'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper
336 break;
337 case self::COL_NOFOLLOW:
338 echo $this->render_robots_cell($all_meta, 'nofollow'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper
339 break;
340 }
341 }
342
343 /**
344 * Resolve the value the front end will actually render for this post.
345 *
346 * The order below mirrors what the rendering code does, verified across every
347 * path that can set the title or description on a singular request:
348 *
349 * 1. `_metasync_seo_*` — the sidebar / Classic meta box value. Wins everywhere:
350 * the sidebar filter runs at `pre_get_document_title` priority 100, after
351 * OTTO's at 99 (admin/class-metasync-seo-sidebar.php:95 vs
352 * otto/metasync-otto-seo-functions.php:3070), and OTTO's own HTML rewrite
353 * re-forces it at the end (otto/Otto_html_class.php:1708).
354 * 2. `_metasync_otto_*` — OTTO's persisted suggestion.
355 * 3. `meta_description` — legacy key the original emitter still prints
356 * (public/class-metasync-seo-output.php:271). Description only.
357 * 4. A third-party SEO plugin's own key, but ONLY when that plugin is active.
358 * A leftover `_yoast_wpseo_title` from a deactivated Yoast renders nothing,
359 * so surfacing it would be inventing a value.
360 *
361 * `_metasync_metatitle` / `_metasync_metadesc` are deliberately absent. They are
362 * mirrors written for third-party plugins and the MCP tools; no singular render
363 * path reads them (confirmed by two independent traces). Ranking them above OTTO
364 * — as Metasync_SEO_Health still does — makes the column disagree with the page.
365 *
366 * Reads post meta only, with one exception: the OTTO disable test also consults
367 * the manual URL exclusion list, which costs a URL resolution per row plus a
368 * single cached read of the exclusion set for the whole request. On the Pages
369 * table that resolution is not free — get_permalink() walks ancestors through
370 * get_page_uri() for a nested page, which WP_Query has not primed, and it fires
371 * the post_link/page_link filters that translation and permalink plugins hook.
372 * Everything else here is free: WP_Query has already primed the post meta cache
373 * for this list table.
374 *
375 * @param int $post_id Post ID.
376 * @param string $field 'title' or 'description'.
377 * @return array{value:string,source:string} Source is '' for a user-owned value.
378 */
379 private function resolve_rendered_value($post_id, $field)
380 {
381 $is_title = ($field === 'title');
382
383 // MetaSync's own tiers come from Metasync_Seo_Precedence, the one place
384 // the order is defined.
385 //
386 // OTTO stands down entirely for a post with the per-post disable flag set
387 // (otto/metasync-otto-seo-functions.php:2582 -> the title filter returns the
388 // incoming title untouched). Its persisted suggestion is then dead data, so
389 // reporting it would name a value the page does not use.
390 //
391 // The persisted-OTTO tier is left out here, which is what this column has
392 // always done. SEO Health does include it, so the two surfaces can disagree
393 // about a post whose only value is `_metasync_metatitle`; carried forward
394 // unchanged rather than resolved as a side effect of consolidating.
395 $chain = Metasync_Seo_Precedence::chain(
396 $is_title
397 ? Metasync_Seo_Precedence::FIELD_TITLE
398 : Metasync_Seo_Precedence::FIELD_DESCRIPTION,
399 Metasync_Seo_Precedence::TYPE_POST,
400 array(
401 'include_otto' => !$this->otto_disabled_for_post($post_id),
402 'include_persisted_otto' => false,
403 )
404 );
405
406 $third_party = $this->get_active_plugin_meta_keys($is_title);
407
408 // The legacy `meta_description` tag is only emitted when no active SEO plugin
409 // takes over: should_output_legacy_description() returns false for Yoast and
410 // Rank Math, which auto-generate their own. So an active plugin outranks the
411 // legacy key; with no plugin active the legacy key is what prints.
412 if (!$is_title) {
413 if (!empty($third_party)) {
414 $chain += $third_party;
415 $chain['meta_description'] = 'Synced';
416 } else {
417 $chain['meta_description'] = 'Synced';
418 }
419 } else {
420 $chain += $third_party;
421 }
422
423 foreach ($chain as $meta_key => $source) {
424 $value = get_post_meta($post_id, $meta_key, true);
425 if (!empty($value) && is_string($value)) {
426 // `source` is no longer rendered — the columns show the value only. It is
427 // still returned so precedence can be asserted directly in tests.
428 return array('value' => $value, 'source' => $source);
429 }
430 }
431
432 return array('value' => '', 'source' => '');
433 }
434
435 /**
436 * Whether OTTO output is switched off for this specific post.
437 *
438 * Delegates to Metasync_Otto_Frontend_Toolbar::is_otto_disabled(), which reports
439 * the effective status: the `_metasync_otto_disabled` meta flag OR a manual entry
440 * on the Compatibility page's "Excluded URLs" list. Both stop OTTO rendering,
441 * so both have to stop the column naming OTTO's value as the rendered one.
442 *
443 * The fallback repeats the meta test verbatim for the case where the toolbar class
444 * is not loaded. It treats only '1' and 'true' as disabled: a looser truthiness
445 * test would read the string 'false' as disabled and wrongly hide OTTO's value
446 * from the column while the front end still serves it.
447 *
448 * @param int $post_id Post ID.
449 * @return bool
450 */
451 private function otto_disabled_for_post($post_id)
452 {
453 // Quick Edit saves over admin-ajax as `inline-save`, and wp_ajax_inline_save()
454 // redraws this row. metasync.php skips otto/otto_pixel.php for any admin-ajax
455 // action that is not ours, so the exclusion checker is absent in exactly the
456 // request that re-renders the column — and the row would show OTTO's value for
457 // an excluded post until the next full page load. Load it here, where the
458 // column is actually being rendered, rather than widening the load for every
459 // third-party ajax request: this runs only inside a column render callback,
460 // so a Quick Edit save pays for it and the frequent ajax actions the guard in
461 // metasync.php exists for (heartbeat, autosave) never reach this line.
462 //
463 // Gated on wp_doing_ajax() rather than the DOING_AJAX constant that
464 // metasync_is_non_metasync_admin_ajax() reads. The two can in principle
465 // disagree — wp_doing_ajax() is filterable — but it is the sanctioned API
466 // and the one PHPStan's WordPress ruleset requires, and a plugin filtering
467 // it to false mid-Quick-Edit would only cost this column its freshness.
468 if (!function_exists('metasync_is_otto_url_manually_excluded') && wp_doing_ajax()) {
469 $pixel_file = plugin_dir_path(dirname(__FILE__)) . 'otto/otto_pixel.php';
470 if (file_exists($pixel_file)) {
471 require_once $pixel_file;
472 }
473 }
474
475 if (class_exists('Metasync_Otto_Frontend_Toolbar')) {
476 return (bool) Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id);
477 }
478
479 $disabled = get_post_meta($post_id, '_metasync_otto_disabled', true);
480 return $disabled === '1' || $disabled === 'true';
481 }
482
483 /**
484 * Whether a value is a third-party template rather than final text.
485 *
486 * Yoast and Rank Math store patterns such as `%%title%% %%sep%% %%sitename%%`
487 * and expand them at render time. Displaying the raw tokens is honest, but
488 * measuring them against the 60/160 limits is not — the rendered length is
489 * entirely different. Callers suppress the counter for these.
490 *
491 * @param string $value Stored value.
492 * @return bool
493 */
494 private function is_template_value($value)
495 {
496 return (bool) preg_match('/%%[a-z0-9_-]+%%/i', $value);
497 }
498
499 /**
500 * Third-party meta keys worth reading, restricted to plugins that are active.
501 *
502 * The plugin paths mirror Metasync_SEO_Conflict_Handler's own detection list.
503 * They are checked directly rather than through that class: its constructor
504 * registers filters and needs a full WordPress request context, which is far
505 * more than a list-table column should pull in to ask "is Yoast switched on?".
506 *
507 * @param bool $is_title Whether to return title keys (else description keys).
508 * @return array meta_key => source label.
509 */
510 private function get_active_plugin_meta_keys($is_title)
511 {
512 if (!function_exists('is_plugin_active')) {
513 if (!defined('ABSPATH') || !file_exists(ABSPATH . 'wp-admin/includes/plugin.php')) {
514 return array();
515 }
516 require_once ABSPATH . 'wp-admin/includes/plugin.php';
517 }
518
519 $keys = array();
520
521 if (is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
522 $keys[$is_title ? '_yoast_wpseo_title' : '_yoast_wpseo_metadesc'] = 'Yoast';
523 }
524
525 if (is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php')) {
526 $keys[$is_title ? 'rank_math_title' : 'rank_math_description'] = 'Rank Math';
527 }
528
529 if (
530 is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php')
531 || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')
532 ) {
533 $keys[$is_title ? '_aioseo_title' : '_aioseo_description'] = 'AIOSEO';
534 }
535
536 return $keys;
537 }
538
539 /**
540 * Render the SEO Title or SEO Meta Desc cell.
541 *
542 * Empty states deliberately differ between the two fields:
543 *
544 * - Title is never really empty. When no override exists the sidebar filter hands
545 * WordPress' own generated title straight back (class-metasync-seo-sidebar.php),
546 * so the page still has a working <title>. Showing a bare dash would imply the
547 * page is broken when it is not — we show the post title, muted, instead.
548 * - Description genuinely can be empty: WordPress does not auto-generate meta
549 * descriptions. That IS an actionable gap, so it gets a "Not set" warning.
550 *
551 * @param int $post_id Post ID.
552 * @param string $field 'title' or 'description'.
553 * @return string HTML.
554 */
555 private function render_text_cell($post_id, $field)
556 {
557 $resolved = $this->resolve_rendered_value($post_id, $field);
558
559 $value = $resolved['value'];
560
561 if ($value === '') {
562 if ($field === 'title') {
563 // No override: WordPress generates the title from the post title, so the
564 // page still has a working one. Shown muted, with the explanation in the
565 // hover title rather than an inline label.
566 return sprintf(
567 '<span class="metasync-seo-col metasync-seo-col--auto" title="%s">%s</span>',
568 esc_attr__('No SEO title set. WordPress generates the page title from the post title.', 'metasync'),
569 esc_html(get_the_title($post_id))
570 );
571 }
572
573 return sprintf(
574 '<span class="metasync-seo-col metasync-seo-col--missing">%s</span>',
575 esc_html__('Not set', 'metasync')
576 );
577 }
578
579 $max = ($field === 'title') ? self::TITLE_MAX_LEN : self::DESC_MAX_LEN;
580
581 // A stored template is shown verbatim but not measured: the tokens expand at
582 // render time, so a raw count would report a length the page never has.
583 $meta_line = $this->is_template_value($value)
584 ? __('template — expands on render', 'metasync')
585 : $this->char_count($value, $max);
586
587 return sprintf(
588 '<span class="metasync-seo-col">%s</span><br /><span class="metasync-seo-col__meta">%s</span>',
589 esc_html($value),
590 esc_html($meta_line)
591 );
592 }
593
594 /**
595 * Character counter, e.g. "153/160".
596 *
597 * @param string $value Value to measure.
598 * @param int $max Recommended maximum.
599 * @return string
600 */
601 private function char_count($value, $max)
602 {
603 $len = function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
604 return $len . '/' . $max;
605 }
606
607 /**
608 * Render the Noindex / Nofollow cell.
609 *
610 * @param array $all_meta Result of get_post_meta($post_id).
611 * @param string $directive 'noindex' or 'nofollow'.
612 * @return string HTML.
613 */
614 private function render_robots_cell($all_meta, $directive)
615 {
616 $flags = $this->resolve_robots_flags($all_meta);
617
618 if (empty($flags[$directive])) {
619 return '<span class="metasync-seo-col__meta">&mdash;</span>';
620 }
621
622 return sprintf(
623 '<span class="metasync-seo-col metasync-seo-col--flag">%s</span>',
624 esc_html__('Yes', 'metasync')
625 );
626 }
627
628 /**
629 * Resolve the effective noindex / nofollow state for a post.
630 *
631 * Mirrors the priority order documented on
632 * Metasync_Seo_Output::resolve_robots_value() (public/class-metasync-seo-output.php),
633 * reduced to the two booleans this screen displays:
634 *
635 * 1. _metasync_robots_index (noindex, separate key)
636 * 2. _metasync_robots_advanced (JSON)
637 * 3. meta_robots (string, set via REST)
638 * 4. metasync_common_robots (array, admin checkboxes)
639 *
640 * There is deliberately no global-default step: the frontend resolver's global
641 * branch only contributes max-* directives, never noindex/nofollow.
642 *
643 * Returns all-false when an active third-party SEO plugin owns the robots tag.
644 * hook_metasync_metatags() only emits our robots on such a site when
645 * Metasync_SEO_Conflict_Handler::metasync_has_robots() passes, and that method
646 * inspects `meta_robots` and `metasync_common_robots` only — it does not know
647 * about `_metasync_robots_index` or `_metasync_robots_advanced`. A post carrying
648 * just those keys gets the plugin's directives on the page, so claiming ours are
649 * in force would be wrong. Showing nothing under-reports rather than misleads.
650 *
651 * @param array $all_meta Result of get_post_meta($post_id).
652 * @return array{noindex:bool,nofollow:bool}
653 */
654 private function resolve_robots_flags($all_meta)
655 {
656 if (!$this->metasync_robots_reach_the_page($all_meta)) {
657 return array('noindex' => false, 'nofollow' => false);
658 }
659 $out = array('noindex' => false, 'nofollow' => false);
660
661 // 1. noindex lives in its own key regardless of the other storage formats.
662 if (($all_meta['_metasync_robots_index'][0] ?? '') === 'noindex') {
663 $out['noindex'] = true;
664 }
665
666 // 2. Advanced JSON is authoritative for the advanced directives when present.
667 $advanced_raw = $all_meta['_metasync_robots_advanced'][0] ?? '';
668 if (!empty($advanced_raw)) {
669 $advanced = json_decode($advanced_raw, true);
670 if (is_array($advanced) && !empty($advanced)) {
671 $out['nofollow'] = !empty($advanced['nofollow']);
672 return $out;
673 }
674 }
675
676 // 3. meta_robots string (REST API).
677 $meta_robots = $all_meta['meta_robots'][0] ?? '';
678 if (!empty($meta_robots)) {
679 // Tokenised, not substring-matched. `none` is the standard shorthand for
680 // "noindex, nofollow" and would otherwise be missed entirely, while a
681 // substring test also accepts malformed values such as "noindexing".
682 $tokens = array_map('trim', explode(',', strtolower((string) $meta_robots)));
683
684 if (in_array('none', $tokens, true)) {
685 $out['noindex'] = true;
686 $out['nofollow'] = true;
687 return $out;
688 }
689 if (in_array('noindex', $tokens, true)) {
690 $out['noindex'] = true;
691 }
692 if (in_array('nofollow', $tokens, true)) {
693 $out['nofollow'] = true;
694 }
695 return $out;
696 }
697
698 // 4. Admin checkbox array.
699 $raw = $all_meta['metasync_common_robots'][0] ?? '';
700 if (!empty($raw)) {
701 $common = maybe_unserialize($raw);
702 if (is_array($common) && !empty(array_filter($common))) {
703 if (!empty($common['noindex'])) {
704 $out['noindex'] = true;
705 }
706 if (!empty($common['nofollow'])) {
707 $out['nofollow'] = true;
708 }
709 return $out;
710 }
711 }
712
713 // No global fallback. The frontend resolver's global step
714 // (Metasync_Seo_Output::resolve_robots_value(), step 4) reads
715 // `advance_robots_meta` and only ever contributes max-snippet /
716 // max-video-preview / max-image-preview — it never applies a global noindex
717 // or nofollow. The global `common_robots_meta` option only pre-checks the
718 // meta box UI (Metasync_Post_Meta_Settings::common_robots_meta_box_display()),
719 // it does not emit a directive until the post itself is saved.
720 //
721 // Inheriting it here would tell an editor a page is noindexed when the page
722 // actually renders as indexable, which is worse than showing nothing.
723 return $out;
724 }
725
726 /**
727 * Whether our robots directives actually reach the page for this post.
728 *
729 * With no third-party SEO plugin active they always do. With one active, the
730 * emitter defers unless the conflict handler recognises a MetaSync robots value,
731 * and it only looks at `meta_robots` and `metasync_common_robots`.
732 *
733 * @param array $all_meta Result of get_post_meta($post_id).
734 * @return bool
735 */
736 private function metasync_robots_reach_the_page($all_meta)
737 {
738 if (empty($this->get_active_plugin_meta_keys(true))) {
739 return true;
740 }
741
742 if (!empty($all_meta['meta_robots'][0] ?? '')) {
743 return true;
744 }
745
746 $common = maybe_unserialize($all_meta['metasync_common_robots'][0] ?? '');
747 return is_array($common) && !empty(array_filter($common));
748 }
749
750 /**
751 * Column styles, printed only on list-table screens that show our columns.
752 *
753 * @return void
754 */
755 public function print_styles()
756 {
757 $screen = function_exists('get_current_screen') ? get_current_screen() : null;
758 if (!$screen || empty($screen->base) || $screen->base !== 'edit') {
759 return;
760 }
761 if (!self::is_supported_post_type($screen->post_type)) {
762 return;
763 }
764 ?>
765 <style>
766 .metasync-seo-col { display: inline-block; max-width: 320px; }
767 .metasync-seo-col--auto { color: #646970; font-style: italic; }
768 .metasync-seo-col--missing { color: #b32d2e; font-weight: 600; }
769 .metasync-seo-col__meta { color: #646970; font-size: 11px; }
770 </style>
771 <?php
772 }
773 }
774